Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88be0ceb41 | ||
|
|
d0bfb894f0 | ||
|
|
190bf59085 | ||
|
|
c656d2ff44 | ||
|
|
5699d17fb6 | ||
|
|
9577b829a0 | ||
|
|
382f3ca8af | ||
|
|
7f63928dd8 | ||
|
|
025ce1ffeb | ||
|
|
98ca4743e5 | ||
|
|
8fa9fb5be5 | ||
|
|
2ce145ba0f | ||
|
|
7146063da2 | ||
|
|
c5debd9c77 | ||
|
|
1d97e907e0 | ||
|
|
f5aa35ac27 | ||
|
|
b072731aed | ||
|
|
e50473c142 | ||
|
|
9d3b3ff507 | ||
|
|
2446e037ce | ||
|
|
1463394cbb | ||
|
|
63c27470f6 | ||
|
|
b6d77e485c | ||
|
|
9cf2010c70 | ||
|
|
79e6f20d3f | ||
|
|
6ec8c304a9 | ||
|
|
e56496d423 | ||
|
|
f42662c44e | ||
|
|
750ed80e02 | ||
|
|
d84f3e3f10 | ||
|
|
8a9c17046d | ||
|
|
0c0d9d0d32 | ||
|
|
a186be1da6 | ||
|
|
00f6b5af2f | ||
|
|
82aa812f98 | ||
|
|
7cb55e2416 | ||
|
|
f23391881c | ||
|
|
2eb9406f05 | ||
|
|
d5d0a5c27a | ||
|
|
5e40d06a6c | ||
|
|
8c9ce67684 | ||
|
|
da6320c04b | ||
|
|
5a998aefb9 | ||
|
|
6d300f7dba | ||
|
|
5e397195b9 |
@@ -0,0 +1,109 @@
|
||||
# Copilot Instructions — Nexus Terminal
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make deps # go mod download + npm install
|
||||
make build # production build → build/bin/nexus-terminal.app
|
||||
make dev # wails dev with hot reload
|
||||
make test # go test ./... -v -count=1
|
||||
make lint # golangci-lint run ./...
|
||||
|
||||
# Single package test
|
||||
go test ./internal/storage/ -v -count=1 -run TestProfileCRUD
|
||||
```
|
||||
|
||||
The frontend has no separate build step — `npm run build` (called by Wails) copies xterm vendor libs from `node_modules` into `frontend/src/js/lib/` and `frontend/src/css/lib/`. All frontend assets are embedded at compile time via `frontend/embed.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Wails v2 desktop app: Go backend + HTML/CSS/JS frontend rendered in a native webview (WKWebView on macOS).
|
||||
|
||||
**Go backend** (`internal/`):
|
||||
- `app/` — lifecycle bootstrap: opens SQLite, inits secret stores, wires everything together
|
||||
- `api/` — single `Service` struct bound to Wails; every public method is callable from JS as `window.go.api.Service.MethodName()`
|
||||
- `sessions/` — manages concurrent protocol sessions; owns SSH/Telnet/RAW/GitHub client lifecycles; handles auto-reconnect, login scripts, broadcast
|
||||
- `storage/` — SQLite repository (WAL mode) with embedded sequential migrations in `storage/migrations/`
|
||||
- `secrets/` — two-tier credential storage: OS keychain (preferred) → AES-256-GCM encrypted file vault (fallback)
|
||||
- `models/` — shared domain types and defaults; protocol configs are JSON-encoded in the DB
|
||||
- `sshclient/` — SSH client with agent, TOTP/2FA, and SOCKS5/HTTP proxy support
|
||||
- `telnetclient/`, `rawclient/`, `githubclient/` — protocol implementations
|
||||
- `sftpclient/` — SFTP file operations (browse, upload, download, rename, delete)
|
||||
- `sshconfig/` — OpenSSH `~/.ssh/config` parser and importer
|
||||
- `recording/` — asciicast v2 session recording and playback
|
||||
- `exportimport/` — encrypted profile bundles (.nexus format)
|
||||
- `updater/` — GitHub Releases update checker
|
||||
- `knownhosts/` — SSH host key management
|
||||
- `validation/` — profile and protocol validation
|
||||
- `audit/` — timestamped audit logging
|
||||
|
||||
**Frontend** (`frontend/`):
|
||||
- Plain HTML/CSS/JS — no framework, no bundler, no ES modules
|
||||
- `index.html` is the app shell; loads CSS then JS in strict order
|
||||
- `src/js/` — 18 modules, each IIFE-wrapped, exporting to `window.NexusTerminal`
|
||||
- `src/css/` — modular stylesheets; `tokens.css` defines all design tokens
|
||||
- Vendor libs (xterm.js, addons) live in `src/js/lib/` and `src/css/lib/` — auto-copied, gitignored
|
||||
|
||||
**Go ↔ JS communication:**
|
||||
- **Synchronous:** Frontend calls `window.go.api.Service.*()` (returns Promise)
|
||||
- **Asynchronous:** Backend pushes events via `wailsRuntime.EventsEmit()`; frontend listens with `window.runtime.EventsOn()`
|
||||
- **Bidirectional events** for secret prompts and host key verification: backend emits a request event with a `requestID`, frontend shows a modal, then emits a response event with the same ID. The Go side blocks on a channel keyed by that ID.
|
||||
|
||||
Key events: `session:data`, `session:state`, `session:closed`, `session:raw:data`, `session:github:repos`, `session:secret:prompt`/`session:secret:response`, `session:hostkey:verify`/`session:hostkey:response`.
|
||||
|
||||
## WKWebView Constraints (Critical)
|
||||
|
||||
The macOS webview silently blocks or breaks several standard web APIs. These constraints affect every frontend change:
|
||||
|
||||
1. **No `window.confirm()`, `window.prompt()`, `window.alert()`** — they either return false/null silently or do nothing. All user prompts must use the custom modal system (DOM-based backdrop + modal with `is-visible` class toggling).
|
||||
|
||||
2. **No ES modules** — `<script type="module">` fails silently. All JS files use plain `<script>` tags and IIFE wrappers: `(function() { ... })();`
|
||||
|
||||
3. **Click swallowing on text-selectable elements** — WKWebView initiates a text selection gesture on `mousedown` for `<div>` elements, which swallows the first `click` event (appears to require double-click). Fix: add `mousedown` handler with `e.preventDefault()` on any clickable non-input element (section headers, list items, etc.).
|
||||
|
||||
4. **Modal visibility pattern** — both `.modal-backdrop` AND `.modal` elements start with `opacity: 0; visibility: hidden` in CSS. You must add the `is-visible` class to **both** elements, not just the backdrop.
|
||||
|
||||
5. **Wails runtime injection** — `window.go` and `window.runtime` are injected by Wails before scripts execute. Do NOT add explicit script tags for Wails scripts. The frontend polls for their availability at startup (max 5s).
|
||||
|
||||
## Conventions
|
||||
|
||||
**Language:** All code comments in British English (colour, initialise, behaviour, etc.).
|
||||
|
||||
**Go patterns:**
|
||||
- String-typed enums: `type Protocol string` with `const ProtocolSSH Protocol = "ssh"` etc.
|
||||
- Protocol configs are pointer fields on `Profile` (`SSH *SSHConfig`, `Telnet *TelnetConfig`, etc.) — nil when not applicable
|
||||
- `NewProfile(protocol)` factory in `defaults.go` populates sensible defaults per protocol
|
||||
- Storage uses `nullableJSON()` helper to write nil for unused config columns
|
||||
- Tests use `t.TempDir()` for isolation, `t.Fatalf()` for setup failures
|
||||
|
||||
**Frontend patterns:**
|
||||
- Every JS file is IIFE-wrapped and exports to `window.NexusTerminal` namespace
|
||||
- Script load order matters (defined in `index.html`) — utils → state → notifications → sidebar → tabs → themes → terminal → profiles → raw → github → settings → commands → secrets → recordings → sftp → splitpane → snippets → app
|
||||
- DOM helpers: `createElement(tag, className, attrs)` from utils.js (supports `textContent`, `innerHTML`, `dataset`, `on*` event handlers)
|
||||
- Profile editor uses `_renderSection(title, fields)` pattern with `_field(type, name, label, value, opts)` helper for form generation
|
||||
- Login script editor uses `_renderLoginScriptSection(prefix, steps)` for expect-style step lists
|
||||
- TOTP section uses `_renderTOTPSection(profile)` with encrypted vault storage
|
||||
- `protocolColour()` returns CSS class names like `proto-ssh`; tab badges use `badge-ssh` class naming (different pattern)
|
||||
|
||||
**CSS patterns:**
|
||||
- Design tokens in `tokens.css`: `--bg-base`, `--bg-surface`, `--bg-elevated`, `--text-primary`, `--accent`, `--protocol-ssh`, etc.
|
||||
- Dark charcoal palette — not Material Design literally, but Material-inspired layering
|
||||
- Protocol colours: SSH=teal `#4ecdc4`, Telnet=amber `#ffa726`, RAW=purple `#ab47bc`, GitHub=indigo `#7c8aff`
|
||||
|
||||
**Adding a new protocol:**
|
||||
1. Add `Protocol` constant + config struct in `models/models.go`
|
||||
2. Add defaults in `models/defaults.go` `NewProfile()` switch
|
||||
3. Create client package in `internal/<proto>client/`
|
||||
4. Add `connect<Proto>()` in `sessions/manager.go` + case in `Connect()` switch
|
||||
5. Add API methods in `api/api.go` if needed
|
||||
6. Add DB migration in `storage/migrations/` (update CHECK constraint, add config column)
|
||||
7. Update `storage/store.go`: all SELECT/INSERT/UPDATE queries + both scan functions
|
||||
8. Frontend: add DEFAULTS in `profiles.js`, render sections, collect form values, protocol tab
|
||||
9. Frontend: add viewer in new JS file (IIFE-wrapped), include in `index.html`, wire in `app.js`
|
||||
10. Add CSS: protocol colour token, sidebar indicator, tab badge, viewer styles
|
||||
|
||||
**Database migrations:**
|
||||
- Numbered SQL files: `001_initial.sql`, `002_github_protocol.sql`, etc.
|
||||
- Each must `INSERT INTO schema_version (version, applied_at) VALUES (N, datetime('now'))` at the end
|
||||
- SQLite has no `ALTER COLUMN` — use the recreate-table pattern (CREATE new → INSERT INTO new SELECT FROM old → DROP old → ALTER TABLE RENAME)
|
||||
- Every query in `store.go` that touches the profiles table must be updated when columns change (there are ~8 SELECT column lists + 2 scan functions)
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Release Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: darwin/amd64
|
||||
runner: macos-13
|
||||
os: darwin
|
||||
arch: amd64
|
||||
archive_type: zip
|
||||
- platform: darwin/arm64
|
||||
runner: macos-14
|
||||
os: darwin
|
||||
arch: arm64
|
||||
archive_type: zip
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
os: linux
|
||||
arch: amd64
|
||||
archive_type: tar
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm64
|
||||
os: linux
|
||||
arch: arm64
|
||||
archive_type: tar
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
os: windows
|
||||
arch: amd64
|
||||
archive_type: zip
|
||||
- platform: windows/arm64
|
||||
runner: ubuntu-latest
|
||||
os: windows
|
||||
arch: arm64
|
||||
archive_type: zip
|
||||
cross: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@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
|
||||
@@ -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,3 +58,8 @@ vault.json
|
||||
*.pem
|
||||
*.key
|
||||
!build/appicon.png
|
||||
|
||||
|
||||
# Builds
|
||||
build/releases/v*
|
||||
build/releases/manifest*
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package help
|
||||
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package release help
|
||||
|
||||
# Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin.
|
||||
WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails)
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),)
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
@@ -20,7 +22,7 @@ dev: deps
|
||||
|
||||
# Production build
|
||||
build: deps
|
||||
$(WAILS) build
|
||||
$(WAILS) build -ldflags "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@@ -47,6 +49,10 @@ lint:
|
||||
package: build
|
||||
@echo "Binary built at build/bin/"
|
||||
|
||||
# Build and package release artefacts for all platforms
|
||||
release:
|
||||
./release.sh
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Nexus Terminal — Build Targets"
|
||||
@@ -59,4 +65,5 @@ 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"
|
||||
|
||||
@@ -1,399 +1,53 @@
|
||||
# Nexus Terminal
|
||||
|
||||
**A premium desktop SSH, Telnet, and RAW TCP client** built with [Wails v2](https://wails.io/) (Go backend, web frontend).
|
||||
**A desktop client for SSH, Telnet, RAW TCP, and GitHub — with secure credential handling and a focused, multi-session workflow.**
|
||||
|
||||
Nexus Terminal provides a modern, tabbed interface for managing remote connections over SSH, Telnet, and raw TCP — with encrypted credential storage, host key verification, audit logging, and a dark Material-inspired design.
|
||||
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence.
|
||||
|
||||
---
|
||||
## Highlights
|
||||
|
||||
## Features
|
||||
|
||||
### Implemented
|
||||
|
||||
- ✅ **SSH connections** — password, public key, and keyboard-interactive authentication
|
||||
- ✅ **Telnet** — option negotiation, character encoding, local echo, configurable line modes
|
||||
- ✅ **RAW TCP** — binary-safe text, hex, and split views with payload presets
|
||||
- ✅ **Saved connection profiles** — tags, folders, environments, favourites
|
||||
- ✅ **Encrypted secret storage** — AES-256-GCM with Argon2id key derivation
|
||||
- ✅ **OS keychain integration** — macOS Keychain, Linux Secret Service, Windows Credential Manager
|
||||
- ✅ **Known hosts verification** — configurable policies (strict / ask / accept-new / insecure)
|
||||
- ✅ **Tabbed multi-session management** — rename, reorder, pin, context menu
|
||||
- ✅ **xterm.js terminal** — search, web links, fit-to-container
|
||||
- ✅ **Profile import/export** — JSON format, secrets excluded by default
|
||||
- ✅ **Command palette** — quick access via Ctrl+K / Cmd+K
|
||||
- ✅ **Dark charcoal Material-inspired UI**
|
||||
- ✅ **Audit logging** — timestamped records of connections and actions
|
||||
- ✅ **Input validation** — comprehensive profile and protocol validation
|
||||
|
||||
### In Progress
|
||||
|
||||
- 🔨 **Jump host / bastion support** — architecture in place, single-hop implemented
|
||||
- 🔨 **Session transcript export** — partial implementation
|
||||
|
||||
### Planned
|
||||
|
||||
- 📋 Port forwarding (extension point architected)
|
||||
- 📋 SFTP file transfer (extension point architected)
|
||||
- 📋 Split panes
|
||||
- 📋 Theme customisation
|
||||
- 📋 OpenSSH config import (partial parser exists)
|
||||
- 📋 Encrypted profile export
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
> _Screenshots will be added once the UI is feature-complete._
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|------------|---------|-------------------------------------------------------------------|
|
||||
| Go | 1.21+ | [golang.org/dl](https://golang.org/dl/) |
|
||||
| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
|
||||
| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
|
||||
|
||||
> **Note:** On Linux you may also need GTK, WebKit, and related system libraries.
|
||||
> Run `wails doctor` to verify your environment.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/nexus-terminal.git
|
||||
cd nexus-terminal
|
||||
make deps # install Go modules and frontend npm packages
|
||||
make dev # launch in development mode with hot reload
|
||||
```
|
||||
|
||||
Or using the shell script:
|
||||
|
||||
```bash
|
||||
./build.sh deps
|
||||
./build.sh dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
| Command | Description |
|
||||
|--------------------|------------------------------------------|
|
||||
| `make deps` | Install all dependencies |
|
||||
| `make dev` | Run in development mode with hot reload |
|
||||
| `make build` | Production build |
|
||||
| `make test` | Run Go tests |
|
||||
| `make test-cover` | Run tests with HTML coverage report |
|
||||
| `make clean` | Remove build artefacts and caches |
|
||||
| `make lint` | Run golangci-lint |
|
||||
| `make package` | Build and package for distribution |
|
||||
| `make help` | Show all available targets |
|
||||
|
||||
The production binary is written to `build/bin/NexusTerminal`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
copilot-ssh-client/
|
||||
├── cmd/
|
||||
│ └── app/
|
||||
│ └── main.go # Application entry point (Wails bootstrap)
|
||||
├── frontend/
|
||||
│ ├── dist/
|
||||
│ │ └── index.html # HTML entry point (embedded by Wails)
|
||||
│ ├── scripts/
|
||||
│ │ ├── build.js # Frontend build & watch script
|
||||
│ │ └── copy-assets.js # Copies xterm assets from node_modules
|
||||
│ ├── src/
|
||||
│ │ ├── assets/icons/ # Application icons
|
||||
│ │ ├── components/ # UI components (planned)
|
||||
│ │ ├── css/ # Stylesheets
|
||||
│ │ │ └── lib/ # Vendored CSS (xterm.css — auto-copied)
|
||||
│ │ └── js/ # JavaScript source
|
||||
│ │ └── lib/ # Vendored JS (xterm — auto-copied)
|
||||
│ └── package.json
|
||||
├── internal/
|
||||
│ ├── api/ # Wails-bound API methods (planned)
|
||||
│ ├── app/
|
||||
│ │ └── app.go # Application lifecycle management
|
||||
│ ├── audit/ # Audit log service (planned)
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Platform-specific path resolution
|
||||
│ ├── knownhosts/ # Known hosts management (planned)
|
||||
│ ├── models/
|
||||
│ │ ├── models.go # Core domain types (Profile, Session, etc.)
|
||||
│ │ └── defaults.go # Default values and factory functions
|
||||
│ ├── preferences/ # Preferences service (planned)
|
||||
│ ├── rawclient/
|
||||
│ │ └── encoding.go # Hex, escaped, and character encoding utilities
|
||||
│ ├── secrets/
|
||||
│ │ ├── keychain.go # OS keychain integration
|
||||
│ │ ├── redact.go # Secret redaction for logs and UI
|
||||
│ │ └── secrets.go # AES-256-GCM encrypted vault
|
||||
│ ├── sessions/ # Session lifecycle management (planned)
|
||||
│ ├── sshclient/
|
||||
│ │ └── hostkey.go # SSH host key verification with policies
|
||||
│ ├── storage/
|
||||
│ │ ├── migrations/
|
||||
│ │ │ ├── 001_initial.sql # Initial database schema
|
||||
│ │ │ └── migrations.go # Embedded migration files
|
||||
│ │ └── storage.go # SQLite persistence layer
|
||||
│ ├── telnetclient/ # Telnet client (planned)
|
||||
│ └── validation/
|
||||
│ └── validation.go # Profile and protocol validation
|
||||
├── build.sh # Shell build script for CI
|
||||
├── Makefile # Make-based build targets
|
||||
├── wails.json # Wails v2 project configuration
|
||||
├── go.mod # Go module definition
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Key Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|--------------------|------------------------------------------------------------------------|
|
||||
| `cmd/app` | Wails application bootstrap — window size, title, lifecycle hooks |
|
||||
| `internal/app` | Wires together all subsystems; manages startup and shutdown |
|
||||
| `internal/config` | Resolves platform-specific data, database, and vault paths |
|
||||
| `internal/models` | Domain types shared across all packages — profiles, sessions, enums |
|
||||
| `internal/storage` | SQLite repository with WAL mode, migrations, and import/export |
|
||||
| `internal/secrets` | Encrypted vault (AES-256-GCM + Argon2id) and OS keychain abstraction |
|
||||
| `internal/sshclient` | SSH host key verification with strict / ask / accept-new policies |
|
||||
| `internal/rawclient` | Hex dump, escape parsing, and character encoding for raw TCP |
|
||||
| `internal/validation` | Multi-field validation for profiles and protocol-specific configs |
|
||||
|
||||
### Frontend–Backend Interaction
|
||||
|
||||
Wails v2 exposes Go methods to the frontend as JavaScript functions. The `App` struct (and future API types) are bound in `main.go`. Frontend calls arrive as synchronous RPC; asynchronous data (terminal output, session events) flows via the Wails event system.
|
||||
|
||||
```
|
||||
┌─────────────┐ Wails bindings ┌──────────────┐
|
||||
│ Frontend │ ◄────────────────► │ Go backend │
|
||||
│ (xterm.js) │ Wails events │ (App struct) │
|
||||
└─────────────┘ └──────────────┘
|
||||
│ │
|
||||
│ xterm.js writes │ golang.org/x/crypto/ssh
|
||||
│ to terminal │ net.Dial (telnet, raw)
|
||||
▼ ▼
|
||||
Browser DOM Remote hosts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Secret Storage
|
||||
|
||||
Nexus Terminal uses a two-tier approach to credential storage:
|
||||
|
||||
1. **OS keychain (preferred)** — macOS Keychain, Linux Secret Service (via D-Bus), Windows Credential Manager. Probed at startup; used automatically when available.
|
||||
2. **Encrypted file vault (fallback)** — AES-256-GCM encrypted JSON file at `<data-dir>/vault`. Requires a master password to unlock.
|
||||
|
||||
### Encryption Details
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------------|-----------------------------------|
|
||||
| Cipher | AES-256-GCM |
|
||||
| Key derivation | Argon2id (time=3, mem=64 KB, threads=4) |
|
||||
| Key length | 256 bits (32 bytes) |
|
||||
| Salt | 32 random bytes (per vault) |
|
||||
| Nonce | Random, prepended to ciphertext |
|
||||
|
||||
### Threat Model
|
||||
|
||||
**Protects against:**
|
||||
- Secrets exposed via disk theft or backup exfiltration
|
||||
- Accidental secret leakage in logs, exports, or UI (redaction layer)
|
||||
- Credential access without the master password
|
||||
|
||||
**Does NOT protect against:**
|
||||
- A compromised operating system or process with memory access
|
||||
- Memory forensics whilst the vault is unlocked
|
||||
- Keyloggers capturing the master password
|
||||
- Weak master passwords (no strength enforcement currently)
|
||||
|
||||
### Secret Key Format
|
||||
|
||||
Secrets are keyed by profile: `profile:<uuid>:password`, `profile:<uuid>:passphrase`. The `ListKeys()` method exposes keys but never values.
|
||||
|
||||
### Export Handling
|
||||
|
||||
Profile exports exclude secrets by default. The JSON export format contains connection metadata but no passwords or private key passphrases unless explicitly requested (future encrypted export).
|
||||
|
||||
---
|
||||
|
||||
## Protocol Support
|
||||
|
||||
| Feature | SSH | Telnet | RAW TCP |
|
||||
|----------------------------------|:---:|:------:|:-------:|
|
||||
| Password authentication | ✅ | — | — |
|
||||
| Public key authentication | ✅ | — | — |
|
||||
| Keyboard-interactive auth | ✅ | — | — |
|
||||
| Host key verification | ✅ | — | — |
|
||||
| Keepalive | ✅ | — | — |
|
||||
| Option negotiation | — | ✅ | — |
|
||||
| Local echo | — | ✅ | — |
|
||||
| Character encoding (UTF-8, etc.) | — | ✅ | ✅ |
|
||||
| Text view | ✅ | ✅ | ✅ |
|
||||
| Hex view | — | — | ✅ |
|
||||
| Split (text + hex) view | — | — | ✅ |
|
||||
| Payload presets | — | — | ✅ |
|
||||
| Configurable line endings | — | ✅ | ✅ |
|
||||
| Auto-reconnect | — | — | ✅ |
|
||||
| Jump hosts / bastions | 🔨 | — | — |
|
||||
| Port forwarding | 📋 | — | — |
|
||||
| SFTP | 📋 | — | — |
|
||||
|
||||
**Legend:** ✅ implemented · 🔨 in progress · 📋 planned · — not applicable
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
### Database
|
||||
|
||||
Nexus Terminal stores profiles, known hosts, preferences, and audit entries in a SQLite database with WAL (Write-Ahead Logging) mode for concurrent read performance.
|
||||
|
||||
| Platform | Default location |
|
||||
|----------|--------------------------------------------------------|
|
||||
| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` |
|
||||
| Linux | `~/.config/NexusTerminal/nexus.db` |
|
||||
| Windows | `%APPDATA%\NexusTerminal\nexus.db` |
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
Migrations are embedded at compile time via `embed.FS` and applied sequentially at startup. Each migration records its version in the `schema_version` table to prevent re-application.
|
||||
|
||||
Current schema (v001):
|
||||
- `profiles` — connection profiles with JSON-encoded protocol configs
|
||||
- `known_hosts` — SSH host key fingerprints with trust state
|
||||
- `preferences` — key-value application preferences (JSON)
|
||||
- `audit_log` — timestamped action records
|
||||
- `schema_version` — applied migration tracking
|
||||
|
||||
---
|
||||
|
||||
## Import / Export
|
||||
|
||||
### JSON Export
|
||||
|
||||
```bash
|
||||
# Via the application UI: Profiles → Export
|
||||
```
|
||||
|
||||
The export format is a JSON array of profile objects. Each profile includes connection settings, terminal preferences, and protocol-specific configuration — but **not** secrets (passwords, passphrases) by default.
|
||||
|
||||
### Import Strategies
|
||||
|
||||
| Strategy | Behaviour |
|
||||
|-------------|---------------------------------------------------------------|
|
||||
| `skip` | Skip profiles whose ID already exists |
|
||||
| `overwrite` | Replace existing profiles with the imported version |
|
||||
| `duplicate` | Import as a new profile with a fresh UUID |
|
||||
|
||||
### Format
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2024-01-15T10:30:00Z",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"display_name": "Production Server",
|
||||
"protocol": "ssh",
|
||||
"host": "prod.example.com",
|
||||
"port": 22,
|
||||
"username": "deploy",
|
||||
"tags": ["production", "web"],
|
||||
"folder": "Servers",
|
||||
"ssh_config": { "..." : "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Adding a New Protocol
|
||||
|
||||
1. Create a new package under `internal/` (e.g., `internal/mystclient/`).
|
||||
2. Define a protocol-specific config struct in `internal/models/models.go`.
|
||||
3. Add the protocol constant to the `Protocol` type.
|
||||
4. Update `internal/models/defaults.go` with default values and a factory case in `NewProfile()`.
|
||||
5. Add validation rules in `internal/validation/validation.go`.
|
||||
6. Wire the new client into `internal/app/app.go` and `internal/sessions/`.
|
||||
7. Update the database migration if new columns are required.
|
||||
|
||||
### Adding Tests
|
||||
|
||||
Tests live alongside the code they exercise:
|
||||
|
||||
```bash
|
||||
go test ./internal/validation/ -v # run validation tests
|
||||
go test ./internal/secrets/ -v # run secrets tests
|
||||
go test ./... -v -count=1 # run all tests
|
||||
go test ./... -coverprofile=coverage.out # with coverage
|
||||
```
|
||||
|
||||
Use `_test.go` files in the same package for white-box tests, or in a `_test` package for black-box tests. Table-driven tests are preferred.
|
||||
|
||||
### Event System
|
||||
|
||||
The Wails runtime provides an event bus for asynchronous communication:
|
||||
|
||||
- **Backend → Frontend:** `runtime.EventsEmit(ctx, "event:name", payload)`
|
||||
- **Frontend → Backend:** `runtime.EventsOn("event:name", callback)`
|
||||
|
||||
Key events:
|
||||
- `backend:ready` — emitted when the Go backend has finished initialisation
|
||||
- `session:data` — terminal output data for a specific session
|
||||
- `session:state` — session state changes (connecting, connected, disconnected, error)
|
||||
|
||||
---
|
||||
|
||||
## Limitations and Future Work
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Single-hop jump hosts only** — the jump host architecture supports chaining, but only one hop is currently implemented.
|
||||
- **No SFTP** — the extension point is designed but file transfer is not yet available.
|
||||
- **No port forwarding** — local/remote/dynamic forwarding is planned but not implemented.
|
||||
- **No split panes** — single terminal per tab; split view is planned.
|
||||
- **No theme customisation** — the dark charcoal theme is currently the only option.
|
||||
- **No master password strength enforcement** — the vault accepts any master password.
|
||||
- **Argon2id memory parameter is conservative** — 64 KB is low for production use; will be increased in a future release.
|
||||
- **No SSH agent forwarding** — authentication uses direct key/password only.
|
||||
- **Linux recursive file watch** — the development watch script may not detect all changes on Linux due to `fs.watch` limitations.
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
- Increase Argon2id memory to 64 MB+ for stronger key derivation
|
||||
- SFTP file browser and transfer
|
||||
- Local, remote, and dynamic port forwarding
|
||||
- Multi-hop jump host chains
|
||||
- Split pane terminal layout
|
||||
- Custom themes and colour schemes
|
||||
- OpenSSH `~/.ssh/config` import
|
||||
- SSH, Telnet, RAW TCP, 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
|
||||
- Session recording and playback
|
||||
- SSH agent forwarding
|
||||
- Encrypted profile export/import
|
||||
- Master password strength meter
|
||||
- Automatic update checking
|
||||
- Secure credential storage with OS keychain and encrypted vault fallback
|
||||
|
||||
---
|
||||
## Quick start
|
||||
|
||||
## Licence
|
||||
1. Launch Nexus Terminal.
|
||||
2. Click **New** in the sidebar and create a connection profile.
|
||||
3. Click **Connect** to open a session in a new tab.
|
||||
|
||||
This project is licensed under the MIT Licence. See [LICENCE](LICENCE) for details.
|
||||
Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connect.
|
||||
|
||||
---
|
||||
If a session drops, use the **Reconnect** button in the disconnected overlay.
|
||||
|
||||
<sub>Built with ❤ using [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/).</sub>
|
||||
## Documentation
|
||||
|
||||
- Product overview: [docs/overview.md](docs/overview.md)
|
||||
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
|
||||
- User manual: [docs/user-manual.md](docs/user-manual.md)
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
||||
|
||||
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||
| ----------------- | ---------------------- | --------------------------- |
|
||||
| Cmd + K | Ctrl + K | Open command palette |
|
||||
| Cmd + N | Ctrl + N | New connection |
|
||||
| Cmd + W | Ctrl + W | Close current tab |
|
||||
| Cmd + Tab | Ctrl + Tab | Next tab |
|
||||
| Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab |
|
||||
| Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number |
|
||||
| Cmd + , | Ctrl + , | Open settings |
|
||||
| Cmd + F | Ctrl + F | Find in terminal |
|
||||
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
|
||||
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
|
||||
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
|
||||
| Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode |
|
||||
| Cmd + Shift + S | Ctrl + Shift + S | Open snippet library |
|
||||
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
|
||||
| F11 | F11 | Toggle fullscreen |
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/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_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 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
|
||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
else
|
||||
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
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 "$@"
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,23 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
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; }
|
||||
@@ -62,7 +79,9 @@ build() {
|
||||
check_deps
|
||||
install_deps
|
||||
log "Building production binary…"
|
||||
wails build
|
||||
local version
|
||||
version="$(build_version)"
|
||||
wails build -ldflags "$(build_ldflags "${version}")"
|
||||
log "Build complete: build/bin/"
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.andydixon.nexusterminal</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||
<defs>
|
||||
<!-- Background gradient: deep navy -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0D1117;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1A1F2E;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Cyan glow for terminal elements -->
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00D4FF;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#00FF88;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Inner glow effect for terminal text -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Subtle glow for grid lines -->
|
||||
<filter id="softGlow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Clip to rounded rect -->
|
||||
<clipPath id="roundedClip">
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded rectangle background -->
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Clipped content group -->
|
||||
<g clip-path="url(#roundedClip)">
|
||||
|
||||
<!-- Subtle grid lines (circuit board / network feel) -->
|
||||
<g opacity="0.07" stroke="#00D4FF" stroke-width="1.5" filter="url(#softGlow)">
|
||||
<!-- Horizontal lines -->
|
||||
<line x1="0" y1="200" x2="1024" y2="200"/>
|
||||
<line x1="0" y1="350" x2="1024" y2="350"/>
|
||||
<line x1="0" y1="500" x2="1024" y2="500"/>
|
||||
<line x1="0" y1="650" x2="1024" y2="650"/>
|
||||
<line x1="0" y1="800" x2="1024" y2="800"/>
|
||||
<!-- Vertical lines -->
|
||||
<line x1="200" y1="0" x2="200" y2="1024"/>
|
||||
<line x1="350" y1="0" x2="350" y2="1024"/>
|
||||
<line x1="500" y1="0" x2="500" y2="1024"/>
|
||||
<line x1="650" y1="0" x2="650" y2="1024"/>
|
||||
<line x1="800" y1="0" x2="800" y2="1024"/>
|
||||
</g>
|
||||
|
||||
<!-- Network node dots at grid intersections (subtle) -->
|
||||
<g opacity="0.12" fill="#00D4FF">
|
||||
<circle cx="200" cy="200" r="4"/>
|
||||
<circle cx="350" cy="200" r="4"/>
|
||||
<circle cx="500" cy="200" r="4"/>
|
||||
<circle cx="650" cy="200" r="4"/>
|
||||
<circle cx="800" cy="200" r="4"/>
|
||||
<circle cx="200" cy="350" r="4"/>
|
||||
<circle cx="350" cy="350" r="4"/>
|
||||
<circle cx="650" cy="350" r="4"/>
|
||||
<circle cx="800" cy="350" r="4"/>
|
||||
<circle cx="200" cy="500" r="4"/>
|
||||
<circle cx="800" cy="500" r="4"/>
|
||||
<circle cx="200" cy="650" r="4"/>
|
||||
<circle cx="350" cy="650" r="4"/>
|
||||
<circle cx="650" cy="650" r="4"/>
|
||||
<circle cx="800" cy="650" r="4"/>
|
||||
<circle cx="200" cy="800" r="4"/>
|
||||
<circle cx="350" cy="800" r="4"/>
|
||||
<circle cx="500" cy="800" r="4"/>
|
||||
<circle cx="650" cy="800" r="4"/>
|
||||
<circle cx="800" cy="800" r="4"/>
|
||||
</g>
|
||||
|
||||
<!-- Central terminal window panel -->
|
||||
<rect x="112" y="240" width="800" height="544" rx="16" ry="16"
|
||||
fill="#0A0E17" fill-opacity="0.85"
|
||||
stroke="#00D4FF" stroke-width="1.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Terminal title bar -->
|
||||
<rect x="112" y="240" width="800" height="52" rx="16" ry="16"
|
||||
fill="#161B27" fill-opacity="0.9"/>
|
||||
<!-- Fix bottom corners of title bar -->
|
||||
<rect x="112" y="268" width="800" height="24"
|
||||
fill="#161B27" fill-opacity="0.9"/>
|
||||
|
||||
<!-- Window control dots -->
|
||||
<circle cx="162" cy="266" r="10" fill="#FF5F57"/>
|
||||
<circle cx="198" cy="266" r="10" fill="#FFBD2E"/>
|
||||
<circle cx="234" cy="266" r="10" fill="#28C840"/>
|
||||
|
||||
<!-- Title bar text: "nexus ssh" -->
|
||||
<text x="512" y="273" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="20" fill="#8892A4" text-anchor="middle" dominant-baseline="middle"
|
||||
letter-spacing="3">nexus — ssh</text>
|
||||
|
||||
<!-- Terminal separator line -->
|
||||
<line x1="112" y1="292" x2="912" y2="292" stroke="#00D4FF" stroke-width="1" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- MAIN TERMINAL CONTENT -->
|
||||
<!-- Line 1: prompt line -->
|
||||
<text x="152" y="360" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="38" fill="#8892A4" filter="url(#softGlow)">user@nexus:~$</text>
|
||||
|
||||
<!-- THE BIG PROMPT SYMBOL: ">_" — cyan glowing, centered -->
|
||||
<text x="512" y="560" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="260" font-weight="bold"
|
||||
fill="url(#cyanGrad)"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
filter="url(#glow)">>_</text>
|
||||
|
||||
<!-- Blinking cursor line at bottom of terminal -->
|
||||
<rect x="152" y="726" width="22" height="36" rx="2" ry="2"
|
||||
fill="#00D4FF" opacity="0.9" filter="url(#softGlow)"/>
|
||||
|
||||
<!-- Bottom status line -->
|
||||
<text x="740" y="748" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="22" fill="#00FF88" fill-opacity="0.6" text-anchor="end">SSH v2 AES-256</text>
|
||||
|
||||
</g>
|
||||
|
||||
<!-- Outer border glow (subtle cyan rim) -->
|
||||
<rect x="2" y="2" width="1020" height="1020" rx="223" ry="223"
|
||||
fill="none"
|
||||
stroke="url(#cyanGrad)" stroke-width="4" stroke-opacity="0.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
@@ -1,82 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nexus Terminal</title>
|
||||
|
||||
<!-- Vendor styles -->
|
||||
<link rel="stylesheet" href="/src/css/lib/xterm.css">
|
||||
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
|
||||
|
||||
<!-- Design system -->
|
||||
<link rel="stylesheet" href="/src/css/tokens.css">
|
||||
<link rel="stylesheet" href="/src/css/base.css">
|
||||
<link rel="stylesheet" href="/src/css/components.css">
|
||||
<link rel="stylesheet" href="/src/css/layout.css">
|
||||
<link rel="stylesheet" href="/src/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/src/css/tabs.css">
|
||||
<link rel="stylesheet" href="/src/css/terminal.css">
|
||||
<link rel="stylesheet" href="/src/css/forms.css">
|
||||
<link rel="stylesheet" href="/src/css/modals.css">
|
||||
<link rel="stylesheet" href="/src/css/github.css">
|
||||
</head>
|
||||
<body>
|
||||
<link rel="stylesheet" href="/src/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/src/css/base.css" />
|
||||
<link rel="stylesheet" href="/src/css/components.css" />
|
||||
<link rel="stylesheet" href="/src/css/layout.css" />
|
||||
<link rel="stylesheet" href="/src/css/sidebar.css" />
|
||||
<link rel="stylesheet" href="/src/css/tabs.css" />
|
||||
<link rel="stylesheet" href="/src/css/terminal.css" />
|
||||
<link rel="stylesheet" href="/src/css/splitpane.css" />
|
||||
<link rel="stylesheet" href="/src/css/recordings.css" />
|
||||
<link rel="stylesheet" href="/src/css/sftp.css" />
|
||||
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.css" />
|
||||
</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-icon btn-sm" id="btn-import" title="Import">
|
||||
<!-- Upload icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
|
||||
<!-- Cog icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
||||
<div class="sidebar-footer">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-new-connection"
|
||||
title="New Connection"
|
||||
>
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-import"
|
||||
title="Import Profiles"
|
||||
>
|
||||
<!-- Upload icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-export"
|
||||
title="Export Profiles"
|
||||
>
|
||||
<!-- Download icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-recordings"
|
||||
title="Session Recordings"
|
||||
>
|
||||
<!-- Film icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-settings"
|
||||
title="Settings"
|
||||
>
|
||||
<!-- Cog icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
||||
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
|
||||
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
|
||||
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
|
||||
@@ -92,62 +176,108 @@
|
||||
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
|
||||
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
|
||||
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
|
||||
1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
1.65 0 0 0-1.51 1z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Tab bar — session tabs
|
||||
============================================================ -->
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<div class="tab-list" id="tab-list">
|
||||
<!-- Tabs are injected dynamically by JS -->
|
||||
</div>
|
||||
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<div class="tab-list" id="tab-list">
|
||||
<!-- Tabs are injected dynamically by JS -->
|
||||
</div>
|
||||
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
||||
<button
|
||||
class="tab-broadcast"
|
||||
id="btn-broadcast"
|
||||
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
|
||||
>
|
||||
<!-- Broadcast icon (loudspeaker) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
|
||||
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
|
||||
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Workspace — terminal area
|
||||
============================================================ -->
|
||||
<main class="workspace" id="workspace">
|
||||
<!-- Empty state — shown when no sessions are active -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<!-- Terminal icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No active sessions</h2>
|
||||
<p>Select a connection from the sidebar or create a new one to get started.</p>
|
||||
<div class="empty-state-actions">
|
||||
<button class="btn btn-primary" id="btn-empty-connect">New Connection</button>
|
||||
<button class="btn btn-secondary" id="btn-empty-quick">Quick Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="workspace" id="workspace">
|
||||
<!-- Empty state — shown when no sessions are active -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<!-- Terminal icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No active sessions</h2>
|
||||
<p>
|
||||
Select a connection from the sidebar or create a new one to get
|
||||
started.
|
||||
</p>
|
||||
<div class="empty-state-actions">
|
||||
<button class="btn btn-primary" id="btn-empty-connect">
|
||||
New Connection
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="btn-empty-quick">
|
||||
Quick Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session panels are injected here dynamically by tabs.js -->
|
||||
<div id="panel-container" class="panel-container"></div>
|
||||
</main>
|
||||
<!-- Session panels are injected here dynamically by tabs.js -->
|
||||
<div id="panel-container" class="panel-container"></div>
|
||||
</main>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Status bar
|
||||
============================================================ -->
|
||||
<footer class="status-bar" id="status-bar">
|
||||
<span id="status-connection">
|
||||
<span class="status-dot is-disconnected"></span>
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<span id="status-vault">🔒 Locked</span>
|
||||
</footer>
|
||||
<footer class="status-bar" id="status-bar">
|
||||
<span id="status-connection">
|
||||
<span class="status-dot is-disconnected"></span>
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button
|
||||
class="status-update is-hidden"
|
||||
id="status-update"
|
||||
type="button"
|
||||
>
|
||||
Update available
|
||||
</button>
|
||||
<span id="status-vault">🔒 Locked</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Modal container (dynamically populated) -->
|
||||
@@ -160,6 +290,7 @@
|
||||
<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) -->
|
||||
@@ -168,6 +299,7 @@
|
||||
<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>
|
||||
@@ -175,6 +307,10 @@
|
||||
<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/app.js"></script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
@@ -42,6 +43,16 @@
|
||||
"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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
a25ea4f4df93e50eb6c41092b5187a9d
|
||||
ce0d75b28075a7688ba4535c94b4784c
|
||||
@@ -4,34 +4,38 @@
|
||||
*
|
||||
* Run automatically via the postinstall hook in package.json.
|
||||
*/
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const frontendDir = path.resolve(__dirname, '..');
|
||||
const frontendDir = path.resolve(__dirname, "..");
|
||||
|
||||
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
|
||||
const assets = [
|
||||
{
|
||||
src: 'xterm/css/xterm.css',
|
||||
dest: 'src/css/lib/xterm.css',
|
||||
src: "xterm/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-web-links/lib/xterm-addon-web-links.js',
|
||||
dest: 'src/js/lib/xterm-addon-web-links.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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -49,7 +53,7 @@ function ensureDir(filePath) {
|
||||
* Copies a single asset, logging the result.
|
||||
*/
|
||||
function copyAsset(entry) {
|
||||
const srcPath = path.join(frontendDir, 'node_modules', entry.src);
|
||||
const srcPath = path.join(frontendDir, "node_modules", entry.src);
|
||||
const destPath = path.join(frontendDir, entry.dest);
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
@@ -65,7 +69,7 @@ function copyAsset(entry) {
|
||||
|
||||
// --- Main ---
|
||||
|
||||
console.log('[copy-assets] Copying xterm library files…');
|
||||
console.log("[copy-assets] Copying xterm library files…");
|
||||
|
||||
let failures = 0;
|
||||
for (const entry of assets) {
|
||||
@@ -80,4 +84,4 @@ if (failures > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[copy-assets] All assets copied successfully.');
|
||||
console.log("[copy-assets] All assets copied successfully.");
|
||||
|
||||
@@ -8,132 +8,132 @@
|
||||
Form panel (profile editor container)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-panel-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.form-panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form sections (disclosure-based grouping)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-section {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: var(--bg-elevated);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: var(--bg-elevated);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-section-header:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.form-section-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-section.is-open .form-section-chevron {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.form-section-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-section-body,
|
||||
.form-section-content {
|
||||
padding: var(--space-4);
|
||||
display: none;
|
||||
padding: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-section.is-open .form-section-body,
|
||||
.form-section.is-open .form-section-content {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Field grid (two-column layout for compact forms)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Single-column override */
|
||||
.form-row.full-width {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Three-column for port + protocol combos */
|
||||
.form-row.triple {
|
||||
grid-template-columns: 1fr 100px 120px;
|
||||
grid-template-columns: 1fr 100px 120px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -143,14 +143,14 @@
|
||||
.proto-only-ssh,
|
||||
.proto-only-telnet,
|
||||
.proto-only-raw {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.protocol-ssh .proto-only-ssh,
|
||||
.protocol-telnet .proto-only-telnet,
|
||||
.protocol-raw .proto-only-raw,
|
||||
.protocol-github .proto-only-github {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* For block-level protocol fields */
|
||||
@@ -158,354 +158,423 @@
|
||||
.protocol-telnet .proto-only-telnet.form-section,
|
||||
.protocol-raw .proto-only-raw.form-section,
|
||||
.protocol-github .proto-only-github.form-section {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tag input
|
||||
----------------------------------------------------------------------- */
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 30px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: text;
|
||||
transition: border-color var(--transition-fast);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 30px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: text;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag-input-wrapper:hover {
|
||||
border-color: var(--border-strong);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.tag-input-wrapper:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: var(--accent);
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: var(--accent);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag-item-remove:hover {
|
||||
background: var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
height: 22px;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.tag-input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Jump host / proxy list
|
||||
----------------------------------------------------------------------- */
|
||||
.jump-host-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.jump-host-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-order {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-value {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.jump-host-item:hover .jump-host-actions {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jump-host-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.jump-host-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Key file selector row
|
||||
----------------------------------------------------------------------- */
|
||||
.file-selector {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.file-selector .form-input {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-selector .btn {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File path display */
|
||||
.file-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Inline validation
|
||||
----------------------------------------------------------------------- */
|
||||
.form-group.is-valid .form-input {
|
||||
border-color: var(--success);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.form-group.is-valid .form-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.20);
|
||||
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2);
|
||||
}
|
||||
|
||||
.form-group.is-invalid .form-input {
|
||||
border-color: var(--error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.form-group.is-invalid .form-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20);
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
.form-validation-icon {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--text-sm);
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.form-group.is-valid .form-validation-icon { color: var(--success); }
|
||||
.form-group.is-invalid .form-validation-icon { color: var(--error); }
|
||||
.form-group.is-valid .form-validation-icon {
|
||||
color: var(--success);
|
||||
}
|
||||
.form-group.is-invalid .form-validation-icon {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Help text under sections
|
||||
----------------------------------------------------------------------- */
|
||||
.form-section-help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Colour preview (for terminal colour settings)
|
||||
----------------------------------------------------------------------- */
|
||||
.colour-preview {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Number input with stepper
|
||||
----------------------------------------------------------------------- */
|
||||
.form-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-number .form-input {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
/* Hide browser spinners */
|
||||
-moz-appearance: textfield;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
/* Hide browser spinners */
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.form-number .form-input::-webkit-inner-spin-button,
|
||||
.form-number .form-input::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.form-number-btn {
|
||||
width: 24px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
width: 24px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-number-btn:last-child {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.form-number-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form action row (test connection, etc.)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-actions-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.form-actions-inline .btn {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-actions-inline .form-actions-status {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form hint — inline help text below fields
|
||||
----------------------------------------------------------------------- */
|
||||
.form-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.form-hint.secret-error {
|
||||
color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-success {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--success);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Toggle wrapper — label + toggle switch in a row
|
||||
----------------------------------------------------------------------- */
|
||||
.form-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.form-toggle-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Jump host rows
|
||||
----------------------------------------------------------------------- */
|
||||
.jump-hosts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.jump-host-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 1fr auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.jump-host-fields {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Port-forwarding rows
|
||||
----------------------------------------------------------------------- */
|
||||
.port-forwards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.port-forward-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.port-forward-fields {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.port-forward-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.port-forward-enabled-label {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.port-forward-enabled-label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Payload preset rows
|
||||
----------------------------------------------------------------------- */
|
||||
.payload-presets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 100px auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 100px auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -513,7 +582,154 @@
|
||||
----------------------------------------------------------------------- */
|
||||
.form-input-sm,
|
||||
.form-select-sm {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Login Script Editor — expect-style send/wait steps
|
||||
----------------------------------------------------------------------- */
|
||||
.form-help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-script-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.login-script-empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.login-script-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.login-script-type {
|
||||
width: 90px;
|
||||
min-width: 90px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-value {
|
||||
flex: 1;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-timeout {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-timeout-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.login-script-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-script-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.login-script-btn:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-script-btn-remove:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-script-add-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
TOTP / 2FA — secret input row with action buttons
|
||||
----------------------------------------------------------------------- */
|
||||
.totp-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.totp-input-row .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* SSH key path row */
|
||||
.key-path-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-path-row .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jump-hosts-list .form-error {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.totp-toggle,
|
||||
.totp-test,
|
||||
.totp-save,
|
||||
.totp-delete {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
@@ -4,263 +4,301 @@
|
||||
========================================================================= */
|
||||
|
||||
.github-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
/* ── Header bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--protocol-github);
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--protocol-github);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.github-viewer-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.github-viewer-search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.github-viewer-search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.github-viewer-search-input:focus {
|
||||
border-color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-viewer-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-viewer-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-refresh:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
/* ── Status bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-status {
|
||||
padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-status-error {
|
||||
color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ── Repo list ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
}
|
||||
|
||||
.github-viewer-empty {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ── Group ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-group {
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.github-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
margin-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
margin-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.github-group-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.github-group-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* ── Repo card ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-repo-card {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.github-repo-card:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.github-repo-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.github-repo-commit {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.github-repo-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--protocol-github);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-repo-badge {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.github-repo-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.github-repo-download,
|
||||
.github-repo-visit {
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.github-repo-download:hover,
|
||||
.github-repo-visit:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-repo-private {
|
||||
background: rgba(255, 167, 38, 0.12);
|
||||
color: #ffa726;
|
||||
background: rgba(255, 167, 38, 0.12);
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.github-repo-fork {
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: #4ecdc4;
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.github-repo-archived {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-repo-desc {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.github-repo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-repo-lang {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.github-lang-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-repo-stars {
|
||||
/* Star colour is inherent in the text */
|
||||
/* Star colour is inherent in the text */
|
||||
}
|
||||
|
||||
.github-repo-pushed {
|
||||
/* Relative time text */
|
||||
/* Relative time text */
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
@@ -7,149 +7,181 @@
|
||||
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: var(--space-1);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status bar
|
||||
----------------------------------------------------------------------- */
|
||||
.status-bar {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-bar span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-update {
|
||||
border: 1px solid var(--accent);
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.status-update:hover {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.status-update.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status bar indicator dots */
|
||||
.status-bar .status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.status-dot.is-connecting {
|
||||
background: var(--warning);
|
||||
}
|
||||
.status-dot.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.status-dot.is-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-dot.is-connected { background: var(--success); }
|
||||
.status-dot.is-connecting { background: var(--warning); }
|
||||
.status-dot.is-error { background: var(--error); }
|
||||
.status-dot.is-disconnected { background: var(--text-tertiary); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Resize handle (between sidebar and workspace)
|
||||
----------------------------------------------------------------------- */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
.resize-handle:hover,
|
||||
.resize-handle.is-dragging {
|
||||
background: var(--accent-muted);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* ── 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);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/* ==========================================================================
|
||||
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);
|
||||
}
|
||||
@@ -7,313 +7,409 @@
|
||||
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);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Bulk actions bar (multi-select)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-bulk-actions {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-bulk-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.sidebar-bulk-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sections (Favourites, Recent, All Connections)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-section {
|
||||
/* Each section can be collapsed */
|
||||
/* Each section can be collapsed */
|
||||
}
|
||||
|
||||
.sidebar-section + .sidebar-section {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-section-header.sidebar-section-header-group .section-title {
|
||||
color: var(--sidebar-group-title);
|
||||
}
|
||||
|
||||
.sidebar-section-header:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-section-header.sidebar-section-header-group:hover .section-title {
|
||||
color: var(--sidebar-group-title);
|
||||
}
|
||||
|
||||
.sidebar-section-header .section-icon {
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sidebar-section-header .section-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
color: var(--text-disabled);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.sidebar-section.is-collapsed .section-chevron {
|
||||
transform: rotate(-90deg);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-overlay);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-overlay);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar list (container for items)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-list {
|
||||
padding: 0 var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-section.is-collapsed .sidebar-list {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar item (individual connection profile)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
margin-bottom: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
margin-bottom: 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);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.sidebar-item-selected {
|
||||
background: var(--accent-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-item-selected .sidebar-item-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active / selected state */
|
||||
.sidebar-item.active {
|
||||
background: var(--accent-muted);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.sidebar-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Protocol colour indicator dot */
|
||||
/* Protocol indicator icon */
|
||||
.sidebar-item .protocol-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator::before {
|
||||
content: "?";
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator.proto-ssh::before {
|
||||
content: "⌨";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-telnet::before {
|
||||
content: "⇄";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-raw::before {
|
||||
content: "≋";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-github::before {
|
||||
content: "GH";
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator.proto-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-ssh { background: var(--protocol-ssh); }
|
||||
.sidebar-item .protocol-indicator.proto-telnet { background: var(--protocol-telnet); }
|
||||
.sidebar-item .protocol-indicator.proto-raw { background: var(--protocol-raw); }
|
||||
.sidebar-item .protocol-indicator.proto-github { background: var(--protocol-github); }
|
||||
|
||||
/* Text block (name + host) */
|
||||
.sidebar-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-item-host {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--leading-tight);
|
||||
margin-top: 1px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--leading-tight);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Edit button (appears on hover, beside name) */
|
||||
.sidebar-item-edit {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-disabled);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-disabled);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-edit {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-edit:hover {
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Favourite star */
|
||||
.sidebar-item-star {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-star {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-star.is-favourite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.sidebar-item-star:hover {
|
||||
color: var(--warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Inline status dot (right side, tiny) */
|
||||
.sidebar-item-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--space-1);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
.sidebar-item-status.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.sidebar-item-status.is-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.sidebar-item-status.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.sidebar-item-status.is-connected { background: var(--success); }
|
||||
.sidebar-item-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||
.sidebar-item-status.is-error { background: var(--error); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar empty list placeholder
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-list-empty {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar footer (actions)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer .btn:first-child {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar collapsed state
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-collapsed .sidebar-header {
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
.sidebar-collapsed .sidebar-title,
|
||||
.sidebar-collapsed .sidebar-search,
|
||||
@@ -322,19 +418,19 @@
|
||||
.sidebar-collapsed .sidebar-item-star,
|
||||
.sidebar-collapsed .section-count,
|
||||
.sidebar-collapsed .sidebar-footer .btn:first-child span {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-section-header {
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-footer {
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* ==========================================================================
|
||||
Nexus Terminal — Split Pane Layout
|
||||
Draggable split panes within session panels for multi-terminal views.
|
||||
========================================================================== */
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Split container — replaces direct terminal-container when split
|
||||
----------------------------------------------------------------------- */
|
||||
.split-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-container.split-horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-container.split-vertical {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Individual pane inside a split
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Active pane indicator (subtle border glow) */
|
||||
.split-pane.is-focused {
|
||||
box-shadow: inset 0 0 0 1px var(--accent-muted);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Pane close button — only visible on hover
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane-close {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.split-pane:hover > .split-pane-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.split-pane-close:hover {
|
||||
background: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Draggable divider between panes
|
||||
----------------------------------------------------------------------- */
|
||||
.split-divider {
|
||||
flex-shrink: 0;
|
||||
background: var(--border-subtle);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.is-dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Vertical split → side-by-side panes → vertical divider bar */
|
||||
.split-container.split-vertical > .split-divider {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Horizontal split → stacked panes → horizontal divider bar */
|
||||
.split-container.split-horizontal > .split-divider {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* Invisible larger hit-area for easier grabbing */
|
||||
.split-divider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.split-container.split-vertical > .split-divider::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.split-container.split-horizontal > .split-divider::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Empty pane placeholder — shown when a new pane needs a session
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-base);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.split-pane-placeholder-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.split-pane-placeholder-text {
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
/* Profile picker list in the placeholder */
|
||||
.split-pane-profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.split-pane-profile-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.split-pane-profile-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.split-pane-profile-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.split-pane-profile-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-pane-profile-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-muted);
|
||||
}
|
||||
|
||||
.split-pane-profile-btn .protocol-badge {
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Prevent text selection while dragging dividers
|
||||
----------------------------------------------------------------------- */
|
||||
body.split-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-resizing-row {
|
||||
cursor: row-resize !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
/* Disable pointer events on iframes / xterm canvases during drag */
|
||||
body.split-resizing .xterm,
|
||||
body.split-resizing-row .xterm {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -8,262 +8,366 @@
|
||||
Individual tab
|
||||
----------------------------------------------------------------------- */
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--tab-bar-height);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
max-width: 200px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--tab-bar-height);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
max-width: 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active tab */
|
||||
.tab.active {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
.tab-label,
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Protocol mini-badge inside tab
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-badge.badge-ssh {
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: var(--protocol-ssh);
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: var(--protocol-ssh);
|
||||
}
|
||||
.tab-badge.badge-telnet {
|
||||
background: rgba(255, 167, 38, 0.15);
|
||||
color: var(--protocol-telnet);
|
||||
background: rgba(255, 167, 38, 0.15);
|
||||
color: var(--protocol-telnet);
|
||||
}
|
||||
.tab-badge.badge-raw {
|
||||
background: rgba(171, 71, 188, 0.15);
|
||||
color: var(--protocol-raw);
|
||||
background: rgba(171, 71, 188, 0.15);
|
||||
color: var(--protocol-raw);
|
||||
}
|
||||
.tab-badge.badge-github {
|
||||
background: rgba(124, 138, 255, 0.15);
|
||||
color: var(--protocol-github);
|
||||
background: rgba(124, 138, 255, 0.15);
|
||||
color: var(--protocol-github);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status dot (connection state)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab-status.is-connected { background: var(--success); }
|
||||
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||
.tab-status.is-error { background: var(--error); }
|
||||
.tab-status.is-disconnected { background: var(--text-tertiary); }
|
||||
.tab-status.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.tab-status.is-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.tab-status.is-reconnecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 0.8s ease infinite;
|
||||
}
|
||||
.tab-status.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.tab-status.is-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Also match the status-* pattern used by updateSessionState */
|
||||
.tab-status.status-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.tab-status.status-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.tab-status.status-reconnecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 0.8s ease infinite;
|
||||
}
|
||||
.tab-status.status-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.tab-status.status-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
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);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Close button — revealed on hover
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.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);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Broadcast toggle button
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-broadcast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--tab-bar-height);
|
||||
height: var(--tab-bar-height);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-broadcast:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab-broadcast.is-active {
|
||||
color: var(--error);
|
||||
opacity: 1;
|
||||
animation: pulse-dot 1.5s ease infinite;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -271,41 +375,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);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
Terminal wrapper — fills the workspace panel
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -18,157 +18,227 @@
|
||||
Shows connection info above the terminal.
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.terminal-header-label {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-header-value {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.terminal-header-separator {
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: var(--border-subtle);
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.terminal-header-spacer {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
xterm.js container
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Slight padding so terminal text doesn't hug edges */
|
||||
padding: var(--space-1);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Disconnected overlay
|
||||
----------------------------------------------------------------------- */
|
||||
.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.terminal-disconnect-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 14, 18, 0.68);
|
||||
z-index: 6;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-disconnect-overlay.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.terminal-disconnect-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
text-align: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.terminal-disconnect-title {
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.terminal-disconnect-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* xterm.js overrides for consistent look */
|
||||
.terminal-container .xterm,
|
||||
.session-panel .xterm {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-1);
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-screen,
|
||||
.session-panel .xterm-screen {
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport,
|
||||
.session-panel .xterm-viewport {
|
||||
/* Allow xterm's own scrollbar to work */
|
||||
overflow-y: auto !important;
|
||||
/* Allow xterm's own scrollbar to work */
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Ensure cursor blink is visible */
|
||||
.terminal-container .xterm-cursor-layer,
|
||||
.session-panel .xterm-cursor-layer {
|
||||
z-index: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Selection styling within terminal */
|
||||
.terminal-container .xterm .xterm-selection div,
|
||||
.session-panel .xterm .xterm-selection div {
|
||||
background: var(--accent-strong) !important;
|
||||
background: var(--accent-strong) !important;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Terminal search overlay
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-search {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-search.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.terminal-search input {
|
||||
width: 200px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 200px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.terminal-search input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.terminal-search-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.terminal-search-toggle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-search-toggle.is-active {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -177,231 +247,233 @@
|
||||
|
||||
/* RAW text view — scrollable, monospace output */
|
||||
.raw-text-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* RAW hex view */
|
||||
.raw-hex-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.raw-hex-row {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.raw-hex-offset {
|
||||
color: var(--text-tertiary);
|
||||
min-width: 60px;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.raw-hex-bytes {
|
||||
min-width: 350px;
|
||||
letter-spacing: 0.08em;
|
||||
min-width: 350px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.raw-hex-bytes .byte-highlight {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.raw-hex-ascii {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.raw-hex-ascii .non-printable {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* RAW split view (text on left, hex on right) */
|
||||
.raw-split-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-split-view .raw-text-view,
|
||||
.raw-split-view .raw-hex-view {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.raw-split-divider {
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
RAW mode view toggle buttons
|
||||
----------------------------------------------------------------------- */
|
||||
.raw-view-toggle {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn {
|
||||
padding: 0 var(--space-2);
|
||||
height: 22px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
padding: 0 var(--space-2);
|
||||
height: 22px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Send bar (RAW TCP mode — manual data entry)
|
||||
----------------------------------------------------------------------- */
|
||||
.send-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-bar-input {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.send-bar-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.send-bar-input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* Line ending selector */
|
||||
.send-bar-eol {
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.send-bar-eol:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Encoding selector */
|
||||
.send-bar-encoding {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Session overlay (connecting / error states)
|
||||
----------------------------------------------------------------------- */
|
||||
.session-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(28, 28, 28, 0.85);
|
||||
z-index: 5;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(28, 28, 28, 0.85);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.session-overlay-connecting {
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-overlay-connecting .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 auto var(--space-4);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.session-overlay-connecting p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-overlay-error {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.session-overlay-error h3 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--error);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--error);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.session-overlay-error p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.session-overlay-error .session-error-detail {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: left;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: left;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.session-overlay-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
--text-tertiary: #707070;
|
||||
--text-disabled: #4a4a4a;
|
||||
--text-inverse: #1c1c1c;
|
||||
--sidebar-group-title: #b89d5f;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Accent colours — teal / cyan
|
||||
@@ -139,3 +140,14 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — GitHub repository viewer.
|
||||
* Renders a scrollable list of repositories grouped by owner/organisation,
|
||||
* sorted by last commit date (newest first). Automatically refreshes via
|
||||
* Wails events emitted by the Go backend.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — GitHub repository viewer.
|
||||
* Renders a scrollable list of repositories grouped by owner/organisation,
|
||||
* sorted by last commit date (newest first). Automatically refreshes via
|
||||
* Wails events emitted by the Go backend.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
|
||||
|
||||
class GitHubViewer {
|
||||
class GitHubViewer {
|
||||
constructor() {
|
||||
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +21,15 @@ class GitHubViewer {
|
||||
* @param {string} profileName
|
||||
*/
|
||||
createViewer(sessionID, container, profileName) {
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
|
||||
const viewer = new GitHubViewerInstance(sessionID, container, profileName);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
viewer.render();
|
||||
const viewer = new GitHubViewerInstance(
|
||||
sessionID,
|
||||
container,
|
||||
profileName,
|
||||
);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
viewer.render();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,11 +37,11 @@ class GitHubViewer {
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
destroyViewer(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +51,22 @@ class GitHubViewer {
|
||||
* @param {Array} repos
|
||||
*/
|
||||
updateRepos(sessionID, login, repos) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setData(login, repos);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setData(login, repos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line for a session.
|
||||
* @param {string} sessionID
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(sessionID, message) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,98 +75,98 @@ class GitHubViewer {
|
||||
* @param {string} message
|
||||
*/
|
||||
showError(sessionID, message) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.showError(message);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.showError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GitHubViewerInstance {
|
||||
class GitHubViewerInstance {
|
||||
/**
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container
|
||||
* @param {string} profileName
|
||||
*/
|
||||
constructor(sessionID, container, profileName) {
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profileName = profileName;
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profileName = profileName;
|
||||
|
||||
/** @type {string} */
|
||||
this._login = '';
|
||||
/** @type {string} */
|
||||
this._login = "";
|
||||
|
||||
/** @type {Array} */
|
||||
this._repos = [];
|
||||
/** @type {Array} */
|
||||
this._repos = [];
|
||||
|
||||
/** @type {string} */
|
||||
this._filter = '';
|
||||
/** @type {string} */
|
||||
this._filter = "";
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._el = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._el = null;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._listEl = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._listEl = null;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._statusEl = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._statusEl = null;
|
||||
|
||||
/** @type {number|null} */
|
||||
this._refreshTimer = null;
|
||||
/** @type {number|null} */
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.innerHTML = "";
|
||||
|
||||
const wrapper = createElement('div', 'github-viewer');
|
||||
const wrapper = createElement("div", "github-viewer");
|
||||
|
||||
// ── Header bar ──────────────────────────────────────────
|
||||
const header = createElement('div', 'github-viewer-header');
|
||||
// ── Header bar ──────────────────────────────────────────
|
||||
const header = createElement("div", "github-viewer-header");
|
||||
|
||||
const titleArea = createElement('div', 'github-viewer-title-area');
|
||||
const icon = createElement('span', 'github-viewer-icon');
|
||||
icon.innerHTML = this._githubSVG();
|
||||
titleArea.appendChild(icon);
|
||||
const titleArea = createElement("div", "github-viewer-title-area");
|
||||
const icon = createElement("span", "github-viewer-icon");
|
||||
icon.innerHTML = this._githubSVG();
|
||||
titleArea.appendChild(icon);
|
||||
|
||||
const titleText = createElement('span', 'github-viewer-title');
|
||||
titleText.textContent = this.profileName || 'GitHub';
|
||||
titleArea.appendChild(titleText);
|
||||
header.appendChild(titleArea);
|
||||
const titleText = createElement("span", "github-viewer-title");
|
||||
titleText.textContent = this.profileName || "GitHub";
|
||||
titleArea.appendChild(titleText);
|
||||
header.appendChild(titleArea);
|
||||
|
||||
// Search / filter
|
||||
const searchWrap = createElement('div', 'github-viewer-search');
|
||||
const searchInput = createElement('input', 'github-viewer-search-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter repositories…',
|
||||
});
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
this._filter = e.target.value.toLowerCase();
|
||||
this._renderList();
|
||||
});
|
||||
searchWrap.appendChild(searchInput);
|
||||
header.appendChild(searchWrap);
|
||||
// Search / filter
|
||||
const searchWrap = createElement("div", "github-viewer-search");
|
||||
const searchInput = createElement("input", "github-viewer-search-input", {
|
||||
type: "text",
|
||||
placeholder: "Filter repositories…",
|
||||
});
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
this._filter = e.target.value.toLowerCase();
|
||||
this._renderList();
|
||||
});
|
||||
searchWrap.appendChild(searchInput);
|
||||
header.appendChild(searchWrap);
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = createElement('button', 'github-viewer-refresh', {
|
||||
title: 'Refresh now',
|
||||
});
|
||||
refreshBtn.innerHTML = '↻';
|
||||
refreshBtn.addEventListener('click', () => this._manualRefresh());
|
||||
header.appendChild(refreshBtn);
|
||||
// Refresh button
|
||||
const refreshBtn = createElement("button", "github-viewer-refresh", {
|
||||
title: "Refresh now",
|
||||
});
|
||||
refreshBtn.innerHTML = "↻";
|
||||
refreshBtn.addEventListener("click", () => this._manualRefresh());
|
||||
header.appendChild(refreshBtn);
|
||||
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
this._statusEl = createElement('div', 'github-viewer-status');
|
||||
this._statusEl.textContent = 'Connecting…';
|
||||
wrapper.appendChild(this._statusEl);
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
this._statusEl = createElement("div", "github-viewer-status");
|
||||
this._statusEl.textContent = "Connecting…";
|
||||
wrapper.appendChild(this._statusEl);
|
||||
|
||||
// ── Repo list ───────────────────────────────────────────
|
||||
this._listEl = createElement('div', 'github-viewer-list');
|
||||
wrapper.appendChild(this._listEl);
|
||||
// ── Repo list ───────────────────────────────────────────
|
||||
this._listEl = createElement("div", "github-viewer-list");
|
||||
wrapper.appendChild(this._listEl);
|
||||
|
||||
this.container.appendChild(wrapper);
|
||||
this._el = wrapper;
|
||||
this.container.appendChild(wrapper);
|
||||
this._el = wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,10 +175,20 @@ class GitHubViewerInstance {
|
||||
* @param {Array} repos
|
||||
*/
|
||||
setData(login, repos) {
|
||||
this._login = login;
|
||||
this._repos = repos || [];
|
||||
this._updateStatus();
|
||||
this._renderList();
|
||||
this._login = login;
|
||||
this._repos = repos || [];
|
||||
this._updateStatus();
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line with a custom message.
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(message) {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
this._statusEl.textContent = message || "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,89 +196,108 @@ class GitHubViewerInstance {
|
||||
* @param {string} message
|
||||
*/
|
||||
showError(message) {
|
||||
if (this._statusEl) {
|
||||
this._statusEl.textContent = `⚠ ${message}`;
|
||||
this._statusEl.classList.add('github-viewer-status-error');
|
||||
}
|
||||
if (this._statusEl) {
|
||||
this._statusEl.textContent = `⚠ ${message}`;
|
||||
this._statusEl.classList.add("github-viewer-status-error");
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._refreshTimer) {
|
||||
clearInterval(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
if (this._el) {
|
||||
this._el.remove();
|
||||
this._el = null;
|
||||
}
|
||||
if (this._refreshTimer) {
|
||||
clearInterval(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
if (this._el) {
|
||||
this._el.remove();
|
||||
this._el = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────
|
||||
|
||||
_updateStatus() {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove('github-viewer-status-error');
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
|
||||
const count = this._repos.length;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? 'y' : 'ies'} · updated ${now}`;
|
||||
const count = this._repos.length;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`;
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
if (!this._listEl) return;
|
||||
this._listEl.innerHTML = '';
|
||||
if (!this._listEl) return;
|
||||
this._listEl.innerHTML = "";
|
||||
|
||||
// Filter
|
||||
let filtered = this._repos;
|
||||
if (this._filter) {
|
||||
filtered = this._repos.filter((r) =>
|
||||
r.fullName.toLowerCase().includes(this._filter) ||
|
||||
(r.description || '').toLowerCase().includes(this._filter) ||
|
||||
(r.language || '').toLowerCase().includes(this._filter)
|
||||
);
|
||||
}
|
||||
// Filter
|
||||
let filtered = this._repos;
|
||||
if (this._filter) {
|
||||
filtered = this._repos.filter(
|
||||
(r) =>
|
||||
r.fullName.toLowerCase().includes(this._filter) ||
|
||||
(r.description || "").toLowerCase().includes(this._filter) ||
|
||||
(r.language || "").toLowerCase().includes(this._filter),
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const empty = createElement('div', 'github-viewer-empty');
|
||||
empty.textContent = this._filter ? 'No matching repositories.' : 'No repositories found.';
|
||||
this._listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
const empty = createElement("div", "github-viewer-empty");
|
||||
empty.textContent = this._filter
|
||||
? "No matching repositories."
|
||||
: "No repositories found.";
|
||||
this._listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by owner
|
||||
const groups = new Map();
|
||||
for (const repo of filtered) {
|
||||
const owner = repo.owner || 'unknown';
|
||||
if (!groups.has(owner)) groups.set(owner, []);
|
||||
groups.get(owner).push(repo);
|
||||
}
|
||||
const commitTimestamp = (repo) => {
|
||||
const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || "";
|
||||
const ts = new Date(raw).getTime();
|
||||
return Number.isNaN(ts) ? 0 : ts;
|
||||
};
|
||||
|
||||
// Sort groups alphabetically (user's own repos first)
|
||||
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||
if (a === this._login) return -1;
|
||||
if (b === this._login) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
// Group by owner
|
||||
const groups = new Map();
|
||||
for (const repo of filtered) {
|
||||
const owner = repo.owner || "unknown";
|
||||
if (!groups.has(owner)) groups.set(owner, []);
|
||||
groups.get(owner).push(repo);
|
||||
}
|
||||
|
||||
for (const owner of sortedKeys) {
|
||||
const repos = groups.get(owner);
|
||||
// Sort repos within each group by latest commit (newest first)
|
||||
for (const repos of groups.values()) {
|
||||
repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a));
|
||||
}
|
||||
|
||||
// Group header
|
||||
const groupEl = createElement('div', 'github-group');
|
||||
const groupHeader = createElement('div', 'github-group-header');
|
||||
groupHeader.innerHTML = `
|
||||
// Sort groups by most recent commit in each group (login first on ties)
|
||||
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||
const aRepos = groups.get(a) || [];
|
||||
const bRepos = groups.get(b) || [];
|
||||
const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0;
|
||||
const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0;
|
||||
if (aMax !== bMax) return bMax - aMax;
|
||||
if (a === this._login) return -1;
|
||||
if (b === this._login) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const owner of sortedKeys) {
|
||||
const repos = groups.get(owner);
|
||||
|
||||
// Group header
|
||||
const groupEl = createElement("div", "github-group");
|
||||
const groupHeader = createElement("div", "github-group-header");
|
||||
groupHeader.innerHTML = `
|
||||
<span class="github-group-name">${escapeHtml(owner)}</span>
|
||||
<span class="github-group-count">${repos.length}</span>
|
||||
`;
|
||||
groupEl.appendChild(groupHeader);
|
||||
groupEl.appendChild(groupHeader);
|
||||
|
||||
// Repo cards
|
||||
for (const repo of repos) {
|
||||
groupEl.appendChild(this._renderRepoCard(repo));
|
||||
}
|
||||
|
||||
this._listEl.appendChild(groupEl);
|
||||
// Repo cards
|
||||
for (const repo of repos) {
|
||||
groupEl.appendChild(this._renderRepoCard(repo));
|
||||
}
|
||||
|
||||
this._listEl.appendChild(groupEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,77 +306,145 @@ class GitHubViewerInstance {
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_renderRepoCard(repo) {
|
||||
const card = createElement('div', 'github-repo-card');
|
||||
card.addEventListener('click', () => {
|
||||
if (repo.htmlURL) window.open(repo.htmlURL, '_blank');
|
||||
});
|
||||
const card = createElement("div", "github-repo-card");
|
||||
card.addEventListener("click", () => {
|
||||
if (repo.htmlURL) window.open(repo.htmlURL, "_blank");
|
||||
});
|
||||
|
||||
// Top row: name + badges
|
||||
const topRow = createElement('div', 'github-repo-top');
|
||||
const nameEl = createElement('span', 'github-repo-name');
|
||||
nameEl.textContent = repo.name;
|
||||
topRow.appendChild(nameEl);
|
||||
// Top row: name + badges
|
||||
const topRow = createElement("div", "github-repo-top");
|
||||
const nameEl = createElement("span", "github-repo-name");
|
||||
nameEl.textContent = repo.name;
|
||||
topRow.appendChild(nameEl);
|
||||
|
||||
if (repo.private) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
||||
badge.textContent = 'Private';
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.fork) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-fork');
|
||||
badge.textContent = 'Fork';
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.archived) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-archived');
|
||||
badge.textContent = 'Archived';
|
||||
topRow.appendChild(badge);
|
||||
if (repo.private) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-private",
|
||||
);
|
||||
badge.textContent = "Private";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.fork) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-fork",
|
||||
);
|
||||
badge.textContent = "Fork";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.archived) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-archived",
|
||||
);
|
||||
badge.textContent = "Archived";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = createElement("div", "github-repo-actions");
|
||||
|
||||
const downloadBtn = createElement("button", "github-repo-download", {
|
||||
textContent: "ZIP",
|
||||
title: "Download repository as zip",
|
||||
});
|
||||
downloadBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this._downloadRepo(repo);
|
||||
});
|
||||
actions.appendChild(downloadBtn);
|
||||
|
||||
const visitLink = createElement("button", "github-repo-visit", {
|
||||
textContent: "Visit",
|
||||
title: "Open repository in browser",
|
||||
});
|
||||
visitLink.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (repo.htmlURL) {
|
||||
if (window.runtime && window.runtime.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL(repo.htmlURL);
|
||||
} else {
|
||||
window.open(repo.htmlURL, "_blank");
|
||||
}
|
||||
}
|
||||
});
|
||||
actions.appendChild(visitLink);
|
||||
|
||||
card.appendChild(topRow);
|
||||
topRow.appendChild(actions);
|
||||
|
||||
// Description
|
||||
if (repo.description) {
|
||||
const desc = createElement('div', 'github-repo-desc');
|
||||
desc.textContent = repo.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
card.appendChild(topRow);
|
||||
|
||||
// Meta row: language, stars, pushed
|
||||
const meta = createElement('div', 'github-repo-meta');
|
||||
if (repo.latestCommitter && repo.latestCommitAt) {
|
||||
const commitLine = createElement("div", "github-repo-commit");
|
||||
const when = new Date(repo.latestCommitAt);
|
||||
const whenText = isNaN(when.getTime())
|
||||
? repo.latestCommitAt
|
||||
: when.toLocaleString();
|
||||
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
|
||||
card.appendChild(commitLine);
|
||||
}
|
||||
|
||||
if (repo.language) {
|
||||
const lang = createElement('span', 'github-repo-lang');
|
||||
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
|
||||
meta.appendChild(lang);
|
||||
}
|
||||
// Description
|
||||
if (repo.description) {
|
||||
const desc = createElement("div", "github-repo-desc");
|
||||
desc.textContent = repo.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
if (repo.stars > 0) {
|
||||
const stars = createElement('span', 'github-repo-stars');
|
||||
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
||||
meta.appendChild(stars);
|
||||
}
|
||||
// Meta row: language, stars, pushed
|
||||
const meta = createElement("div", "github-repo-meta");
|
||||
|
||||
if (repo.pushedAt) {
|
||||
const pushed = createElement('span', 'github-repo-pushed');
|
||||
pushed.textContent = relativeTime(repo.pushedAt);
|
||||
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
||||
meta.appendChild(pushed);
|
||||
}
|
||||
if (repo.language) {
|
||||
const lang = createElement("span", "github-repo-lang");
|
||||
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
|
||||
meta.appendChild(lang);
|
||||
}
|
||||
|
||||
card.appendChild(meta);
|
||||
if (repo.stars > 0) {
|
||||
const stars = createElement("span", "github-repo-stars");
|
||||
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
||||
meta.appendChild(stars);
|
||||
}
|
||||
|
||||
return card;
|
||||
if (repo.pushedAt) {
|
||||
const pushed = createElement("span", "github-repo-pushed");
|
||||
pushed.textContent = relativeTime(repo.pushedAt);
|
||||
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
||||
meta.appendChild(pushed);
|
||||
}
|
||||
|
||||
card.appendChild(meta);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async _downloadRepo(repo) {
|
||||
if (!this._statusEl) return;
|
||||
const ref = repo.defaultBranch || "";
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||
|
||||
try {
|
||||
await window.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");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,33 +454,33 @@ class GitHubViewerInstance {
|
||||
* @returns {string} CSS colour value.
|
||||
*/
|
||||
_langColour(lang) {
|
||||
const colours = {
|
||||
'JavaScript': '#f1e05a',
|
||||
'TypeScript': '#3178c6',
|
||||
'Python': '#3572A5',
|
||||
'Go': '#00ADD8',
|
||||
'Rust': '#dea584',
|
||||
'Java': '#b07219',
|
||||
'C': '#555555',
|
||||
'C++': '#f34b7d',
|
||||
'C#': '#178600',
|
||||
'Ruby': '#701516',
|
||||
'PHP': '#4F5D95',
|
||||
'Swift': '#F05138',
|
||||
'Kotlin': '#A97BFF',
|
||||
'Shell': '#89e051',
|
||||
'HTML': '#e34c26',
|
||||
'CSS': '#563d7c',
|
||||
'Dart': '#00B4AB',
|
||||
'Lua': '#000080',
|
||||
'Scala': '#c22d40',
|
||||
'Elixir': '#6e4a7e',
|
||||
'Haskell': '#5e5086',
|
||||
'Zig': '#ec915c',
|
||||
'Vue': '#41b883',
|
||||
'SCSS': '#c6538c',
|
||||
};
|
||||
return colours[lang] || '#8b949e';
|
||||
const colours = {
|
||||
JavaScript: "#f1e05a",
|
||||
TypeScript: "#3178c6",
|
||||
Python: "#3572A5",
|
||||
Go: "#00ADD8",
|
||||
Rust: "#dea584",
|
||||
Java: "#b07219",
|
||||
C: "#555555",
|
||||
"C++": "#f34b7d",
|
||||
"C#": "#178600",
|
||||
Ruby: "#701516",
|
||||
PHP: "#4F5D95",
|
||||
Swift: "#F05138",
|
||||
Kotlin: "#A97BFF",
|
||||
Shell: "#89e051",
|
||||
HTML: "#e34c26",
|
||||
CSS: "#563d7c",
|
||||
Dart: "#00B4AB",
|
||||
Lua: "#000080",
|
||||
Scala: "#c22d40",
|
||||
Elixir: "#6e4a7e",
|
||||
Haskell: "#5e5086",
|
||||
Zig: "#ec915c",
|
||||
Vue: "#41b883",
|
||||
SCSS: "#c6538c",
|
||||
};
|
||||
return colours[lang] || "#8b949e";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,11 +488,10 @@ class GitHubViewerInstance {
|
||||
* @returns {string}
|
||||
*/
|
||||
_githubSVG() {
|
||||
return '<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
|
||||
return '<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
(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">×</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">×</button>
|
||||
</div>
|
||||
<div class="modal-body playback-body">
|
||||
<div id="playback-terminal" class="playback-terminal"></div>
|
||||
<div class="playback-controls">
|
||||
<button class="btn btn-xs btn-primary playback-play-pause" title="Play/Pause">▶</button>
|
||||
<button class="btn btn-xs btn-outline playback-restart" title="Restart">⟲</button>
|
||||
<input type="range" class="playback-progress" min="0" max="1000" value="0" />
|
||||
<span class="playback-time">0:00 / ${_formatSeconds(totalDuration)}</span>
|
||||
<select class="playback-speed form-input" style="width:auto;padding:2px 6px;font-size:11px;">
|
||||
<option value="0.5">0.5×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="2">2×</option>
|
||||
<option value="5">5×</option>
|
||||
<option value="10">10×</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
// Create xterm instance for playback.
|
||||
const termContainer = modal.querySelector('#playback-terminal');
|
||||
const { getTerminalTheme } = window.NexusTerminal;
|
||||
const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal';
|
||||
const theme = getTerminalTheme ? getTerminalTheme(themeName) : {};
|
||||
|
||||
const term = new window.Terminal({
|
||||
cols: header.width || 80,
|
||||
rows: header.height || 24,
|
||||
theme: theme,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
scrollback: 10000,
|
||||
});
|
||||
term.open(termContainer);
|
||||
|
||||
const fitAddon = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
setTimeout(() => fitAddon.fit(), 50);
|
||||
|
||||
// Playback state.
|
||||
let playing = false;
|
||||
let speed = 1;
|
||||
let eventIndex = 0;
|
||||
let playbackTimer = null;
|
||||
let startRealTime = 0;
|
||||
let pausedAt = 0; // elapsed virtual time when paused
|
||||
|
||||
const playPauseBtn = modal.querySelector('.playback-play-pause');
|
||||
const restartBtn = modal.querySelector('.playback-restart');
|
||||
const progressInput = modal.querySelector('.playback-progress');
|
||||
const timeLabel = modal.querySelector('.playback-time');
|
||||
const speedSelect = modal.querySelector('.playback-speed');
|
||||
|
||||
const updateTimeDisplay = (elapsed) => {
|
||||
timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`;
|
||||
progressInput.value = Math.round((elapsed / totalDuration) * 1000);
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (!playing || eventIndex >= events.length) {
|
||||
if (eventIndex >= events.length) {
|
||||
playing = false;
|
||||
playPauseBtn.textContent = '▶';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = events[eventIndex];
|
||||
const evtTime = evt[0]; // seconds from start
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
const virtualElapsed = pausedAt + realElapsed * speed;
|
||||
const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000);
|
||||
|
||||
playbackTimer = setTimeout(() => {
|
||||
if (!playing) return;
|
||||
|
||||
// Write output events to the terminal.
|
||||
if (evt[1] === 'o') {
|
||||
term.write(evt[2]);
|
||||
}
|
||||
|
||||
eventIndex++;
|
||||
const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed;
|
||||
updateTimeDisplay(Math.min(currentVirtual, totalDuration));
|
||||
scheduleNext();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
if (eventIndex >= events.length) {
|
||||
// Restart from beginning.
|
||||
eventIndex = 0;
|
||||
pausedAt = 0;
|
||||
term.reset();
|
||||
}
|
||||
playing = true;
|
||||
playPauseBtn.textContent = '⏸';
|
||||
startRealTime = Date.now();
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
playing = false;
|
||||
playPauseBtn.textContent = '▶';
|
||||
if (playbackTimer) {
|
||||
clearTimeout(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
pausedAt = pausedAt + realElapsed * speed;
|
||||
};
|
||||
|
||||
const seekTo = (fraction) => {
|
||||
const wasPlaying = playing;
|
||||
if (playing) pause();
|
||||
|
||||
const targetTime = fraction * totalDuration;
|
||||
term.reset();
|
||||
eventIndex = 0;
|
||||
|
||||
// Replay all events up to targetTime.
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (events[i][0] > targetTime) break;
|
||||
if (events[i][1] === 'o') {
|
||||
term.write(events[i][2]);
|
||||
}
|
||||
eventIndex = i + 1;
|
||||
}
|
||||
|
||||
pausedAt = targetTime;
|
||||
updateTimeDisplay(targetTime);
|
||||
|
||||
if (wasPlaying) play();
|
||||
};
|
||||
|
||||
playPauseBtn.addEventListener('click', () => {
|
||||
if (playing) pause(); else play();
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
if (playing) pause();
|
||||
term.reset();
|
||||
eventIndex = 0;
|
||||
pausedAt = 0;
|
||||
updateTimeDisplay(0);
|
||||
});
|
||||
|
||||
progressInput.addEventListener('input', () => {
|
||||
seekTo(parseInt(progressInput.value, 10) / 1000);
|
||||
});
|
||||
|
||||
speedSelect.addEventListener('change', () => {
|
||||
const newSpeed = parseFloat(speedSelect.value);
|
||||
if (playing) {
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
pausedAt = pausedAt + realElapsed * speed;
|
||||
speed = newSpeed;
|
||||
startRealTime = Date.now();
|
||||
if (playbackTimer) clearTimeout(playbackTimer);
|
||||
scheduleNext();
|
||||
} else {
|
||||
speed = newSpeed;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-play on open.
|
||||
setTimeout(play, 200);
|
||||
|
||||
// Close handler.
|
||||
const close = () => {
|
||||
if (playing) pause();
|
||||
term.dispose();
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted Export/Import UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ExportImportUI {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the encrypted export modal.
|
||||
*/
|
||||
async showExportModal() {
|
||||
const profiles = this.app.state.profiles || [];
|
||||
if (profiles.length === 0) {
|
||||
this.app.notifications.info('No profiles to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-md');
|
||||
|
||||
let profileListHTML = profiles.map(p => `
|
||||
<label class="export-profile-item">
|
||||
<input type="checkbox" value="${p.id}" checked />
|
||||
<span>${_esc(p.displayName || p.host)} <small class="text-muted">(${p.protocol})</small></span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Export Profiles (Encrypted)</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Select profiles to export</label>
|
||||
<div class="export-profile-list" style="max-height:200px;overflow-y:auto;margin-bottom:12px;">
|
||||
${profileListHTML}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Export password</label>
|
||||
<input type="password" class="form-input export-password" placeholder="Enter a strong password" />
|
||||
<small class="text-muted">This password will be required to import the file.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-input export-password-confirm" placeholder="Confirm password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm export-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary export-confirm">Export</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.export-cancel').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.export-confirm').addEventListener('click', async () => {
|
||||
const password = modal.querySelector('.export-password').value;
|
||||
const confirm = modal.querySelector('.export-password-confirm').value;
|
||||
|
||||
if (!password) {
|
||||
this.app.notifications.error('Please enter a password.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
this.app.notifications.error('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked');
|
||||
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
this.app.notifications.error('Please select at least one profile.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await window.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">×</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;
|
||||
|
||||
})();
|
||||
@@ -134,6 +134,10 @@ class SecretPrompt {
|
||||
`;
|
||||
if (isNew) {
|
||||
formHtml += `
|
||||
<div class="password-strength-meter">
|
||||
<div class="strength-bar"><div class="strength-fill"></div></div>
|
||||
<span class="strength-label"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-input master-confirm"
|
||||
@@ -166,6 +170,19 @@ class SecretPrompt {
|
||||
const errorEl = modal.querySelector('.secret-error');
|
||||
passInput.focus();
|
||||
|
||||
// Wire up the password strength meter for new vault creation.
|
||||
if (isNew) {
|
||||
const strengthFill = modal.querySelector('.strength-fill');
|
||||
const strengthLabel = modal.querySelector('.strength-label');
|
||||
passInput.addEventListener('input', () => {
|
||||
const result = _measureStrength(passInput.value);
|
||||
strengthFill.style.width = result.percent + '%';
|
||||
strengthFill.className = 'strength-fill strength-' + result.level;
|
||||
strengthLabel.textContent = result.label;
|
||||
strengthLabel.className = 'strength-label strength-' + result.level;
|
||||
});
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const pw = passInput.value;
|
||||
if (!pw) {
|
||||
@@ -310,6 +327,42 @@ class SecretPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates password strength based on length and character diversity.
|
||||
* Returns an object with { percent, level, label } for the strength meter.
|
||||
* @param {string} pw
|
||||
* @returns {{ percent: number, level: string, label: string }}
|
||||
* @private
|
||||
*/
|
||||
function _measureStrength(pw) {
|
||||
if (!pw) return { percent: 0, level: 'none', label: '' };
|
||||
|
||||
let score = 0;
|
||||
const len = pw.length;
|
||||
|
||||
// Length contribution (0–40 points)
|
||||
score += Math.min(len * 3, 40);
|
||||
|
||||
// Character-class diversity (0–40 points)
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasDigit = /[0-9]/.test(pw);
|
||||
const hasSymbol = /[^a-zA-Z0-9]/.test(pw);
|
||||
const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
|
||||
score += classes * 10;
|
||||
|
||||
// Unique-character ratio bonus (0–20 points)
|
||||
const unique = new Set(pw).size;
|
||||
score += Math.min(Math.floor((unique / Math.max(len, 1)) * 20), 20);
|
||||
|
||||
score = Math.min(score, 100);
|
||||
|
||||
if (score < 25) return { percent: score, level: 'weak', label: 'Weak' };
|
||||
if (score < 50) return { percent: score, level: 'fair', label: 'Fair' };
|
||||
if (score < 75) return { percent: score, level: 'good', label: 'Good' };
|
||||
return { percent: score, level: 'strong', label: 'Strong' };
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SecretPrompt = SecretPrompt;
|
||||
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — SFTP File Browser
|
||||
*
|
||||
* Provides a right-side panel for browsing, uploading, downloading,
|
||||
* renaming and deleting remote files alongside the terminal.
|
||||
*/
|
||||
|
||||
const {
|
||||
createElement,
|
||||
escapeHtml,
|
||||
formatBytes,
|
||||
relativeTime,
|
||||
dismissContextMenus,
|
||||
} = window.NexusTerminal;
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
function pathJoin(base, name) {
|
||||
if (base === "/") return "/" + name;
|
||||
return base.replace(/\/+$/, "") + "/" + name;
|
||||
}
|
||||
|
||||
function pathSegments(p) {
|
||||
const parts = p.split("/").filter(Boolean);
|
||||
var segs = [{ label: "/", path: "/" }];
|
||||
var acc = "";
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
acc += "/" + parts[i];
|
||||
segs.push({ label: parts[i], path: acc });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function parentPath(p) {
|
||||
if (p === "/") return "/";
|
||||
var trimmed = p.replace(/\/+$/, "");
|
||||
var idx = trimmed.lastIndexOf("/");
|
||||
return idx <= 0 ? "/" : trimmed.substring(0, idx);
|
||||
}
|
||||
|
||||
function fileIcon(entry) {
|
||||
if (entry.isDir) return "📁";
|
||||
var ext = (entry.name.split(".").pop() || "").toLowerCase();
|
||||
switch (ext) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "svg":
|
||||
case "webp":
|
||||
return "🖼";
|
||||
case "mp3":
|
||||
case "wav":
|
||||
case "ogg":
|
||||
case "flac":
|
||||
return "🎵";
|
||||
case "mp4":
|
||||
case "avi":
|
||||
case "mkv":
|
||||
case "mov":
|
||||
return "🎬";
|
||||
case "zip":
|
||||
case "tar":
|
||||
case "gz":
|
||||
case "bz2":
|
||||
case "xz":
|
||||
case "7z":
|
||||
return "📦";
|
||||
case "sh":
|
||||
case "bash":
|
||||
case "zsh":
|
||||
return "⚙";
|
||||
case "py":
|
||||
case "js":
|
||||
case "ts":
|
||||
case "go":
|
||||
case "rs":
|
||||
case "c":
|
||||
case "h":
|
||||
case "cpp":
|
||||
case "java":
|
||||
return "◇";
|
||||
case "md":
|
||||
case "txt":
|
||||
case "log":
|
||||
case "csv":
|
||||
return "📝";
|
||||
case "json":
|
||||
case "yaml":
|
||||
case "yml":
|
||||
case "toml":
|
||||
case "xml":
|
||||
return "⚙";
|
||||
case "key":
|
||||
case "pem":
|
||||
case "crt":
|
||||
case "pub":
|
||||
return "🔑";
|
||||
default:
|
||||
return "📄";
|
||||
}
|
||||
}
|
||||
|
||||
function modeString(mode) {
|
||||
if (!mode && mode !== 0) return "";
|
||||
var s = "";
|
||||
var perms = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
|
||||
s += perms[(mode >> 6) & 7];
|
||||
s += perms[(mode >> 3) & 7];
|
||||
s += perms[mode & 7];
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── SFTPBrowser class ────────────────────────────────────────────── */
|
||||
|
||||
class SFTPBrowser {
|
||||
/**
|
||||
* @param {App} app — the application instance
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
/** @type {Map<string, {el: HTMLElement, handle: HTMLElement, cwd: string, files: Array, selected: string|null, loading: boolean}>} */
|
||||
this.panels = new Map();
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Opens (or focuses) the SFTP panel for a session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async open(sessionId) {
|
||||
if (this.panels.has(sessionId)) return;
|
||||
|
||||
var sessionPanel = document.getElementById("panel-" + sessionId);
|
||||
if (!sessionPanel) {
|
||||
this.app.notifications.error(
|
||||
"Cannot open file browser: session panel not found.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var state = {
|
||||
el: null,
|
||||
handle: null,
|
||||
cwd: "/",
|
||||
files: [],
|
||||
selected: null,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
// Build the panel DOM
|
||||
var panel = createElement("div", "sftp-panel");
|
||||
var handle = createElement("div", "sftp-resize-handle");
|
||||
panel.appendChild(handle);
|
||||
state.el = panel;
|
||||
state.handle = handle;
|
||||
|
||||
// Toolbar
|
||||
var toolbar = this._buildToolbar(sessionId, state);
|
||||
panel.appendChild(toolbar);
|
||||
|
||||
// File list container
|
||||
var fileList = createElement("div", "sftp-file-list");
|
||||
panel.appendChild(fileList);
|
||||
|
||||
// Context action bar (hidden initially)
|
||||
var ctxBar = createElement("div", "sftp-context-bar");
|
||||
ctxBar.style.display = "none";
|
||||
panel.appendChild(ctxBar);
|
||||
|
||||
this.panels.set(sessionId, state);
|
||||
|
||||
// Append the overlay panel into the session panel
|
||||
sessionPanel.appendChild(panel);
|
||||
|
||||
this._setupResize(sessionId, state);
|
||||
|
||||
// Fetch initial directory
|
||||
await this._navigate(sessionId, null);
|
||||
|
||||
// Refit terminal after layout change
|
||||
this._refitTerminal(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the SFTP panel for a session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
close(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
state.el.remove();
|
||||
|
||||
this.panels.delete(sessionId);
|
||||
this._refitTerminal(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the SFTP panel for a session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
toggle(sessionId) {
|
||||
if (this.panels.has(sessionId)) {
|
||||
this.close(sessionId);
|
||||
} else {
|
||||
this.open(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current directory for an open SFTP panel, or an empty string.
|
||||
* @param {string} sessionId
|
||||
* @returns {string}
|
||||
*/
|
||||
getCwd(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
return state ? state.cwd : "";
|
||||
}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
|
||||
_buildToolbar(sessionId, state) {
|
||||
var toolbar = createElement("div", "sftp-toolbar");
|
||||
|
||||
var breadcrumb = createElement("div", "sftp-breadcrumb");
|
||||
breadcrumb.dataset.role = "breadcrumb";
|
||||
toolbar.appendChild(breadcrumb);
|
||||
|
||||
var actions = createElement("div", "sftp-toolbar-actions");
|
||||
|
||||
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
|
||||
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
|
||||
actions.appendChild(mkdirBtn);
|
||||
|
||||
var uploadBtn = this._toolbarBtn("↑", "Upload file", "is-accent");
|
||||
uploadBtn.addEventListener("click", () => this._upload(sessionId));
|
||||
actions.appendChild(uploadBtn);
|
||||
|
||||
var refreshBtn = this._toolbarBtn("⟳", "Refresh");
|
||||
refreshBtn.addEventListener("click", () => this._refresh(sessionId));
|
||||
actions.appendChild(refreshBtn);
|
||||
|
||||
var closeBtn = this._toolbarBtn("✕", "Close", "is-close");
|
||||
closeBtn.addEventListener("click", () => this.close(sessionId));
|
||||
actions.appendChild(closeBtn);
|
||||
|
||||
toolbar.appendChild(actions);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
_toolbarBtn(label, title, extraClass) {
|
||||
var cls = "sftp-toolbar-btn" + (extraClass ? " " + extraClass : "");
|
||||
return createElement("button", cls, { textContent: label, title: title });
|
||||
}
|
||||
|
||||
/* ── Navigation ───────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Navigates to a directory path. If path is null, fetches pwd first.
|
||||
*/
|
||||
async _navigate(sessionId, path) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state || state.loading) return;
|
||||
|
||||
state.loading = true;
|
||||
state.selected = null;
|
||||
this._renderLoading(sessionId);
|
||||
|
||||
try {
|
||||
if (path === null) {
|
||||
path = await window.go.api.Service.SFTPPwd(sessionId);
|
||||
}
|
||||
var files = await window.go.api.Service.SFTPList(sessionId, path);
|
||||
state.cwd = path;
|
||||
state.files = this._sortFiles(files || []);
|
||||
state.loading = false;
|
||||
this._renderBreadcrumb(sessionId);
|
||||
this._renderFiles(sessionId);
|
||||
} catch (err) {
|
||||
state.loading = false;
|
||||
this._renderError(sessionId, err.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async _refresh(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
await this._navigate(sessionId, state.cwd);
|
||||
}
|
||||
|
||||
_sortFiles(files) {
|
||||
return files.slice().sort(function (a, b) {
|
||||
// Directories first
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Rendering ────────────────────────────────────────────────── */
|
||||
|
||||
_getFileList(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return null;
|
||||
return state.el.querySelector(".sftp-file-list");
|
||||
}
|
||||
|
||||
_getCtxBar(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return null;
|
||||
return state.el.querySelector(".sftp-context-bar");
|
||||
}
|
||||
|
||||
_renderBreadcrumb(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
var container = state.el.querySelector('[data-role="breadcrumb"]');
|
||||
container.innerHTML = "";
|
||||
|
||||
var segs = pathSegments(state.cwd);
|
||||
var self = this;
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
if (i > 0) {
|
||||
container.appendChild(
|
||||
createElement("span", "sftp-breadcrumb-sep", { textContent: "›" }),
|
||||
);
|
||||
}
|
||||
var btn = createElement("button", "sftp-breadcrumb-segment", {
|
||||
textContent: segs[i].label,
|
||||
title: segs[i].path,
|
||||
});
|
||||
(function (p) {
|
||||
btn.addEventListener("click", function () {
|
||||
self._navigate(sessionId, p);
|
||||
});
|
||||
})(segs[i].path);
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_renderFiles(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
var list = this._getFileList(sessionId);
|
||||
list.innerHTML = "";
|
||||
|
||||
// Hide context bar
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
if (ctxBar) ctxBar.style.display = "none";
|
||||
|
||||
// Parent directory row if not at root
|
||||
if (state.cwd !== "/") {
|
||||
var parentRow = this._buildFileRow(
|
||||
{
|
||||
name: "..",
|
||||
isDir: true,
|
||||
path: parentPath(state.cwd),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
},
|
||||
sessionId,
|
||||
true,
|
||||
);
|
||||
parentRow.classList.add("is-parent");
|
||||
list.appendChild(parentRow);
|
||||
}
|
||||
|
||||
if (state.files.length === 0) {
|
||||
var empty = createElement("div", "sftp-state");
|
||||
empty.innerHTML =
|
||||
'<div class="sftp-state-icon">📂</div><div class="sftp-state-msg">Empty directory</div>';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
for (var i = 0; i < state.files.length; i++) {
|
||||
list.appendChild(this._buildFileRow(state.files[i], sessionId, false));
|
||||
}
|
||||
}
|
||||
|
||||
_buildFileRow(entry, sessionId, isParent) {
|
||||
var row = createElement("div", "sftp-file-row");
|
||||
var self = this;
|
||||
|
||||
// Icon
|
||||
var icon = createElement(
|
||||
"span",
|
||||
"sftp-file-icon" + (entry.isDir ? " is-dir" : ""),
|
||||
);
|
||||
icon.textContent = isParent ? "↩" : fileIcon(entry);
|
||||
row.appendChild(icon);
|
||||
|
||||
// Name
|
||||
var name = createElement("span", "sftp-file-name", {
|
||||
textContent: entry.name,
|
||||
title: entry.name,
|
||||
});
|
||||
row.appendChild(name);
|
||||
|
||||
// Meta (size or date)
|
||||
var meta = createElement("span", "sftp-file-meta");
|
||||
if (!isParent && !entry.isDir) {
|
||||
meta.textContent = formatBytes(entry.size || 0);
|
||||
meta.title = entry.modifiedAt
|
||||
? new Date(entry.modifiedAt).toLocaleString()
|
||||
: "";
|
||||
} else if (!isParent && entry.isDir) {
|
||||
meta.textContent = "";
|
||||
}
|
||||
row.appendChild(meta);
|
||||
|
||||
// Double-click: navigate into directory
|
||||
if (entry.isDir) {
|
||||
row.addEventListener("dblclick", function () {
|
||||
var target = isParent
|
||||
? parentPath(self.panels.get(sessionId).cwd)
|
||||
: entry.path;
|
||||
self._navigate(sessionId, target);
|
||||
});
|
||||
}
|
||||
|
||||
// Single click: select (files only, or directories too for context actions)
|
||||
if (!isParent) {
|
||||
row.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._selectFile(sessionId, entry, row);
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
_renderLoading(sessionId) {
|
||||
var list = this._getFileList(sessionId);
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
var s = createElement("div", "sftp-state");
|
||||
s.innerHTML =
|
||||
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading…</div>';
|
||||
list.appendChild(s);
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
if (ctxBar) ctxBar.style.display = "none";
|
||||
}
|
||||
|
||||
_renderError(sessionId, msg) {
|
||||
var list = this._getFileList(sessionId);
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
var s = createElement("div", "sftp-state");
|
||||
s.innerHTML =
|
||||
'<div class="sftp-state-icon">⚠</div>' +
|
||||
'<div class="sftp-state-msg">' +
|
||||
escapeHtml(msg) +
|
||||
"</div>" +
|
||||
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
|
||||
var self = this;
|
||||
var retryBtn = s.querySelector(".sftp-state-retry");
|
||||
if (retryBtn) {
|
||||
retryBtn.addEventListener("click", function () {
|
||||
self._refresh(sessionId);
|
||||
});
|
||||
}
|
||||
list.appendChild(s);
|
||||
}
|
||||
|
||||
/* ── Selection & context actions ──────────────────────────────── */
|
||||
|
||||
_selectFile(sessionId, entry, rowEl) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
// Deselect previous
|
||||
var prev = state.el.querySelector(".sftp-file-row.is-selected");
|
||||
if (prev) prev.classList.remove("is-selected");
|
||||
|
||||
// If clicking the same entry, deselect
|
||||
if (state.selected === entry.path) {
|
||||
state.selected = null;
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
if (ctxBar) ctxBar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
state.selected = entry.path;
|
||||
rowEl.classList.add("is-selected");
|
||||
this._showContextBar(sessionId, entry);
|
||||
}
|
||||
|
||||
_showContextBar(sessionId, entry) {
|
||||
var ctxBar = this._getCtxBar(sessionId);
|
||||
if (!ctxBar) return;
|
||||
|
||||
ctxBar.innerHTML = "";
|
||||
ctxBar.style.display = "flex";
|
||||
|
||||
var label = createElement("span", "sftp-ctx-label", {
|
||||
textContent: entry.name,
|
||||
title: entry.path,
|
||||
});
|
||||
ctxBar.appendChild(label);
|
||||
|
||||
var self = this;
|
||||
|
||||
if (!entry.isDir) {
|
||||
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
|
||||
textContent: "↓ Download",
|
||||
});
|
||||
dlBtn.addEventListener("click", function () {
|
||||
self._download(sessionId, entry.path);
|
||||
});
|
||||
ctxBar.appendChild(dlBtn);
|
||||
}
|
||||
|
||||
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||
textContent: "✎ Rename",
|
||||
});
|
||||
renameBtn.addEventListener("click", function () {
|
||||
self._promptRename(sessionId, entry);
|
||||
});
|
||||
ctxBar.appendChild(renameBtn);
|
||||
|
||||
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
|
||||
textContent: "✕ Delete",
|
||||
});
|
||||
delBtn.addEventListener("click", function () {
|
||||
self._confirmDelete(sessionId, entry);
|
||||
});
|
||||
ctxBar.appendChild(delBtn);
|
||||
}
|
||||
|
||||
/* ── File operations ──────────────────────────────────────────── */
|
||||
|
||||
async _download(sessionId, filePath) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPDownload(sessionId, filePath);
|
||||
this.app.notifications.success("Download started");
|
||||
} catch (err) {
|
||||
this.app.notifications.error(
|
||||
"Download failed: " + (err.message || err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async _upload(sessionId) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
try {
|
||||
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
|
||||
this.app.notifications.success("Upload complete");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
// User may have cancelled the dialog — only show error if meaningful
|
||||
var msg = err.message || String(err);
|
||||
if (msg && msg.toLowerCase().indexOf("cancel") === -1) {
|
||||
this.app.notifications.error("Upload failed: " + msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _mkdir(sessionId, name) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
var fullPath = pathJoin(state.cwd, name);
|
||||
try {
|
||||
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
|
||||
this.app.notifications.success("Directory created");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("mkdir failed: " + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
async _rename(sessionId, oldPath, newName) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
var newPath = pathJoin(parentPath(oldPath), newName);
|
||||
try {
|
||||
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
|
||||
this.app.notifications.success("Renamed");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Rename failed: " + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(sessionId, entry) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
|
||||
this.app.notifications.success(
|
||||
(entry.isDir ? "Directory" : "File") + " deleted",
|
||||
);
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Delete failed: " + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */
|
||||
|
||||
_promptMkdir(sessionId) {
|
||||
this._showInputModal({
|
||||
title: "New Folder",
|
||||
placeholder: "Folder name",
|
||||
confirmLabel: "Create",
|
||||
onConfirm: function (value) {
|
||||
if (value.trim()) this._mkdir(sessionId, value.trim());
|
||||
}.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
_promptRename(sessionId, entry) {
|
||||
this._showInputModal({
|
||||
title: "Rename",
|
||||
placeholder: "New name",
|
||||
initialValue: entry.name,
|
||||
confirmLabel: "Rename",
|
||||
onConfirm: function (value) {
|
||||
var v = value.trim();
|
||||
if (v && v !== entry.name) this._rename(sessionId, entry.path, v);
|
||||
}.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
_confirmDelete(sessionId, entry) {
|
||||
this._showConfirmModal({
|
||||
title: "Delete " + (entry.isDir ? "Folder" : "File"),
|
||||
message:
|
||||
"Permanently delete <strong>" + escapeHtml(entry.name) + "</strong>?",
|
||||
confirmLabel: "Delete",
|
||||
isDanger: true,
|
||||
onConfirm: function () {
|
||||
this._delete(sessionId, entry);
|
||||
}.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a modal with a text input field.
|
||||
*/
|
||||
_showInputModal(opts) {
|
||||
var backdrop = createElement("div", "modal-backdrop");
|
||||
var modal = createElement("div", "modal modal-sm");
|
||||
modal.innerHTML =
|
||||
'<div class="modal-header">' +
|
||||
'<span class="modal-title">' +
|
||||
escapeHtml(opts.title) +
|
||||
"</span>" +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
"</div>" +
|
||||
'<div class="modal-body">' +
|
||||
'<input class="sftp-inline-input" type="text" placeholder="' +
|
||||
escapeHtml(opts.placeholder || "") +
|
||||
'"' +
|
||||
(opts.initialValue
|
||||
? ' value="' + escapeHtml(opts.initialValue) + '"'
|
||||
: "") +
|
||||
" />" +
|
||||
"</div>" +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||
'<button class="btn btn-sm btn-primary sftp-modal-confirm">' +
|
||||
escapeHtml(opts.confirmLabel || "OK") +
|
||||
"</button>" +
|
||||
"</div>";
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
var input = modal.querySelector(".sftp-inline-input");
|
||||
requestAnimationFrame(function () {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
var closed = false;
|
||||
function close(confirmed) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(function () {
|
||||
backdrop.remove();
|
||||
}, 200);
|
||||
if (confirmed && opts.onConfirm) {
|
||||
opts.onConfirm(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", function () {
|
||||
close(false);
|
||||
});
|
||||
modal
|
||||
.querySelector(".sftp-modal-cancel")
|
||||
.addEventListener("click", function () {
|
||||
close(false);
|
||||
});
|
||||
modal
|
||||
.querySelector(".sftp-modal-confirm")
|
||||
.addEventListener("click", function () {
|
||||
close(true);
|
||||
});
|
||||
backdrop.addEventListener("click", function (e) {
|
||||
if (e.target === backdrop) close(false);
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") close(true);
|
||||
if (e.key === "Escape") close(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a confirmation modal (no native confirm).
|
||||
*/
|
||||
_showConfirmModal(opts) {
|
||||
var backdrop = createElement("div", "modal-backdrop");
|
||||
var modal = createElement("div", "modal modal-sm confirm-dialog");
|
||||
modal.innerHTML =
|
||||
'<div class="modal-header">' +
|
||||
'<span class="modal-title">' +
|
||||
escapeHtml(opts.title) +
|
||||
"</span>" +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
"</div>" +
|
||||
'<div class="modal-body">' +
|
||||
"<p>" +
|
||||
(opts.message || "") +
|
||||
"</p>" +
|
||||
"</div>" +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||
'<button class="btn btn-sm ' +
|
||||
(opts.isDanger ? "btn-danger-filled" : "btn-primary") +
|
||||
' sftp-modal-confirm">' +
|
||||
escapeHtml(opts.confirmLabel || "Confirm") +
|
||||
"</button>" +
|
||||
"</div>";
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
var closed = false;
|
||||
function close(confirmed) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(function () {
|
||||
backdrop.remove();
|
||||
}, 200);
|
||||
if (confirmed && opts.onConfirm) {
|
||||
opts.onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", function () {
|
||||
close(false);
|
||||
});
|
||||
modal
|
||||
.querySelector(".sftp-modal-cancel")
|
||||
.addEventListener("click", function () {
|
||||
close(false);
|
||||
});
|
||||
modal
|
||||
.querySelector(".sftp-modal-confirm")
|
||||
.addEventListener("click", function () {
|
||||
close(true);
|
||||
});
|
||||
backdrop.addEventListener("click", function (e) {
|
||||
if (e.target === backdrop) close(false);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Resize handle ────────────────────────────────────────────── */
|
||||
|
||||
_setupResize(sessionId, state) {
|
||||
var handle = state.handle;
|
||||
var panel = state.el;
|
||||
var self = this;
|
||||
var startX, startWidth;
|
||||
|
||||
function onMouseMove(e) {
|
||||
var delta = startX - e.clientX;
|
||||
var newWidth = Math.max(
|
||||
180,
|
||||
Math.min(startWidth + delta, window.innerWidth * 0.5),
|
||||
);
|
||||
panel.style.width = newWidth + "px";
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
handle.classList.remove("is-dragging");
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
self._refitTerminal(sessionId);
|
||||
}
|
||||
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
handle.classList.add("is-dragging");
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Terminal refit ────────────────────────────────────────────── */
|
||||
|
||||
_refitTerminal(sessionId) {
|
||||
if (
|
||||
this.app.terminals &&
|
||||
typeof this.app.terminals.fitSession === "function"
|
||||
) {
|
||||
// Small delay lets the layout settle before measuring
|
||||
setTimeout(
|
||||
function () {
|
||||
this.app.terminals.fitSession(sessionId);
|
||||
}.bind(this),
|
||||
50,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Export ────────────────────────────────────────────────────────── */
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SFTPBrowser = SFTPBrowser;
|
||||
})();
|
||||
@@ -0,0 +1,306 @@
|
||||
(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.
|
||||
* @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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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">×</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;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,600 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — Split Pane Manager.
|
||||
* Enables splitting session panels into multiple panes, each hosting
|
||||
* its own terminal session. Supports horizontal (top/bottom) and
|
||||
* vertical (left/right) splits with draggable resize dividers.
|
||||
*/
|
||||
|
||||
var MIN_PANE_SIZE = 100; // px
|
||||
|
||||
/**
|
||||
* Unique pane id counter.
|
||||
* @type {number}
|
||||
*/
|
||||
var _paneIdCounter = 0;
|
||||
|
||||
function nextPaneId() {
|
||||
return 'pane-' + (++_paneIdCounter);
|
||||
}
|
||||
|
||||
class SplitPaneManager {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/**
|
||||
* Map of sessionId → split tree root (or null if unsplit).
|
||||
* The tree is a simple object:
|
||||
* Leaf: { type: 'leaf', paneId, el, sessionId }
|
||||
* Branch: { type: 'branch', direction, first, second, el, divider, ratio }
|
||||
* @type {Map<string, Object|null>}
|
||||
*/
|
||||
this.trees = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits the active session panel vertically (left | right).
|
||||
*/
|
||||
splitVertical() {
|
||||
this._splitActive('vertical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the active session panel horizontally (top / bottom).
|
||||
*/
|
||||
splitHorizontal() {
|
||||
this._splitActive('horizontal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all split state for a session (used on tab close).
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
cleanup(sessionId) {
|
||||
this.trees.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fits all terminals visible in the active session's pane tree.
|
||||
*/
|
||||
refitAll() {
|
||||
var activeId = this.app.state.activeSessionId;
|
||||
if (!activeId) return;
|
||||
var tree = this.trees.get(activeId);
|
||||
if (tree) this._refitTree(tree);
|
||||
}
|
||||
|
||||
// ── Core split logic ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits the active session panel.
|
||||
* @param {'horizontal'|'vertical'} direction
|
||||
* @private
|
||||
*/
|
||||
_splitActive(direction) {
|
||||
var sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
var panel = document.getElementById('panel-' + sessionId);
|
||||
if (!panel) return;
|
||||
|
||||
var tree = this.trees.get(sessionId);
|
||||
|
||||
if (!tree) {
|
||||
// First split — wrap existing content
|
||||
tree = this._initTree(panel, sessionId);
|
||||
}
|
||||
|
||||
// Find the focused leaf (or the first leaf)
|
||||
var leaf = this._findFocusedLeaf(tree) || this._firstLeaf(tree);
|
||||
if (!leaf) return;
|
||||
|
||||
this._splitLeaf(leaf, direction, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial tree structure from an unsplit panel.
|
||||
* Wraps the existing terminal-container in a split-pane leaf.
|
||||
* @param {HTMLElement} panel
|
||||
* @param {string} sessionId
|
||||
* @returns {Object} root leaf node
|
||||
* @private
|
||||
*/
|
||||
_initTree(panel, sessionId) {
|
||||
var termContainer = panel.querySelector('.terminal-container');
|
||||
// For non-terminal panels (raw, github), wrap whatever content exists
|
||||
var contentChildren = Array.from(panel.children);
|
||||
|
||||
var paneId = nextPaneId();
|
||||
var paneEl = document.createElement('div');
|
||||
paneEl.className = 'split-pane';
|
||||
paneEl.dataset.paneId = paneId;
|
||||
paneEl.style.flex = '1';
|
||||
|
||||
// Move existing content into the pane
|
||||
contentChildren.forEach(function(child) {
|
||||
paneEl.appendChild(child);
|
||||
});
|
||||
|
||||
// Reset terminal-container from absolute to relative positioning inside a pane
|
||||
if (termContainer) {
|
||||
termContainer.style.position = 'relative';
|
||||
termContainer.style.inset = 'auto';
|
||||
termContainer.style.flex = '1';
|
||||
termContainer.style.width = '100%';
|
||||
termContainer.style.height = '100%';
|
||||
}
|
||||
|
||||
panel.appendChild(paneEl);
|
||||
|
||||
var leaf = {
|
||||
type: 'leaf',
|
||||
paneId: paneId,
|
||||
el: paneEl,
|
||||
sessionId: sessionId,
|
||||
parent: null
|
||||
};
|
||||
|
||||
paneEl._splitNode = leaf;
|
||||
this.trees.set(sessionId, leaf);
|
||||
|
||||
// Add focus tracking
|
||||
this._addFocusTracking(paneEl);
|
||||
|
||||
return leaf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a leaf node into two panes.
|
||||
* @param {Object} leaf - The leaf node to split.
|
||||
* @param {'horizontal'|'vertical'} direction
|
||||
* @param {string} ownerSessionId - The session that owns this panel.
|
||||
* @private
|
||||
*/
|
||||
_splitLeaf(leaf, direction, ownerSessionId) {
|
||||
var parentBranch = leaf.parent;
|
||||
var panel = this._findPanel(ownerSessionId);
|
||||
if (!panel) return;
|
||||
|
||||
// Create a container for the two panes
|
||||
var containerEl = document.createElement('div');
|
||||
containerEl.className = 'split-container split-' + direction;
|
||||
containerEl.style.flex = leaf.el.style.flex || '1';
|
||||
|
||||
// The existing leaf becomes the first child
|
||||
leaf.el.style.flex = '1';
|
||||
|
||||
// Create divider
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
// Create new empty pane
|
||||
var newPaneId = nextPaneId();
|
||||
var newPaneEl = document.createElement('div');
|
||||
newPaneEl.className = 'split-pane';
|
||||
newPaneEl.dataset.paneId = newPaneId;
|
||||
newPaneEl.style.flex = '1';
|
||||
|
||||
// Add a placeholder with session picker to the new pane
|
||||
this._renderPlaceholder(newPaneEl, ownerSessionId);
|
||||
|
||||
// Add close buttons to both panes
|
||||
this._addCloseButton(leaf.el, ownerSessionId);
|
||||
this._addCloseButton(newPaneEl, ownerSessionId);
|
||||
|
||||
// Add focus tracking
|
||||
this._addFocusTracking(newPaneEl);
|
||||
|
||||
// Build the new branch node
|
||||
var newLeaf = {
|
||||
type: 'leaf',
|
||||
paneId: newPaneId,
|
||||
el: newPaneEl,
|
||||
sessionId: null,
|
||||
parent: null
|
||||
};
|
||||
newPaneEl._splitNode = newLeaf;
|
||||
|
||||
var branch = {
|
||||
type: 'branch',
|
||||
direction: direction,
|
||||
first: leaf,
|
||||
second: newLeaf,
|
||||
el: containerEl,
|
||||
divider: divider,
|
||||
ratio: 0.5,
|
||||
parent: parentBranch
|
||||
};
|
||||
|
||||
leaf.parent = branch;
|
||||
newLeaf.parent = branch;
|
||||
containerEl._splitNode = branch;
|
||||
|
||||
// Replace the leaf element in the DOM
|
||||
var leafParent = leaf.el.parentNode;
|
||||
leafParent.insertBefore(containerEl, leaf.el);
|
||||
containerEl.appendChild(leaf.el);
|
||||
containerEl.appendChild(divider);
|
||||
containerEl.appendChild(newPaneEl);
|
||||
|
||||
// Update the tree structure
|
||||
if (parentBranch) {
|
||||
if (parentBranch.first === leaf) {
|
||||
parentBranch.first = branch;
|
||||
} else {
|
||||
parentBranch.second = branch;
|
||||
}
|
||||
} else {
|
||||
// This was the root leaf — the branch becomes the new root
|
||||
this.trees.set(ownerSessionId, branch);
|
||||
}
|
||||
|
||||
// Set up divider dragging
|
||||
this._setupDividerDrag(divider, branch);
|
||||
|
||||
// Refit terminals after layout change
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._refitTree(self.trees.get(ownerSessionId));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pane closing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Closes a pane and unsplits, keeping the sibling.
|
||||
* @param {string} paneId
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_closePane(paneId, ownerSessionId) {
|
||||
var root = this.trees.get(ownerSessionId);
|
||||
if (!root) return;
|
||||
|
||||
var leaf = this._findLeafById(root, paneId);
|
||||
if (!leaf || !leaf.parent) {
|
||||
// Only one pane left — can't close it
|
||||
return;
|
||||
}
|
||||
|
||||
var branch = leaf.parent;
|
||||
var sibling = (branch.first === leaf) ? branch.second : branch.first;
|
||||
|
||||
// If the closing pane has a terminal session, destroy it (but not the owner session)
|
||||
if (leaf.sessionId && leaf.sessionId !== ownerSessionId) {
|
||||
this.app.terminals.destroyTerminal(leaf.sessionId);
|
||||
}
|
||||
|
||||
// Replace the branch element with the sibling element in the DOM
|
||||
sibling.el.style.flex = branch.el.style.flex || '1';
|
||||
branch.el.parentNode.insertBefore(sibling.el, branch.el);
|
||||
branch.el.remove();
|
||||
|
||||
// Remove close button from sibling if it's now the only pane
|
||||
sibling.parent = branch.parent;
|
||||
if (branch.parent) {
|
||||
if (branch.parent.first === branch) {
|
||||
branch.parent.first = sibling;
|
||||
} else {
|
||||
branch.parent.second = sibling;
|
||||
}
|
||||
} else {
|
||||
// Sibling is now root
|
||||
this.trees.set(ownerSessionId, sibling);
|
||||
}
|
||||
|
||||
// Remove close button if we're back to a single leaf
|
||||
var newRoot = this.trees.get(ownerSessionId);
|
||||
if (newRoot && newRoot.type === 'leaf') {
|
||||
var closeBtn = newRoot.el.querySelector(':scope > .split-pane-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
}
|
||||
|
||||
// Refit terminals
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._refitTree(self.trees.get(ownerSessionId));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Divider drag ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets up mouse drag behaviour on a divider.
|
||||
* @param {HTMLElement} divider
|
||||
* @param {Object} branch
|
||||
* @private
|
||||
*/
|
||||
_setupDividerDrag(divider, branch) {
|
||||
var self = this;
|
||||
|
||||
divider.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var isHorizontal = branch.direction === 'horizontal';
|
||||
var bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
|
||||
document.body.classList.add(bodyClass);
|
||||
divider.classList.add('is-dragging');
|
||||
|
||||
var containerRect = branch.el.getBoundingClientRect();
|
||||
var dividerSize = isHorizontal ? 4 : 4; // divider thickness
|
||||
|
||||
function onMouseMove(e) {
|
||||
var totalSize, mousePos;
|
||||
if (isHorizontal) {
|
||||
totalSize = containerRect.height - dividerSize;
|
||||
mousePos = e.clientY - containerRect.top;
|
||||
} else {
|
||||
totalSize = containerRect.width - dividerSize;
|
||||
mousePos = e.clientX - containerRect.left;
|
||||
}
|
||||
|
||||
var ratio = mousePos / (totalSize + dividerSize);
|
||||
ratio = Math.max(MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
|
||||
ratio = Math.min(1 - MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
|
||||
|
||||
branch.ratio = ratio;
|
||||
branch.first.el.style.flex = ratio + '';
|
||||
branch.second.el.style.flex = (1 - ratio) + '';
|
||||
|
||||
// Refit terminals in this branch
|
||||
self._refitTree(branch);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.body.classList.remove(bodyClass);
|
||||
divider.classList.remove('is-dragging');
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Final refit
|
||||
self._refitTree(branch);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Placeholder / session picker ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a placeholder with a list of available profiles to connect.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_renderPlaceholder(paneEl, ownerSessionId) {
|
||||
var placeholder = document.createElement('div');
|
||||
placeholder.className = 'split-pane-placeholder';
|
||||
|
||||
var icon = document.createElement('div');
|
||||
icon.className = 'split-pane-placeholder-icon';
|
||||
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>';
|
||||
|
||||
var text = document.createElement('div');
|
||||
text.className = 'split-pane-placeholder-text';
|
||||
text.textContent = 'Select a connection for this pane';
|
||||
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(text);
|
||||
|
||||
// Build profile list
|
||||
var profiles = this.app.state.profiles || [];
|
||||
if (profiles.length > 0) {
|
||||
var list = document.createElement('div');
|
||||
list.className = 'split-pane-profile-list';
|
||||
|
||||
var self = this;
|
||||
profiles.forEach(function(profile) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'split-pane-profile-btn';
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'protocol-badge';
|
||||
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
|
||||
badge.style.color = self._protocolColor(profile.protocol);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = profile.displayName || profile.host || profile.id;
|
||||
|
||||
btn.appendChild(badge);
|
||||
btn.appendChild(label);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
self._connectInPane(paneEl, profile, ownerSessionId);
|
||||
});
|
||||
|
||||
list.appendChild(btn);
|
||||
});
|
||||
|
||||
placeholder.appendChild(list);
|
||||
}
|
||||
|
||||
paneEl.appendChild(placeholder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects a profile in a specific pane.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {Object} profile
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_connectInPane(paneEl, profile, ownerSessionId) {
|
||||
var self = this;
|
||||
var node = paneEl._splitNode;
|
||||
|
||||
// Remove placeholder
|
||||
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// Connect via backend
|
||||
window.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;
|
||||
|
||||
})();
|
||||
@@ -25,6 +25,9 @@ class State {
|
||||
/** @type {boolean} */
|
||||
this.secretStoreInitialised = false;
|
||||
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — tab management.
|
||||
* Handles the tab bar: adding, removing, reordering, renaming,
|
||||
* pinning tabs, and context menus.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — tab management.
|
||||
* Handles the tab bar: adding, removing, reordering, renaming,
|
||||
* pinning tabs, and context menus.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, protocolColour, stateLabel, dismissContextMenus } = window.NexusTerminal;
|
||||
const {
|
||||
createElement,
|
||||
escapeHtml,
|
||||
protocolColour,
|
||||
stateLabel,
|
||||
dismissContextMenus,
|
||||
} = window.NexusTerminal;
|
||||
|
||||
class TabManager {
|
||||
class TabManager {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.app = app;
|
||||
|
||||
/** @type {HTMLElement} The inner tab list where tab elements are inserted. */
|
||||
this.tabList = document.getElementById('tab-list');
|
||||
/** @type {HTMLElement} The inner tab list where tab elements are inserted. */
|
||||
this.tabList = document.getElementById("tab-list");
|
||||
|
||||
/** @type {HTMLElement} The outer tab bar wrapper (used for drag-and-drop). */
|
||||
this.tabBar = document.getElementById('tab-bar');
|
||||
/** @type {HTMLElement} The outer tab bar wrapper (used for drag-and-drop). */
|
||||
this.tabBar = document.getElementById("tab-bar");
|
||||
|
||||
/** @type {HTMLElement} The container for terminal/viewer panels. */
|
||||
this.panelContainer = document.getElementById('panel-container');
|
||||
/** @type {HTMLElement} The container for terminal/viewer panels. */
|
||||
this.panelContainer = document.getElementById("panel-container");
|
||||
|
||||
/** @type {string[]} Ordered list of session IDs. */
|
||||
this.order = [];
|
||||
/** @type {string[]} Ordered list of session IDs. */
|
||||
this.order = [];
|
||||
|
||||
this._setupDragAndDrop();
|
||||
this._setupDragAndDrop();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -36,21 +42,21 @@ class TabManager {
|
||||
* @param {Object} sessionInfo
|
||||
*/
|
||||
addTab(sessionInfo) {
|
||||
this.order.push(sessionInfo.id);
|
||||
this._renderTab(sessionInfo);
|
||||
this.order.push(sessionInfo.id);
|
||||
this._renderTab(sessionInfo);
|
||||
|
||||
// Create panel container for this session's terminal/viewer
|
||||
const panel = createElement('div', 'session-panel', {
|
||||
id: `panel-${sessionInfo.id}`,
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
});
|
||||
panel.style.display = 'none';
|
||||
this.panelContainer.appendChild(panel);
|
||||
// Create panel container for this session's terminal/viewer
|
||||
const panel = createElement("div", "session-panel", {
|
||||
id: `panel-${sessionInfo.id}`,
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
});
|
||||
panel.style.display = "none";
|
||||
this.panelContainer.appendChild(panel);
|
||||
|
||||
// Hide the empty-state BEFORE activating the panel so the
|
||||
// panel-container gets the full workspace height immediately.
|
||||
this._updateEmptyState();
|
||||
this.setActive(sessionInfo.id);
|
||||
// Hide the empty-state BEFORE activating the panel so the
|
||||
// panel-container gets the full workspace height immediately.
|
||||
this._updateEmptyState();
|
||||
this.setActive(sessionInfo.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,30 +64,33 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
removeTab(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx !== -1) this.order.splice(idx, 1);
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx !== -1) this.order.splice(idx, 1);
|
||||
|
||||
// Remove tab element
|
||||
const tabEl = this.tabList.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl) tabEl.remove();
|
||||
// Remove tab element
|
||||
const tabEl = this.tabList.querySelector(
|
||||
`[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl) tabEl.remove();
|
||||
|
||||
// Remove panel
|
||||
const panel = document.getElementById(`panel-${sessionId}`);
|
||||
if (panel) panel.remove();
|
||||
// Remove panel
|
||||
const panel = document.getElementById(`panel-${sessionId}`);
|
||||
if (panel) panel.remove();
|
||||
|
||||
// If this was the active tab, activate the nearest neighbour
|
||||
if (this.app.state.activeSessionId === sessionId) {
|
||||
const next = this.order.length > 0
|
||||
? this.order[Math.min(idx, this.order.length - 1)]
|
||||
: null;
|
||||
if (next) {
|
||||
this.setActive(next);
|
||||
} else {
|
||||
this.app.state.setActiveSession(null);
|
||||
}
|
||||
// If this was the active tab, activate the nearest neighbour
|
||||
if (this.app.state.activeSessionId === sessionId) {
|
||||
const next =
|
||||
this.order.length > 0
|
||||
? this.order[Math.min(idx, this.order.length - 1)]
|
||||
: null;
|
||||
if (next) {
|
||||
this.setActive(next);
|
||||
} else {
|
||||
this.app.state.setActiveSession(null);
|
||||
}
|
||||
}
|
||||
|
||||
this._updateEmptyState();
|
||||
this._updateEmptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,35 +98,46 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
setActive(sessionId) {
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
|
||||
// Highlight active tab
|
||||
this.tabList.querySelectorAll('.tab').forEach((t) => {
|
||||
t.classList.toggle('tab-active', t.dataset.sessionId === sessionId);
|
||||
});
|
||||
// Highlight active tab
|
||||
this.tabList.querySelectorAll(".tab").forEach((t) => {
|
||||
t.classList.toggle("tab-active", t.dataset.sessionId === sessionId);
|
||||
});
|
||||
|
||||
// Show corresponding panel, hide others
|
||||
this.panelContainer.querySelectorAll('.session-panel').forEach((p) => {
|
||||
p.style.display = p.dataset.sessionId === sessionId ? 'flex' : 'none';
|
||||
});
|
||||
// Show corresponding panel, hide others
|
||||
this.panelContainer.querySelectorAll(".session-panel").forEach((p) => {
|
||||
p.style.display = p.dataset.sessionId === sessionId ? "flex" : "none";
|
||||
});
|
||||
|
||||
// Notify sidebar
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session) {
|
||||
this.app.sidebar.highlightActive(session.profileId);
|
||||
}
|
||||
// Notify sidebar
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session) {
|
||||
this.app.sidebar.highlightActive(session.profileId);
|
||||
}
|
||||
|
||||
// Fit the terminal for the newly active session
|
||||
this.app.terminals.fitActive();
|
||||
// Fit the terminal for the newly active session
|
||||
this.app.terminals.fitActive();
|
||||
requestAnimationFrame(() => {
|
||||
this.app.terminals.focusTerminal(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the currently active tab.
|
||||
*/
|
||||
closeActive() {
|
||||
if (this.app.state.activeSessionId) {
|
||||
this.closeTab(this.app.state.activeSessionId);
|
||||
}
|
||||
if (this.app.state.activeSessionId) {
|
||||
this.closeTab(this.app.state.activeSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnects a session by ID.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
reconnectSession(sessionId) {
|
||||
return this._reconnect(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,25 +145,29 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async closeTab(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
|
||||
// Ask for confirmation if connected and the preference is set
|
||||
if (session && session.state === 'connected' && this.app.state.preferences.confirmOnDisconnect) {
|
||||
const ok = await this._confirmClose(sessionId);
|
||||
if (!ok) return;
|
||||
}
|
||||
// Ask for confirmation if connected and the preference is set
|
||||
if (
|
||||
session &&
|
||||
session.state === "connected" &&
|
||||
this.app.state.preferences.confirmOnDisconnect
|
||||
) {
|
||||
const ok = await this._confirmClose(sessionId);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DisconnectSession(sessionId);
|
||||
} catch (_) {
|
||||
// Session may already be closed — carry on
|
||||
}
|
||||
try {
|
||||
await window.go.api.Service.DisconnectSession(sessionId);
|
||||
} catch (_) {
|
||||
// Session may already be closed — carry on
|
||||
}
|
||||
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,12 +177,12 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_confirmClose(sessionId) {
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = this._tabLabel(sessionId);
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
modal.innerHTML = `
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = this._tabLabel(sessionId);
|
||||
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">Close Session</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
@@ -173,24 +197,32 @@ class TabManager {
|
||||
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</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); });
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,11 +231,13 @@ class TabManager {
|
||||
* @param {string} name
|
||||
*/
|
||||
renameTab(sessionId, name) {
|
||||
this.app.state.updateSession(sessionId, { tabName: name });
|
||||
const tabEl = this.tabList.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl) {
|
||||
tabEl.querySelector('.tab-name').textContent = name;
|
||||
}
|
||||
this.app.state.updateSession(sessionId, { tabName: name });
|
||||
const tabEl = this.tabList.querySelector(
|
||||
`[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl) {
|
||||
tabEl.querySelector(".tab-name").textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,9 +245,9 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
pinTab(sessionId) {
|
||||
this.app.state.updateSession(sessionId, { pinned: true });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
this.app.state.updateSession(sessionId, { pinned: true });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,9 +255,9 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
unpinTab(sessionId) {
|
||||
this.app.state.updateSession(sessionId, { pinned: false });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
this.app.state.updateSession(sessionId, { pinned: false });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,11 +265,14 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
moveLeft(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx > 0) {
|
||||
[this.order[idx - 1], this.order[idx]] = [this.order[idx], this.order[idx - 1]];
|
||||
this._reorderDOM();
|
||||
}
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx > 0) {
|
||||
[this.order[idx - 1], this.order[idx]] = [
|
||||
this.order[idx],
|
||||
this.order[idx - 1],
|
||||
];
|
||||
this._reorderDOM();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,11 +280,14 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
moveRight(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx >= 0 && idx < this.order.length - 1) {
|
||||
[this.order[idx], this.order[idx + 1]] = [this.order[idx + 1], this.order[idx]];
|
||||
this._reorderDOM();
|
||||
}
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx >= 0 && idx < this.order.length - 1) {
|
||||
[this.order[idx], this.order[idx + 1]] = [
|
||||
this.order[idx + 1],
|
||||
this.order[idx],
|
||||
];
|
||||
this._reorderDOM();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,10 +295,10 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async duplicateTab(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session && session.profileId) {
|
||||
await this.app.connectProfile(session.profileId);
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session && session.profileId) {
|
||||
await this.app.connectProfile(session.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,20 +308,22 @@ class TabManager {
|
||||
* @param {string} [error]
|
||||
*/
|
||||
updateSessionState(sessionId, state, error) {
|
||||
this.app.state.updateSession(sessionId, { state, error: error || '' });
|
||||
this.app.state.updateSession(sessionId, { state, error: error || "" });
|
||||
|
||||
const tabEl = this.tabList.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (!tabEl) return;
|
||||
const tabEl = this.tabList.querySelector(
|
||||
`[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (!tabEl) return;
|
||||
|
||||
const dot = tabEl.querySelector('.tab-status');
|
||||
if (dot) {
|
||||
dot.className = `tab-status status-${state}`;
|
||||
dot.title = stateLabel(state);
|
||||
}
|
||||
const dot = tabEl.querySelector(".tab-status");
|
||||
if (dot) {
|
||||
dot.className = `tab-status status-${state}`;
|
||||
dot.title = stateLabel(state);
|
||||
}
|
||||
|
||||
if (state === 'error' && error) {
|
||||
this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`);
|
||||
}
|
||||
if (state === "error" && error) {
|
||||
this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +331,7 @@ class TabManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
handleSessionClosed(sessionId) {
|
||||
this.updateSessionState(sessionId, 'disconnected');
|
||||
this.updateSessionState(sessionId, "disconnected");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,58 +341,73 @@ class TabManager {
|
||||
* @param {number} y
|
||||
*/
|
||||
showContextMenu(sessionId, x, y) {
|
||||
dismissContextMenus();
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session) return;
|
||||
dismissContextMenus();
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const menu = createElement('div', 'context-menu');
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
const menu = createElement("div", "context-menu");
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
|
||||
const isPinned = session.pinned;
|
||||
const isPinned = session.pinned;
|
||||
|
||||
const items = [
|
||||
{ label: 'Rename…', action: () => this._promptRename(sessionId) },
|
||||
{ label: 'Duplicate', action: () => this.duplicateTab(sessionId) },
|
||||
{ divider: true },
|
||||
{ label: isPinned ? 'Unpin' : 'Pin', action: () => isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId) },
|
||||
{ label: 'Move Left', action: () => this.moveLeft(sessionId) },
|
||||
{ label: 'Move Right', action: () => this.moveRight(sessionId) },
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Reconnect',
|
||||
action: () => this._reconnect(sessionId),
|
||||
disabled: session.state === 'connected' || session.state === 'connecting',
|
||||
},
|
||||
{ divider: true },
|
||||
{ label: 'Close', className: 'context-menu-danger', action: () => this.closeTab(sessionId) },
|
||||
];
|
||||
const items = [
|
||||
{ label: "Rename…", action: () => this._promptRename(sessionId) },
|
||||
{ label: "Duplicate", action: () => this.duplicateTab(sessionId) },
|
||||
{ divider: true },
|
||||
{
|
||||
label: isPinned ? "Unpin" : "Pin",
|
||||
action: () =>
|
||||
isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId),
|
||||
},
|
||||
{ label: "Move Left", action: () => this.moveLeft(sessionId) },
|
||||
{ label: "Move Right", action: () => this.moveRight(sessionId) },
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Reconnect",
|
||||
action: () => this._reconnect(sessionId),
|
||||
disabled:
|
||||
session.state === "connected" || session.state === "connecting",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Close",
|
||||
className: "context-menu-danger",
|
||||
action: () => this.closeTab(sessionId),
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of items) {
|
||||
if (entry.divider) {
|
||||
menu.appendChild(createElement('div', 'context-menu-divider'));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
|
||||
textContent: entry.label,
|
||||
});
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('context-menu-disabled');
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
dismissContextMenus();
|
||||
entry.action();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
for (const entry of items) {
|
||||
if (entry.divider) {
|
||||
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement(
|
||||
"button",
|
||||
`context-menu-item ${entry.className || ""}`,
|
||||
{
|
||||
textContent: entry.label,
|
||||
},
|
||||
);
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("context-menu-disabled");
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
dismissContextMenus();
|
||||
entry.action();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||
if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||
// Keep within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth)
|
||||
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||
if (rect.bottom > window.innerHeight)
|
||||
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +415,7 @@ class TabManager {
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getTabOrder() {
|
||||
return [...this.order];
|
||||
return [...this.order];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,9 +423,9 @@ class TabManager {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getNextTab() {
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx + 1) % this.order.length];
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx + 1) % this.order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,9 +433,9 @@ class TabManager {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getPrevTab() {
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx - 1 + this.order.length) % this.order.length];
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx - 1 + this.order.length) % this.order.length];
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
@@ -389,56 +446,81 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_renderTab(sessionInfo) {
|
||||
const tab = createElement('div', 'tab', {
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
draggable: 'true',
|
||||
const tab = createElement("div", "tab", {
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
draggable: "true",
|
||||
});
|
||||
|
||||
// Protocol badge
|
||||
const protoLabel =
|
||||
sessionInfo.protocol === "github"
|
||||
? "GitHub"
|
||||
: sessionInfo.protocol.toUpperCase();
|
||||
const badge = createElement(
|
||||
"span",
|
||||
`tab-badge badge-${sessionInfo.protocol}`,
|
||||
{
|
||||
textContent: protoLabel,
|
||||
},
|
||||
);
|
||||
tab.appendChild(badge);
|
||||
|
||||
// Name
|
||||
const name = createElement("span", "tab-name", {
|
||||
textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(),
|
||||
});
|
||||
tab.appendChild(name);
|
||||
|
||||
// Status dot
|
||||
const status = createElement(
|
||||
"span",
|
||||
`tab-status status-${sessionInfo.state || "connecting"}`,
|
||||
{
|
||||
title: stateLabel(sessionInfo.state),
|
||||
},
|
||||
);
|
||||
tab.appendChild(status);
|
||||
|
||||
// Pin indicator
|
||||
if (sessionInfo.pinned) {
|
||||
tab.classList.add("tab-pinned");
|
||||
}
|
||||
|
||||
// SFTP toggle button (SSH sessions only)
|
||||
if (sessionInfo.protocol === "ssh") {
|
||||
const sftpBtn = createElement("button", "tab-sftp", {
|
||||
textContent: "📁",
|
||||
title: "Toggle SFTP file browser",
|
||||
"aria-label": "Toggle SFTP",
|
||||
});
|
||||
|
||||
// Protocol badge
|
||||
const protoLabel = sessionInfo.protocol === 'github' ? 'GitHub' : sessionInfo.protocol.toUpperCase();
|
||||
const badge = createElement('span', `tab-badge badge-${sessionInfo.protocol}`, {
|
||||
textContent: protoLabel,
|
||||
sftpBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.app.sftp.toggle(sessionInfo.id);
|
||||
});
|
||||
tab.appendChild(badge);
|
||||
tab.appendChild(sftpBtn);
|
||||
}
|
||||
|
||||
// Name
|
||||
const name = createElement('span', 'tab-name', {
|
||||
textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(),
|
||||
});
|
||||
tab.appendChild(name);
|
||||
// Close button
|
||||
const closeBtn = createElement("button", "tab-close", {
|
||||
textContent: "×",
|
||||
"aria-label": "Close tab",
|
||||
});
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(sessionInfo.id);
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
// Status dot
|
||||
const status = createElement('span', `tab-status status-${sessionInfo.state || 'connecting'}`, {
|
||||
title: stateLabel(sessionInfo.state),
|
||||
});
|
||||
tab.appendChild(status);
|
||||
// Click → activate
|
||||
tab.addEventListener("click", () => this.setActive(sessionInfo.id));
|
||||
|
||||
// Pin indicator
|
||||
if (sessionInfo.pinned) {
|
||||
tab.classList.add('tab-pinned');
|
||||
}
|
||||
// Right-click → context menu
|
||||
tab.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this.showContextMenu(sessionInfo.id, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Close button
|
||||
const closeBtn = createElement('button', 'tab-close', {
|
||||
textContent: '×',
|
||||
'aria-label': 'Close tab',
|
||||
});
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(sessionInfo.id);
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
// Click → activate
|
||||
tab.addEventListener('click', () => this.setActive(sessionInfo.id));
|
||||
|
||||
// Right-click → context menu
|
||||
tab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this.showContextMenu(sessionInfo.id, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
this.tabList.appendChild(tab);
|
||||
this.tabList.appendChild(tab);
|
||||
}
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────
|
||||
@@ -449,20 +531,20 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_reorderDOM() {
|
||||
// Sort: pinned first, then by order index
|
||||
const sorted = [...this.order].sort((a, b) => {
|
||||
const sa = this.app.state.getSession(a);
|
||||
const sb = this.app.state.getSession(b);
|
||||
const pinnedA = sa?.pinned ? 0 : 1;
|
||||
const pinnedB = sb?.pinned ? 0 : 1;
|
||||
if (pinnedA !== pinnedB) return pinnedA - pinnedB;
|
||||
return this.order.indexOf(a) - this.order.indexOf(b);
|
||||
});
|
||||
// Sort: pinned first, then by order index
|
||||
const sorted = [...this.order].sort((a, b) => {
|
||||
const sa = this.app.state.getSession(a);
|
||||
const sb = this.app.state.getSession(b);
|
||||
const pinnedA = sa?.pinned ? 0 : 1;
|
||||
const pinnedB = sb?.pinned ? 0 : 1;
|
||||
if (pinnedA !== pinnedB) return pinnedA - pinnedB;
|
||||
return this.order.indexOf(a) - this.order.indexOf(b);
|
||||
});
|
||||
|
||||
for (const id of sorted) {
|
||||
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
|
||||
if (el) this.tabList.appendChild(el);
|
||||
}
|
||||
for (const id of sorted) {
|
||||
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
|
||||
if (el) this.tabList.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,11 +553,13 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_updateTabStyles(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const tabEl = this.tabList.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl && session) {
|
||||
tabEl.classList.toggle('tab-pinned', !!session.pinned);
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const tabEl = this.tabList.querySelector(
|
||||
`[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl && session) {
|
||||
tabEl.classList.toggle("tab-pinned", !!session.pinned);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,10 +567,10 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_updateEmptyState() {
|
||||
const empty = document.getElementById('empty-state');
|
||||
if (empty) {
|
||||
empty.style.display = this.order.length === 0 ? 'flex' : 'none';
|
||||
}
|
||||
const empty = document.getElementById("empty-state");
|
||||
if (empty) {
|
||||
empty.style.display = this.order.length === 0 ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -495,14 +579,14 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_promptRename(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const current = session?.tabName || '';
|
||||
// Use the app's custom prompt modal (window.prompt is unavailable in WKWebView)
|
||||
this.app._inputPrompt('Rename Tab', 'Tab name', current).then((name) => {
|
||||
if (name !== null && name.trim()) {
|
||||
this.renameTab(sessionId, name.trim());
|
||||
}
|
||||
});
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const current = session?.tabName || "";
|
||||
// Use the app's custom prompt modal (window.prompt is unavailable in WKWebView)
|
||||
this.app._inputPrompt("Rename Tab", "Tab name", current).then((name) => {
|
||||
if (name !== null && name.trim()) {
|
||||
this.renameTab(sessionId, name.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,17 +595,17 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
async _reconnect(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || !session.profileId) return;
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || !session.profileId) return;
|
||||
|
||||
// Close the current tab, then open a fresh connection
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
// Close the current tab, then open a fresh connection
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
|
||||
await this.app.connectProfile(session.profileId);
|
||||
await this.app.connectProfile(session.profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,8 +615,8 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_tabLabel(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
return session?.tabName || 'Session';
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
return session?.tabName || "Session";
|
||||
}
|
||||
|
||||
// ── Drag and drop ────────────────────────────────────────────────
|
||||
@@ -542,68 +626,71 @@ class TabManager {
|
||||
* @private
|
||||
*/
|
||||
_setupDragAndDrop() {
|
||||
if (!this.tabList) return;
|
||||
if (!this.tabList) return;
|
||||
|
||||
let draggedId = null;
|
||||
let draggedId = null;
|
||||
|
||||
this.tabList.addEventListener('dragstart', (e) => {
|
||||
const tab = e.target.closest('.tab');
|
||||
if (!tab) return;
|
||||
draggedId = tab.dataset.sessionId;
|
||||
tab.classList.add('tab-dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
this.tabList.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const target = e.target.closest('.tab');
|
||||
if (!target || target.dataset.sessionId === draggedId) return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const midX = rect.left + rect.width / 2;
|
||||
target.classList.toggle('tab-drop-before', e.clientX < midX);
|
||||
target.classList.toggle('tab-drop-after', e.clientX >= midX);
|
||||
});
|
||||
|
||||
this.tabList.addEventListener('dragleave', (e) => {
|
||||
const target = e.target.closest('.tab');
|
||||
if (target) {
|
||||
target.classList.remove('tab-drop-before', 'tab-drop-after');
|
||||
}
|
||||
});
|
||||
|
||||
this.tabList.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target.closest('.tab');
|
||||
if (!target || !draggedId) return;
|
||||
|
||||
target.classList.remove('tab-drop-before', 'tab-drop-after');
|
||||
|
||||
const targetId = target.dataset.sessionId;
|
||||
if (targetId === draggedId) return;
|
||||
|
||||
// Reorder
|
||||
const fromIdx = this.order.indexOf(draggedId);
|
||||
const toIdx = this.order.indexOf(targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
this.order.splice(fromIdx, 1);
|
||||
this.order.splice(toIdx, 0, draggedId);
|
||||
this._reorderDOM();
|
||||
});
|
||||
|
||||
this.tabList.addEventListener('dragend', () => {
|
||||
this.tabList.querySelectorAll('.tab').forEach((t) => {
|
||||
t.classList.remove('tab-dragging', 'tab-drop-before', 'tab-drop-after');
|
||||
});
|
||||
draggedId = null;
|
||||
this.tabList.addEventListener("dragstart", (e) => {
|
||||
const tab = e.target.closest(".tab");
|
||||
if (!tab) return;
|
||||
draggedId = tab.dataset.sessionId;
|
||||
tab.classList.add("tab-dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
this.tabList.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
const target = e.target.closest(".tab");
|
||||
if (!target || target.dataset.sessionId === draggedId) return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const midX = rect.left + rect.width / 2;
|
||||
target.classList.toggle("tab-drop-before", e.clientX < midX);
|
||||
target.classList.toggle("tab-drop-after", e.clientX >= midX);
|
||||
});
|
||||
|
||||
this.tabList.addEventListener("dragleave", (e) => {
|
||||
const target = e.target.closest(".tab");
|
||||
if (target) {
|
||||
target.classList.remove("tab-drop-before", "tab-drop-after");
|
||||
}
|
||||
});
|
||||
|
||||
this.tabList.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target.closest(".tab");
|
||||
if (!target || !draggedId) return;
|
||||
|
||||
target.classList.remove("tab-drop-before", "tab-drop-after");
|
||||
|
||||
const targetId = target.dataset.sessionId;
|
||||
if (targetId === draggedId) return;
|
||||
|
||||
// Reorder
|
||||
const fromIdx = this.order.indexOf(draggedId);
|
||||
const toIdx = this.order.indexOf(targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
this.order.splice(fromIdx, 1);
|
||||
this.order.splice(toIdx, 0, draggedId);
|
||||
this._reorderDOM();
|
||||
});
|
||||
|
||||
this.tabList.addEventListener("dragend", () => {
|
||||
this.tabList.querySelectorAll(".tab").forEach((t) => {
|
||||
t.classList.remove(
|
||||
"tab-dragging",
|
||||
"tab-drop-before",
|
||||
"tab-drop-after",
|
||||
);
|
||||
});
|
||||
draggedId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TabManager = TabManager;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TabManager = TabManager;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
(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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
})();
|
||||
@@ -251,6 +251,7 @@ function stateLabel(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';
|
||||
}
|
||||
@@ -259,4 +260,32 @@ function stateLabel(state) {
|
||||
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 });
|
||||
|
||||
})();
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import {models} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
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>;
|
||||
@@ -13,30 +17,60 @@ 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 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 GetAppInfo():Promise<Record<string, 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 GetSessionCwd(arg1:string):Promise<string>;
|
||||
|
||||
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetTOTPCode(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>;
|
||||
@@ -47,6 +81,8 @@ export function ListFavourites():Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListKnownHosts():Promise<Array<models.KnownHost>>;
|
||||
|
||||
export function ListPortForwards(arg1:string):Promise<Array<models.PortForward>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListProfilesByFolder(arg1:string):Promise<Array<models.Profile>>;
|
||||
@@ -55,8 +91,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 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>;
|
||||
@@ -73,6 +115,24 @@ 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>;
|
||||
@@ -91,12 +151,24 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function SetTOTPSecret(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
|
||||
|
||||
export function StartRecording(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||
|
||||
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function StopRecording(arg1:string):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
||||
|
||||
export function UnlockSecretStore(arg1:string):Promise<void>;
|
||||
|
||||
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||
|
||||
export function UpdateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
|
||||
|
||||
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function WriteSessionEscaped(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
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,6 +30,10 @@ export function CreateProfile(arg1) {
|
||||
return window['go']['api']['Service']['CreateProfile'](arg1);
|
||||
}
|
||||
|
||||
export function CreateSnippet(arg1) {
|
||||
return window['go']['api']['Service']['CreateSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteKnownHost(arg1) {
|
||||
return window['go']['api']['Service']['DeleteKnownHost'](arg1);
|
||||
}
|
||||
@@ -34,10 +46,26 @@ export function DeleteProfileSecrets(arg1) {
|
||||
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteRecording(arg1) {
|
||||
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteSnippet(arg1) {
|
||||
return window['go']['api']['Service']['DeleteSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteTOTPSecret(arg1) {
|
||||
return window['go']['api']['Service']['DeleteTOTPSecret'](arg1);
|
||||
}
|
||||
|
||||
export function DisconnectSession(arg1) {
|
||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DuplicateProfile(arg1) {
|
||||
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
||||
}
|
||||
@@ -46,6 +74,18 @@ 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 GetAppInfo() {
|
||||
return window['go']['api']['Service']['GetAppInfo']();
|
||||
}
|
||||
@@ -58,18 +98,46 @@ export function GetProfile(arg1) {
|
||||
return window['go']['api']['Service']['GetProfile'](arg1);
|
||||
}
|
||||
|
||||
export function GetRecording(arg1) {
|
||||
return window['go']['api']['Service']['GetRecording'](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 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']();
|
||||
}
|
||||
@@ -90,6 +158,10 @@ export function ListKnownHosts() {
|
||||
return window['go']['api']['Service']['ListKnownHosts']();
|
||||
}
|
||||
|
||||
export function ListPortForwards(arg1) {
|
||||
return window['go']['api']['Service']['ListPortForwards'](arg1);
|
||||
}
|
||||
|
||||
export function ListProfiles() {
|
||||
return window['go']['api']['Service']['ListProfiles']();
|
||||
}
|
||||
@@ -106,10 +178,22 @@ export function ListProfilesByTag(arg1) {
|
||||
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
|
||||
}
|
||||
|
||||
export function ListRecordings() {
|
||||
return window['go']['api']['Service']['ListRecordings']();
|
||||
}
|
||||
|
||||
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']();
|
||||
}
|
||||
@@ -142,6 +226,42 @@ 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);
|
||||
}
|
||||
@@ -178,6 +298,26 @@ export function SetProfileSecret(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SetTOTPSecret(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SetTOTPSecret'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StartPortForward(arg1, arg2) {
|
||||
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StartRecording(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopPortForward(arg1, arg2) {
|
||||
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StopRecording(arg1) {
|
||||
return window['go']['api']['Service']['StopRecording'](arg1);
|
||||
}
|
||||
|
||||
export function TestConnection(arg1, arg2) {
|
||||
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||
}
|
||||
@@ -190,6 +330,10 @@ export function UpdateProfile(arg1) {
|
||||
return window['go']['api']['Service']['UpdateProfile'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateSnippet(arg1) {
|
||||
return window['go']['api']['Service']['UpdateSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function WriteSession(arg1, arg2) {
|
||||
return window['go']['api']['Service']['WriteSession'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export namespace models {
|
||||
|
||||
export class AppPreferences {
|
||||
theme: string;
|
||||
terminalTheme: string;
|
||||
defaultProtocol: string;
|
||||
confirmOnClose: boolean;
|
||||
confirmOnDisconnect: boolean;
|
||||
@@ -11,6 +12,7 @@ export namespace models {
|
||||
defaultFontSize: number;
|
||||
defaultScrollback: number;
|
||||
defaultCursorStyle: string;
|
||||
useNativePaste: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppPreferences(source);
|
||||
@@ -19,6 +21,7 @@ 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"];
|
||||
@@ -28,12 +31,12 @@ export namespace models {
|
||||
this.defaultFontSize = source["defaultFontSize"];
|
||||
this.defaultScrollback = source["defaultScrollback"];
|
||||
this.defaultCursorStyle = source["defaultCursorStyle"];
|
||||
this.useNativePaste = source["useNativePaste"];
|
||||
}
|
||||
}
|
||||
export class AuditEntry {
|
||||
id: string;
|
||||
// Go type: time
|
||||
timestamp: any;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
profileId?: string;
|
||||
sessionId?: string;
|
||||
@@ -46,30 +49,12 @@ export namespace models {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.timestamp = this.convertValues(source["timestamp"], null);
|
||||
this.timestamp = source["timestamp"];
|
||||
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;
|
||||
@@ -93,6 +78,9 @@ export namespace models {
|
||||
owner: string;
|
||||
description: string;
|
||||
htmlURL: string;
|
||||
defaultBranch: string;
|
||||
latestCommitter: string;
|
||||
latestCommitAt: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
archived: boolean;
|
||||
@@ -112,6 +100,9 @@ export namespace models {
|
||||
this.owner = source["owner"];
|
||||
this.description = source["description"];
|
||||
this.htmlURL = source["htmlURL"];
|
||||
this.defaultBranch = source["defaultBranch"];
|
||||
this.latestCommitter = source["latestCommitter"];
|
||||
this.latestCommitAt = source["latestCommitAt"];
|
||||
this.private = source["private"];
|
||||
this.fork = source["fork"];
|
||||
this.archived = source["archived"];
|
||||
@@ -150,10 +141,8 @@ export namespace models {
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey: string;
|
||||
// Go type: time
|
||||
firstSeen: any;
|
||||
// Go type: time
|
||||
lastSeen: any;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
trusted: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -168,28 +157,26 @@ export namespace models {
|
||||
this.keyType = source["keyType"];
|
||||
this.fingerprint = source["fingerprint"];
|
||||
this.publicKey = source["publicKey"];
|
||||
this.firstSeen = this.convertValues(source["firstSeen"], null);
|
||||
this.lastSeen = this.convertValues(source["lastSeen"], null);
|
||||
this.firstSeen = source["firstSeen"];
|
||||
this.lastSeen = source["lastSeen"];
|
||||
this.trusted = source["trusted"];
|
||||
}
|
||||
}
|
||||
export class LoginScriptStep {
|
||||
type: string;
|
||||
value: string;
|
||||
timeout: number;
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
static createFrom(source: any = {}) {
|
||||
return new LoginScriptStep(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.value = source["value"];
|
||||
this.timeout = source["timeout"];
|
||||
}
|
||||
}
|
||||
export class PayloadPreset {
|
||||
id: string;
|
||||
@@ -209,6 +196,32 @@ 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 RAWConfig {
|
||||
autoReconnect: boolean;
|
||||
lineEnding: string;
|
||||
@@ -256,6 +269,9 @@ export namespace models {
|
||||
encoding: string;
|
||||
negotiationDebug: boolean;
|
||||
lineMode: boolean;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectTries: number;
|
||||
loginScripts: LoginScriptStep[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TelnetConfig(source);
|
||||
@@ -269,7 +285,28 @@ export namespace models {
|
||||
this.encoding = source["encoding"];
|
||||
this.negotiationDebug = source["negotiationDebug"];
|
||||
this.lineMode = source["lineMode"];
|
||||
this.autoReconnect = source["autoReconnect"];
|
||||
this.maxReconnectTries = source["maxReconnectTries"];
|
||||
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SSHConfig {
|
||||
authMethods: string[];
|
||||
@@ -281,7 +318,19 @@ export namespace models {
|
||||
keepaliveInterval: number;
|
||||
keepaliveCountMax: number;
|
||||
compression: boolean;
|
||||
agentAuth: boolean;
|
||||
agentForwarding: boolean;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectTries: number;
|
||||
loginScripts: LoginScriptStep[];
|
||||
totpSecret: string;
|
||||
proxyType: string;
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
proxyUsername: string;
|
||||
proxyPassword: string;
|
||||
jumpHosts: JumpHost[];
|
||||
portForwards: PortForward[];
|
||||
startupCommand: string;
|
||||
envVars: Record<string, string>;
|
||||
|
||||
@@ -300,7 +349,19 @@ export namespace models {
|
||||
this.keepaliveInterval = source["keepaliveInterval"];
|
||||
this.keepaliveCountMax = source["keepaliveCountMax"];
|
||||
this.compression = source["compression"];
|
||||
this.agentAuth = source["agentAuth"];
|
||||
this.agentForwarding = source["agentForwarding"];
|
||||
this.autoReconnect = source["autoReconnect"];
|
||||
this.maxReconnectTries = source["maxReconnectTries"];
|
||||
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
|
||||
this.totpSecret = source["totpSecret"];
|
||||
this.proxyType = source["proxyType"];
|
||||
this.proxyHost = source["proxyHost"];
|
||||
this.proxyPort = source["proxyPort"];
|
||||
this.proxyUsername = source["proxyUsername"];
|
||||
this.proxyPassword = source["proxyPassword"];
|
||||
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
||||
this.portForwards = this.convertValues(source["portForwards"], PortForward);
|
||||
this.startupCommand = source["startupCommand"];
|
||||
this.envVars = source["envVars"];
|
||||
}
|
||||
@@ -336,14 +397,11 @@ export namespace models {
|
||||
environment: string;
|
||||
notes: string;
|
||||
favourite: boolean;
|
||||
// Go type: time
|
||||
lastConnected?: any;
|
||||
lastConnected?: string;
|
||||
connectTimeout: number;
|
||||
readTimeout: number;
|
||||
// Go type: time
|
||||
createdAt: any;
|
||||
// Go type: time
|
||||
updatedAt: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
terminalFontFamily: string;
|
||||
terminalFontSize: number;
|
||||
terminalScrollback: number;
|
||||
@@ -371,11 +429,11 @@ export namespace models {
|
||||
this.environment = source["environment"];
|
||||
this.notes = source["notes"];
|
||||
this.favourite = source["favourite"];
|
||||
this.lastConnected = this.convertValues(source["lastConnected"], null);
|
||||
this.lastConnected = source["lastConnected"];
|
||||
this.connectTimeout = source["connectTimeout"];
|
||||
this.readTimeout = source["readTimeout"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], null);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], null);
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
this.terminalFontFamily = source["terminalFontFamily"];
|
||||
this.terminalFontSize = source["terminalFontSize"];
|
||||
this.terminalScrollback = source["terminalScrollback"];
|
||||
@@ -416,8 +474,7 @@ export namespace models {
|
||||
state: string;
|
||||
tabName: string;
|
||||
pinned: boolean;
|
||||
// Go type: time
|
||||
connectedAt: any;
|
||||
connectedAt: string;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -435,27 +492,35 @@ export namespace models {
|
||||
this.state = source["state"];
|
||||
this.tabName = source["tabName"];
|
||||
this.pinned = source["pinned"];
|
||||
this.connectedAt = this.convertValues(source["connectedAt"], null);
|
||||
this.connectedAt = source["connectedAt"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
export class Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
description: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
static createFrom(source: any = {}) {
|
||||
return new Snippet(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.command = source["command"];
|
||||
this.description = source["description"];
|
||||
this.folder = source["folder"];
|
||||
this.tags = source["tags"];
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
module github.com/copilot-ssh-client/app
|
||||
module github.com/andydixon/nexus
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/zalando/go-keyring v0.2.5
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/text v0.23.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.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.0.1-0.20190219062509-6c824513bacc // 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.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kr/fs v0.1.0 // 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
|
||||
@@ -37,6 +42,5 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,8 +4,11 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK
|
||||
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 h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
@@ -18,6 +21,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
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=
|
||||
@@ -46,15 +51,21 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
|
||||
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.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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=
|
||||
@@ -71,25 +82,25 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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.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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
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/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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=
|
||||
|
||||
@@ -3,29 +3,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/audit"
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"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"
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/exportimport"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/preferences"
|
||||
"github.com/andydixon/nexus/internal/secrets"
|
||||
"github.com/andydixon/nexus/internal/sessions"
|
||||
"github.com/andydixon/nexus/internal/sshconfig"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"github.com/andydixon/nexus/internal/updater"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// appVersion is set at build time via ldflags.
|
||||
var appVersion = "dev"
|
||||
|
||||
// appUpdateURL is set at build time via ldflags.
|
||||
var appUpdateURL = ""
|
||||
|
||||
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||
type Service struct {
|
||||
store *storage.Store
|
||||
@@ -138,6 +151,99 @@ func (s *Service) ListProfiles() ([]models.Profile, error) {
|
||||
return derefProfiles(profiles), nil
|
||||
}
|
||||
|
||||
// ListSSHKeys returns absolute paths to candidate SSH private keys in ~/.ssh.
|
||||
func (s *Service) ListSSHKeys() ([]string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(home, ".ssh")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ignored := map[string]bool{
|
||||
"authorized_keys": true,
|
||||
"authorized_keys2": true,
|
||||
"config": true,
|
||||
"known_hosts": true,
|
||||
"known_hosts.old": true,
|
||||
"ssh_config": true,
|
||||
"ssh_known_hosts": true,
|
||||
"ssh_known_hosts2": true,
|
||||
}
|
||||
|
||||
keys := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if ignored[name] {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, ".pub") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, ".old") {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(dir, name)
|
||||
if !isLikelyPrivateKey(fullPath) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, fullPath)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func isLikelyPrivateKey(path string) bool {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && n == 0 {
|
||||
return false
|
||||
}
|
||||
head := buf[:n]
|
||||
|
||||
if bytes.HasPrefix(head, []byte("openssh-key-v1")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.Contains(head, []byte("BEGIN OPENSSH PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(head, []byte("BEGIN RSA PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(head, []byte("BEGIN DSA PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(head, []byte("BEGIN EC PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(head, []byte("BEGIN ED25519 PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(head, []byte("BEGIN PRIVATE KEY")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SearchProfiles searches profiles by display name, host, or tags.
|
||||
func (s *Service) SearchProfiles(query string) ([]models.Profile, error) {
|
||||
profiles, err := s.store.SearchProfiles(query)
|
||||
@@ -272,6 +378,11 @@ func (s *Service) ReconnectSession(sessionID string) error {
|
||||
return s.sessions.Reconnect(sessionID, s.promptSecret)
|
||||
}
|
||||
|
||||
// CancelReconnect cancels an in-progress auto-reconnect for a session.
|
||||
func (s *Service) CancelReconnect(sessionID string) {
|
||||
s.sessions.CancelReconnect(sessionID)
|
||||
}
|
||||
|
||||
// WriteSession writes base64-encoded data to a session.
|
||||
func (s *Service) WriteSession(sessionID string, data string) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
@@ -432,6 +543,72 @@ func (s *Service) DeleteProfileSecrets(profileID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOTP / 2FA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetTOTPSecret stores a TOTP secret for the given profile, encrypted in
|
||||
// the secret vault (or OS keychain).
|
||||
func (s *Service) SetTOTPSecret(profileID string, secret string) error {
|
||||
return s.SetProfileSecret(profileID, "totp", secret)
|
||||
}
|
||||
|
||||
// GetTOTPCode generates the current 6-digit TOTP code for the given profile.
|
||||
// Used for the "Test" button in the profile editor.
|
||||
func (s *Service) GetTOTPCode(profileID string) (string, error) {
|
||||
// Look up the TOTP secret from the vault.
|
||||
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||
var secret string
|
||||
|
||||
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
|
||||
secret = val
|
||||
}
|
||||
}
|
||||
if secret == "" && s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
|
||||
secret = val
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("no TOTP secret configured for this profile")
|
||||
}
|
||||
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate TOTP code: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// HasTOTPSecret checks whether a profile has a TOTP secret stored.
|
||||
func (s *Service) HasTOTPSecret(profileID string) bool {
|
||||
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DeleteTOTPSecret removes the TOTP secret for the given profile.
|
||||
func (s *Service) DeleteTOTPSecret(profileID string) error {
|
||||
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
|
||||
if s.keychainStore != nil && s.keychainStore.Available() {
|
||||
_ = s.keychainStore.DeleteSecret(totpKey)
|
||||
}
|
||||
if s.secretStore != nil && s.secretStore.IsUnlocked() {
|
||||
_ = s.secretStore.DeleteSecret(totpKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -565,6 +742,54 @@ func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snippets (command library)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListSnippets returns all saved command snippets.
|
||||
func (s *Service) ListSnippets() ([]models.Snippet, error) {
|
||||
snippets, err := s.store.ListSnippets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]models.Snippet, len(snippets))
|
||||
for i, sn := range snippets {
|
||||
result[i] = *sn
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateSnippet saves a new command snippet.
|
||||
func (s *Service) CreateSnippet(snippet models.Snippet) (models.Snippet, error) {
|
||||
if snippet.ID == "" {
|
||||
snippet.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now()
|
||||
snippet.CreatedAt = now
|
||||
snippet.UpdatedAt = now
|
||||
if snippet.Tags == nil {
|
||||
snippet.Tags = []string{}
|
||||
}
|
||||
if err := s.store.CreateSnippet(&snippet); err != nil {
|
||||
return models.Snippet{}, fmt.Errorf("failed to create snippet: %w", err)
|
||||
}
|
||||
return snippet, nil
|
||||
}
|
||||
|
||||
// UpdateSnippet updates an existing command snippet.
|
||||
func (s *Service) UpdateSnippet(snippet models.Snippet) (models.Snippet, error) {
|
||||
snippet.UpdatedAt = time.Now()
|
||||
if err := s.store.UpdateSnippet(&snippet); err != nil {
|
||||
return models.Snippet{}, fmt.Errorf("failed to update snippet: %w", err)
|
||||
}
|
||||
return snippet, nil
|
||||
}
|
||||
|
||||
// DeleteSnippet removes a command snippet by ID.
|
||||
func (s *Service) DeleteSnippet(id string) error {
|
||||
return s.store.DeleteSnippet(id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -607,6 +832,29 @@ func (s *Service) SaveFile(title string, defaultName string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// FileExists returns true if the path exists and is not a directory.
|
||||
func (s *Service) FileExists(path string) (bool, error) {
|
||||
if path == "" {
|
||||
return false, nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
// FileIsPrivateKey returns true if the path appears to be an SSH private key.
|
||||
func (s *Service) FileIsPrivateKey(path string) (bool, error) {
|
||||
if path == "" {
|
||||
return false, nil
|
||||
}
|
||||
return isLikelyPrivateKey(path), nil
|
||||
}
|
||||
|
||||
// GetAppInfo returns build and environment information.
|
||||
func (s *Service) GetAppInfo() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -624,6 +872,464 @@ func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, err
|
||||
return s.sessions.FetchGitHubRepos(sessionID)
|
||||
}
|
||||
|
||||
// DownloadGitHubRepoArchive downloads a repository zip archive to a local file.
|
||||
func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, ref string) (string, error) {
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
if fullName == "" {
|
||||
return "", fmt.Errorf("repository name cannot be empty")
|
||||
}
|
||||
|
||||
parts := strings.Split(fullName, "/")
|
||||
repoName := parts[len(parts)-1]
|
||||
defaultName := repoName + ".zip"
|
||||
if ref != "" {
|
||||
safeRef := strings.ReplaceAll(ref, "/", "-")
|
||||
defaultName = fmt.Sprintf("%s-%s.zip", repoName, safeRef)
|
||||
}
|
||||
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Download Repository",
|
||||
DefaultFilename: defaultName,
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{DisplayName: "Zip Archive (*.zip)", Pattern: "*.zip"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("save dialog failed: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("download cancelled")
|
||||
}
|
||||
if filepath.Ext(path) != ".zip" {
|
||||
path += ".zip"
|
||||
}
|
||||
|
||||
if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListPortForwards returns the active port-forwarding tunnels for an SSH session.
|
||||
func (s *Service) ListPortForwards(sessionID string) ([]models.PortForward, error) {
|
||||
return s.sessions.ListPortForwards(sessionID)
|
||||
}
|
||||
|
||||
// StartPortForward activates a new forwarding tunnel on a live SSH session.
|
||||
func (s *Service) StartPortForward(sessionID string, rule models.PortForward) error {
|
||||
return s.sessions.StartPortForward(sessionID, rule)
|
||||
}
|
||||
|
||||
// StopPortForward terminates an active forwarding tunnel.
|
||||
func (s *Service) StopPortForward(sessionID string, forwardID string) error {
|
||||
return s.sessions.StopPortForward(sessionID, forwardID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session recording
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// StartRecording begins recording terminal I/O for a session.
|
||||
func (s *Service) StartRecording(sessionID string, cols int, rows int) (string, error) {
|
||||
return s.sessions.StartRecording(sessionID, cols, rows)
|
||||
}
|
||||
|
||||
// StopRecording stops an active recording for a session.
|
||||
func (s *Service) StopRecording(sessionID string) error {
|
||||
return s.sessions.StopRecording(sessionID)
|
||||
}
|
||||
|
||||
// IsRecording reports whether a session is currently being recorded.
|
||||
func (s *Service) IsRecording(sessionID string) bool {
|
||||
return s.sessions.IsRecording(sessionID)
|
||||
}
|
||||
|
||||
// ListRecordings returns metadata for all saved recordings.
|
||||
func (s *Service) ListRecordings() ([]map[string]interface{}, error) {
|
||||
metas, err := s.sessions.ListRecordings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, len(metas))
|
||||
for i, m := range metas {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"sessionId": m.SessionID,
|
||||
"title": m.Title,
|
||||
"width": m.Width,
|
||||
"height": m.Height,
|
||||
"duration": m.Duration,
|
||||
"size": m.Size,
|
||||
"createdAt": m.CreatedAt.Format(time.RFC3339),
|
||||
"filePath": m.FilePath,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetRecording returns the full recording data for playback.
|
||||
func (s *Service) GetRecording(id string) (map[string]interface{}, error) {
|
||||
hdr, events, err := s.sessions.GetRecording(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evtList := make([][]interface{}, len(events))
|
||||
for i, e := range events {
|
||||
evtList[i] = []interface{}{e.Elapsed, string(e.Type), e.Data}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"header": map[string]interface{}{
|
||||
"version": hdr.Version,
|
||||
"width": hdr.Width,
|
||||
"height": hdr.Height,
|
||||
"timestamp": hdr.Timestamp,
|
||||
"title": hdr.Title,
|
||||
},
|
||||
"events": evtList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteRecording removes a saved recording.
|
||||
func (s *Service) DeleteRecording(id string) error {
|
||||
return s.sessions.DeleteRecording(id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted export / import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ExportProfilesEncrypted encrypts and exports selected profiles to a file.
|
||||
// The profileIDs parameter selects which profiles to include; if empty, all
|
||||
// profiles are exported. Returns the file path on success.
|
||||
func (s *Service) ExportProfilesEncrypted(profileIDs []string, password string) (string, error) {
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
var profiles []models.Profile
|
||||
if len(profileIDs) == 0 {
|
||||
// Export all profiles.
|
||||
all, err := s.store.ListProfiles()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list profiles: %w", err)
|
||||
}
|
||||
for _, p := range all {
|
||||
profiles = append(profiles, *p)
|
||||
}
|
||||
} else {
|
||||
for _, id := range profileIDs {
|
||||
p, err := s.store.GetProfile(id)
|
||||
if err != nil {
|
||||
continue // skip missing profiles
|
||||
}
|
||||
profiles = append(profiles, *p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(profiles) == 0 {
|
||||
return "", fmt.Errorf("no profiles to export")
|
||||
}
|
||||
|
||||
// Prompt for save location.
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Export Encrypted Profiles",
|
||||
DefaultFilename: "nexus-profiles.nexus",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("save dialog failed: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("export cancelled")
|
||||
}
|
||||
|
||||
// Ensure .nexus extension.
|
||||
if filepath.Ext(path) != ".nexus" {
|
||||
path += ".nexus"
|
||||
}
|
||||
|
||||
if err := exportimport.EncryptAndExport(profiles, password, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.auditLog.LogExport(len(profiles), false)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ImportProfilesEncrypted decrypts and imports profiles from an encrypted file.
|
||||
// Returns the number of profiles successfully imported.
|
||||
func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int, error) {
|
||||
if s.ctx == nil {
|
||||
return 0, fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
// Prompt for file selection.
|
||||
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Import Encrypted Profiles",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open dialog failed: %w", err)
|
||||
}
|
||||
if path == "" {
|
||||
return 0, fmt.Errorf("import cancelled")
|
||||
}
|
||||
|
||||
profiles, err := exportimport.DecryptAndImport(path, password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Import using the existing strategy logic.
|
||||
imported := 0
|
||||
for _, p := range profiles {
|
||||
switch strategy {
|
||||
case "skip":
|
||||
if existing, _ := s.store.GetProfile(p.ID); existing != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.store.CreateProfile(&p); err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
|
||||
case "overwrite":
|
||||
p.UpdatedAt = time.Now()
|
||||
if existing, _ := s.store.GetProfile(p.ID); existing != nil {
|
||||
if err := s.store.UpdateProfile(&p); err != nil {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if err := s.store.CreateProfile(&p); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
imported++
|
||||
|
||||
case "duplicate":
|
||||
p.ID = uuid.New().String()
|
||||
now := time.Now()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
if err := s.store.CreateProfile(&p); err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown import strategy %q", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
s.auditLog.LogImport(imported)
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP file browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SFTPList lists the contents of a remote directory.
|
||||
func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) {
|
||||
entries, err := s.sessions.SFTPList(sessionID, remotePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = map[string]interface{}{
|
||||
"name": e.Name,
|
||||
"path": e.Path,
|
||||
"size": e.Size,
|
||||
"isDir": e.IsDir,
|
||||
"mode": e.Mode,
|
||||
"modifiedAt": e.ModifiedAt,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SFTPMkdir creates a remote directory (including parents).
|
||||
func (s *Service) SFTPMkdir(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPMkdir(sessionID, remotePath)
|
||||
}
|
||||
|
||||
// SFTPStat returns information about a remote path.
|
||||
func (s *Service) SFTPStat(sessionID string, remotePath string) (map[string]interface{}, error) {
|
||||
entry, err := s.sessions.SFTPStat(sessionID, remotePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": entry.Name,
|
||||
"path": entry.Path,
|
||||
"size": entry.Size,
|
||||
"isDir": entry.IsDir,
|
||||
"mode": entry.Mode,
|
||||
"modifiedAt": entry.ModifiedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SFTPRemove deletes a remote file or empty directory.
|
||||
func (s *Service) SFTPRemove(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPRemove(sessionID, remotePath)
|
||||
}
|
||||
|
||||
// SFTPRename moves or renames a remote file/directory.
|
||||
func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error {
|
||||
return s.sessions.SFTPRename(sessionID, oldPath, newPath)
|
||||
}
|
||||
|
||||
// SFTPDownload downloads a remote file. Opens a native save dialog for the
|
||||
// local destination.
|
||||
func (s *Service) SFTPDownload(sessionID string, remotePath string) error {
|
||||
if s.ctx == nil {
|
||||
return fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Save File",
|
||||
DefaultFilename: filepath.Base(remotePath),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dialog failed: %w", err)
|
||||
}
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("download cancelled")
|
||||
}
|
||||
|
||||
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
|
||||
}
|
||||
|
||||
// SFTPUpload uploads a local file to a remote directory. Opens a native file
|
||||
// dialog to select the local file.
|
||||
func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
|
||||
if s.ctx == nil {
|
||||
return fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select File to Upload",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dialog failed: %w", err)
|
||||
}
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("upload cancelled")
|
||||
}
|
||||
|
||||
destPath := remotePath + "/" + filepath.Base(localPath)
|
||||
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
|
||||
}
|
||||
|
||||
// SFTPUploadData uploads raw file data to a remote directory.
|
||||
// Data is expected as base64-encoded bytes from the frontend.
|
||||
func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName string, base64Data string) error {
|
||||
if base64Data == "" {
|
||||
return fmt.Errorf("empty upload payload")
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode upload data: %w", err)
|
||||
}
|
||||
|
||||
safeName := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." || safeName == "/" {
|
||||
return fmt.Errorf("invalid file name")
|
||||
}
|
||||
|
||||
destPath := path.Join(remoteDir, safeName)
|
||||
return s.sessions.SFTPUploadBytes(sessionID, destPath, data)
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (s *Service) SFTPPwd(sessionID string) (string, error) {
|
||||
return s.sessions.SFTPPwd(sessionID)
|
||||
}
|
||||
|
||||
// GetSessionCwd returns a best-effort working directory for SSH sessions.
|
||||
func (s *Service) GetSessionCwd(sessionID string) (string, error) {
|
||||
return s.sessions.GetSessionCwd(sessionID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenSSH config import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ImportSSHConfig parses the given SSH config file (or the default
|
||||
// ~/.ssh/config if path is empty) and imports the Host blocks as profiles.
|
||||
func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) {
|
||||
if configPath == "" {
|
||||
configPath = sshconfig.DefaultConfigPath()
|
||||
}
|
||||
|
||||
blocks, err := sshconfig.ParseFile(configPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
profiles := sshconfig.ToProfiles(blocks, folder)
|
||||
imported := 0
|
||||
for _, p := range profiles {
|
||||
if err := s.store.CreateProfile(&p); err != nil {
|
||||
continue // skip duplicates or errors
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
s.auditLog.LogImport(imported)
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Automatic update checking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CheckForUpdates queries GitHub Releases for a newer version.
|
||||
func (s *Service) CheckForUpdates() (map[string]interface{}, error) {
|
||||
ctx := context.Background()
|
||||
var info *updater.UpdateInfo
|
||||
var err error
|
||||
|
||||
if appUpdateURL != "" {
|
||||
url := strings.ReplaceAll(appUpdateURL, "{platform}", runtime.GOOS)
|
||||
url = strings.ReplaceAll(url, "{os}", runtime.GOOS)
|
||||
url = strings.ReplaceAll(url, "{arch}", runtime.GOARCH)
|
||||
checker := updater.NewURLChecker(url, appVersion)
|
||||
info, err = checker.Check(ctx)
|
||||
} else {
|
||||
checker := updater.NewChecker(appVersion)
|
||||
info, err = checker.Check(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"available": info.Available,
|
||||
"currentVersion": info.CurrentVersion,
|
||||
"latestVersion": info.LatestVersion,
|
||||
"releaseURL": info.ReleaseURL,
|
||||
"releaseName": info.ReleaseName,
|
||||
"releaseNotes": info.ReleaseNotes,
|
||||
"publishedAt": info.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||
// This verifies the network path without establishing a full protocol session.
|
||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||
|
||||
@@ -6,18 +6,20 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"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"
|
||||
"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"
|
||||
)
|
||||
|
||||
// App is the central application struct. Wails binds its lifecycle methods
|
||||
@@ -97,8 +99,12 @@ 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)
|
||||
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog, recordStore)
|
||||
a.SessionManager.SetContext(ctx)
|
||||
|
||||
// Wire the API service with all backing stores now that they exist.
|
||||
@@ -140,3 +146,27 @@ 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)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
// Client wraps the GitHub REST API for repository listing.
|
||||
@@ -42,6 +45,7 @@ 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"`
|
||||
@@ -58,6 +62,15 @@ 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
|
||||
@@ -120,6 +133,7 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||
Owner: r.Owner.Login,
|
||||
Description: r.Description,
|
||||
HTMLURL: r.HTMLURL,
|
||||
DefaultBranch: r.DefaultBranch,
|
||||
Private: r.Private,
|
||||
Fork: r.Fork,
|
||||
Archived: r.Archived,
|
||||
@@ -129,9 +143,149 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||
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"
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package knownhosts
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
func DefaultAppPreferences() AppPreferences {
|
||||
return AppPreferences{
|
||||
Theme: "dark",
|
||||
TerminalTheme: "charcoal",
|
||||
DefaultProtocol: string(ProtocolSSH),
|
||||
ConfirmOnClose: true,
|
||||
ConfirmOnDisconnect: true,
|
||||
@@ -48,6 +49,7 @@ func DefaultAppPreferences() AppPreferences {
|
||||
DefaultFontSize: DefaultFontSize,
|
||||
DefaultScrollback: DefaultScrollback,
|
||||
DefaultCursorStyle: DefaultCursorStyle,
|
||||
UseNativePaste: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +82,7 @@ func NewProfile(protocol Protocol) Profile {
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
KeepaliveCountMax: DefaultKeepaliveCountMax,
|
||||
JumpHosts: []JumpHost{},
|
||||
PortForwards: []PortForward{},
|
||||
EnvVars: map[string]string{},
|
||||
}
|
||||
case ProtocolTelnet:
|
||||
|
||||
@@ -57,11 +57,11 @@ type Profile struct {
|
||||
Environment string `json:"environment"`
|
||||
Notes string `json:"notes"`
|
||||
Favourite bool `json:"favourite"`
|
||||
LastConnected *time.Time `json:"lastConnected"`
|
||||
LastConnected *time.Time `json:"lastConnected" ts_type:"string"`
|
||||
ConnectTimeout int `json:"connectTimeout"` // seconds
|
||||
ReadTimeout int `json:"readTimeout"` // seconds
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" ts_type:"string"`
|
||||
UpdatedAt time.Time `json:"updatedAt" ts_type:"string"`
|
||||
|
||||
// Terminal preferences
|
||||
TerminalFontFamily string `json:"terminalFontFamily"`
|
||||
@@ -93,11 +93,30 @@ 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"`
|
||||
@@ -108,14 +127,37 @@ 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"`
|
||||
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
|
||||
}
|
||||
|
||||
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
||||
@@ -157,6 +199,9 @@ 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"`
|
||||
@@ -173,6 +218,7 @@ const (
|
||||
SessionConnecting SessionState = "connecting"
|
||||
SessionConnected SessionState = "connected"
|
||||
SessionDisconnected SessionState = "disconnected"
|
||||
SessionReconnecting SessionState = "reconnecting"
|
||||
SessionError SessionState = "error"
|
||||
)
|
||||
|
||||
@@ -187,7 +233,7 @@ type SessionInfo struct {
|
||||
State SessionState `json:"state"`
|
||||
TabName string `json:"tabName"`
|
||||
Pinned bool `json:"pinned"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
ConnectedAt time.Time `json:"connectedAt" ts_type:"string"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -199,14 +245,15 @@ type KnownHost struct {
|
||||
KeyType string `json:"keyType"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
FirstSeen time.Time `json:"firstSeen" ts_type:"string"`
|
||||
LastSeen time.Time `json:"lastSeen" ts_type:"string"`
|
||||
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"`
|
||||
@@ -216,14 +263,27 @@ type AppPreferences struct {
|
||||
DefaultFontSize int `json:"defaultFontSize"`
|
||||
DefaultScrollback int `json:"defaultScrollback"`
|
||||
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
||||
UseNativePaste bool `json:"useNativePaste"`
|
||||
}
|
||||
|
||||
// AuditEntry records an auditable action within the application.
|
||||
type AuditEntry struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Timestamp time.Time `json:"timestamp" ts_type:"string"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
// PreferenceStore is the persistence interface used by the Manager.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
// Client manages a raw TCP connection.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
|
||||
// Argon2id key derivation parameters.
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonMemory = 128 * 1024 // 128 MiB — deliberately strong for a desktop app
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package sessions
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, 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)
|
||||
mgr := NewManager(nil, nil, nil, nil, nil)
|
||||
// Should not panic.
|
||||
mgr.CloseAll()
|
||||
if len(mgr.ListSessions()) != 0 {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package sshclient — SSH agent integration.
|
||||
//
|
||||
// This file provides SSH agent authentication and agent forwarding.
|
||||
// It connects to the local SSH agent via the SSH_AUTH_SOCK environment
|
||||
// variable and can both use agent keys for authentication and forward
|
||||
// the agent channel to the remote host (allowing the remote session
|
||||
// to authenticate further hops using local keys).
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// connectAgent connects to the local SSH agent via SSH_AUTH_SOCK.
|
||||
// Returns the agent and a cleanup function, or an error if unavailable.
|
||||
func connectAgent() (agent.Agent, func(), error) {
|
||||
sock := os.Getenv("SSH_AUTH_SOCK")
|
||||
if sock == "" {
|
||||
return nil, nil, fmt.Errorf("SSH_AUTH_SOCK not set — SSH agent unavailable")
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to connect to SSH agent at %s: %w", sock, err)
|
||||
}
|
||||
|
||||
ag := agent.NewClient(conn)
|
||||
cleanup := func() { conn.Close() }
|
||||
return ag, cleanup, nil
|
||||
}
|
||||
|
||||
// agentAuthMethod returns an ssh.AuthMethod that delegates authentication
|
||||
// to the running SSH agent. Returns nil if the agent is unavailable.
|
||||
func agentAuthMethod() (ssh.AuthMethod, func(), error) {
|
||||
ag, cleanup, err := connectAgent()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
signers, err := ag.Signers()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SSH agent: failed to list keys: %w", err)
|
||||
}
|
||||
|
||||
if len(signers) == 0 {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SSH agent: no keys loaded")
|
||||
}
|
||||
|
||||
return ssh.PublicKeysCallback(ag.Signers), cleanup, nil
|
||||
}
|
||||
|
||||
// requestAgentForwarding sets up agent forwarding on the SSH session so that
|
||||
// the remote host can use local agent keys for further authentication.
|
||||
// This is equivalent to `ssh -A`.
|
||||
func requestAgentForwarding(sshClient *ssh.Client, session *ssh.Session) error {
|
||||
if err := agent.RequestAgentForwarding(session); err != nil {
|
||||
return fmt.Errorf("requesting agent forwarding: %w", err)
|
||||
}
|
||||
|
||||
ag, cleanup, err := connectAgent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ForwardToAgent runs in a background goroutine and serves agent
|
||||
// requests from the remote side. It stops when the SSH connection closes.
|
||||
go func() {
|
||||
defer cleanup()
|
||||
agent.ForwardToAgent(sshClient, ag)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Package sshclient — port forwarding support.
|
||||
//
|
||||
// This file implements local and remote SSH port forwarding (tunnelling).
|
||||
// Local forwarding listens on a local address and pipes connections through
|
||||
// the SSH channel to a remote destination. Remote forwarding requests the
|
||||
// SSH server to listen on a remote address and pipes accepted connections
|
||||
// back to a local destination.
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ActiveForward tracks a running port-forwarding tunnel.
|
||||
type ActiveForward struct {
|
||||
Rule models.PortForward `json:"rule"`
|
||||
Listener net.Listener `json:"-"`
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
conns []net.Conn // tracked connections for clean shutdown
|
||||
}
|
||||
|
||||
// ForwardManager manages active port-forwarding tunnels for a single SSH connection.
|
||||
type ForwardManager struct {
|
||||
mu sync.Mutex
|
||||
forwards map[string]*ActiveForward
|
||||
client *ssh.Client
|
||||
}
|
||||
|
||||
// NewForwardManager creates a ForwardManager bound to the given SSH client.
|
||||
func NewForwardManager(client *ssh.Client) *ForwardManager {
|
||||
return &ForwardManager{
|
||||
forwards: make(map[string]*ActiveForward),
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// StartForward activates a port-forwarding rule. Returns an error if the
|
||||
// rule's ID is already active or the listener cannot be established.
|
||||
func (fm *ForwardManager) StartForward(rule models.PortForward) error {
|
||||
fm.mu.Lock()
|
||||
if _, exists := fm.forwards[rule.ID]; exists {
|
||||
fm.mu.Unlock()
|
||||
return fmt.Errorf("forward %q is already active", rule.ID)
|
||||
}
|
||||
fm.mu.Unlock()
|
||||
|
||||
switch rule.Direction {
|
||||
case models.PortForwardLocal:
|
||||
return fm.startLocalForward(rule)
|
||||
case models.PortForwardRemote:
|
||||
return fm.startRemoteForward(rule)
|
||||
default:
|
||||
return fmt.Errorf("unsupported forward direction %q", rule.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
// StopForward terminates a specific active forward.
|
||||
func (fm *ForwardManager) StopForward(forwardID string) error {
|
||||
fm.mu.Lock()
|
||||
af, ok := fm.forwards[forwardID]
|
||||
if !ok {
|
||||
fm.mu.Unlock()
|
||||
return fmt.Errorf("forward %q not found", forwardID)
|
||||
}
|
||||
delete(fm.forwards, forwardID)
|
||||
fm.mu.Unlock()
|
||||
|
||||
return af.shutdown()
|
||||
}
|
||||
|
||||
// StopAll terminates every active forward. Called on session close.
|
||||
func (fm *ForwardManager) StopAll() {
|
||||
fm.mu.Lock()
|
||||
all := make([]*ActiveForward, 0, len(fm.forwards))
|
||||
for id, af := range fm.forwards {
|
||||
all = append(all, af)
|
||||
delete(fm.forwards, id)
|
||||
}
|
||||
fm.mu.Unlock()
|
||||
|
||||
for _, af := range all {
|
||||
_ = af.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveForwards returns the list of currently active forwarding rules.
|
||||
func (fm *ForwardManager) ActiveForwards() []models.PortForward {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
|
||||
result := make([]models.PortForward, 0, len(fm.forwards))
|
||||
for _, af := range fm.forwards {
|
||||
result = append(result, af.Rule)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local forwarding: listen locally → tunnel to remote via SSH
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (fm *ForwardManager) startLocalForward(rule models.PortForward) error {
|
||||
bindAddr := rule.BindHost
|
||||
if bindAddr == "" {
|
||||
bindAddr = "127.0.0.1"
|
||||
}
|
||||
listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort))
|
||||
|
||||
listener, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("local forward: listen on %s failed: %w", listenAddr, err)
|
||||
}
|
||||
|
||||
af := &ActiveForward{
|
||||
Rule: rule,
|
||||
Listener: listener,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
fm.mu.Lock()
|
||||
fm.forwards[rule.ID] = af
|
||||
fm.mu.Unlock()
|
||||
|
||||
destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort))
|
||||
|
||||
go func() {
|
||||
defer close(af.done)
|
||||
for {
|
||||
localConn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
// Listener closed — normal shutdown path.
|
||||
return
|
||||
}
|
||||
|
||||
af.mu.Lock()
|
||||
af.conns = append(af.conns, localConn)
|
||||
af.mu.Unlock()
|
||||
|
||||
go func(lc net.Conn) {
|
||||
defer lc.Close()
|
||||
|
||||
remoteConn, dialErr := fm.client.Dial("tcp", destAddr)
|
||||
if dialErr != nil {
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
af.mu.Lock()
|
||||
af.conns = append(af.conns, remoteConn)
|
||||
af.mu.Unlock()
|
||||
|
||||
pipeBidirectional(lc, remoteConn)
|
||||
}(localConn)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote forwarding: ask server to listen → tunnel back to local destination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (fm *ForwardManager) startRemoteForward(rule models.PortForward) error {
|
||||
bindAddr := rule.BindHost
|
||||
if bindAddr == "" {
|
||||
bindAddr = "0.0.0.0"
|
||||
}
|
||||
listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort))
|
||||
|
||||
listener, err := fm.client.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote forward: server listen on %s failed: %w", listenAddr, err)
|
||||
}
|
||||
|
||||
af := &ActiveForward{
|
||||
Rule: rule,
|
||||
Listener: listener,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
fm.mu.Lock()
|
||||
fm.forwards[rule.ID] = af
|
||||
fm.mu.Unlock()
|
||||
|
||||
destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort))
|
||||
|
||||
go func() {
|
||||
defer close(af.done)
|
||||
for {
|
||||
remoteConn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
af.mu.Lock()
|
||||
af.conns = append(af.conns, remoteConn)
|
||||
af.mu.Unlock()
|
||||
|
||||
go func(rc net.Conn) {
|
||||
defer rc.Close()
|
||||
|
||||
localConn, dialErr := net.Dial("tcp", destAddr)
|
||||
if dialErr != nil {
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
|
||||
af.mu.Lock()
|
||||
af.conns = append(af.conns, localConn)
|
||||
af.mu.Unlock()
|
||||
|
||||
pipeBidirectional(rc, localConn)
|
||||
}(remoteConn)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// shutdown closes the listener and all tracked connections.
|
||||
func (af *ActiveForward) shutdown() error {
|
||||
var firstErr error
|
||||
if af.Listener != nil {
|
||||
if err := af.Listener.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
af.mu.Lock()
|
||||
conns := af.conns
|
||||
af.conns = nil
|
||||
af.mu.Unlock()
|
||||
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// pipeBidirectional copies data between two connections until either side closes.
|
||||
func pipeBidirectional(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
copy := func(dst, src net.Conn) {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(dst, src)
|
||||
// Signal the other direction to stop by closing the write-half.
|
||||
if tc, ok := dst.(*net.TCPConn); ok {
|
||||
_ = tc.CloseWrite()
|
||||
}
|
||||
}
|
||||
|
||||
go copy(a, b)
|
||||
go copy(b, a)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// Client manages a single SSH session lifecycle.
|
||||
// It handles connection establishment, authentication, PTY allocation,
|
||||
// terminal resize propagation, and clean shutdown.
|
||||
// terminal resize propagation, port forwarding, and clean shutdown.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
config *models.Profile
|
||||
@@ -30,6 +36,8 @@ type Client struct {
|
||||
onClose func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
forwards *ForwardManager // port forwarding tunnels
|
||||
agentCleanup func() // closes agent socket connection
|
||||
}
|
||||
|
||||
// NewClient creates a new SSH client bound to the given profile.
|
||||
@@ -123,7 +131,18 @@ func (c *Client) connectInternal(
|
||||
port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
|
||||
sshConn, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
|
||||
// Dial through proxy if configured, otherwise use direct TCP.
|
||||
conn, dialErr := c.dialTarget(sc, addr, timeout)
|
||||
if dialErr != nil {
|
||||
return fmt.Errorf("SSH connection failed: %w", dialErr)
|
||||
}
|
||||
ncc, chans, reqs, sshErr := ssh.NewClientConn(conn, addr, sshConfig)
|
||||
if sshErr != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("SSH handshake failed: %w", sshErr)
|
||||
}
|
||||
sshConn = ssh.NewClient(ncc, chans, reqs)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH connection failed: %w", err)
|
||||
@@ -150,6 +169,16 @@ func (c *Client) connectInternal(
|
||||
return fmt.Errorf("PTY request failed: %w", err)
|
||||
}
|
||||
|
||||
// Set up SSH agent forwarding if requested (must happen before shell start).
|
||||
if sc.AgentForwarding {
|
||||
if fwdErr := requestAgentForwarding(sshConn, session); fwdErr != nil {
|
||||
// Non-fatal — log but don't abort the connection.
|
||||
if c.onError != nil {
|
||||
c.onError(fmt.Errorf("agent forwarding: %w", fwdErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdinPipe, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
@@ -193,6 +222,7 @@ func (c *Client) connectInternal(
|
||||
c.stdout = stdoutPipe
|
||||
c.stderr = stderrPipe
|
||||
c.connected = true
|
||||
c.forwards = NewForwardManager(sshConn)
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.readLoop()
|
||||
@@ -231,6 +261,29 @@ func (c *Client) Resize(cols, rows int) error {
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// RunCommand executes a single command using a new SSH session.
|
||||
func (c *Client) RunCommand(command string) (string, error) {
|
||||
c.mu.Lock()
|
||||
if !c.connected || c.sshClient == nil {
|
||||
c.mu.Unlock()
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
client := c.sshClient
|
||||
c.mu.Unlock()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open SSH session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
out, err := session.Output(command)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// Close cleanly shuts down the SSH session and connection, cancels the
|
||||
// background context, and invokes the onClose callback.
|
||||
func (c *Client) Close() error {
|
||||
@@ -242,11 +295,23 @@ func (c *Client) Close() error {
|
||||
c.connected = false
|
||||
session := c.session
|
||||
sshClient := c.sshClient
|
||||
fwdMgr := c.forwards
|
||||
agCleanup := c.agentCleanup
|
||||
onClose := c.onClose
|
||||
c.mu.Unlock()
|
||||
|
||||
c.cancel()
|
||||
|
||||
// Shut down any active port forwards first.
|
||||
if fwdMgr != nil {
|
||||
fwdMgr.StopAll()
|
||||
}
|
||||
|
||||
// Close the SSH agent socket connection.
|
||||
if agCleanup != nil {
|
||||
agCleanup()
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && firstErr == nil {
|
||||
@@ -272,11 +337,38 @@ func (c *Client) IsConnected() bool {
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// Forwards returns the ForwardManager for this client, or nil if not connected.
|
||||
func (c *Client) Forwards() *ForwardManager {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.forwards
|
||||
}
|
||||
|
||||
// SSHConn returns the underlying ssh.Client for use by subsystems such as
|
||||
// SFTP. Returns nil if not connected.
|
||||
func (c *Client) SSHConn() *ssh.Client {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.sshClient
|
||||
}
|
||||
|
||||
// buildAuthMethods constructs the SSH authentication method list from the
|
||||
// profile's SSHConfig and the provided credential callbacks.
|
||||
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
|
||||
// If agent auth is enabled, try to use the local SSH agent first.
|
||||
if sc.AgentAuth {
|
||||
am, cleanup, err := agentAuthMethod()
|
||||
if err == nil {
|
||||
methods = append(methods, am)
|
||||
c.mu.Lock()
|
||||
c.agentCleanup = cleanup
|
||||
c.mu.Unlock()
|
||||
}
|
||||
// Non-fatal — fall through to other auth methods if agent unavailable.
|
||||
}
|
||||
|
||||
authList := sc.AuthMethods
|
||||
if len(authList) == 0 {
|
||||
authList = []string{"publickey", "password", "keyboard-interactive"}
|
||||
@@ -303,15 +395,29 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
||||
case "keyboard-interactive":
|
||||
if getPassword != nil {
|
||||
pwdCallback := getPassword
|
||||
totpSecret := ""
|
||||
if sc.TOTPSecret != "" {
|
||||
totpSecret = sc.TOTPSecret
|
||||
}
|
||||
methods = append(methods, ssh.KeyboardInteractive(
|
||||
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
ans, err := pwdCallback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for i, question := range questions {
|
||||
q := strings.ToLower(question)
|
||||
// Detect TOTP/2FA prompts and auto-fill when secret is configured.
|
||||
if totpSecret != "" && isTOTPPrompt(q) {
|
||||
code, err := totp.GenerateCode(totpSecret, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate TOTP code: %w", err)
|
||||
}
|
||||
answers[i] = code
|
||||
} else {
|
||||
ans, err := pwdCallback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers[i] = ans
|
||||
}
|
||||
answers[i] = ans
|
||||
}
|
||||
return answers, nil
|
||||
},
|
||||
@@ -326,6 +432,117 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
// isTOTPPrompt checks whether a keyboard-interactive question looks like a
|
||||
// TOTP/2FA prompt. Common patterns include "verification code", "otp",
|
||||
// "two-factor", "2fa", "authenticator", etc.
|
||||
func isTOTPPrompt(question string) bool {
|
||||
totpKeywords := []string{
|
||||
"verification code",
|
||||
"otp",
|
||||
"one-time",
|
||||
"one time",
|
||||
"two-factor",
|
||||
"two factor",
|
||||
"2fa",
|
||||
"totp",
|
||||
"authenticator",
|
||||
"security code",
|
||||
"6-digit",
|
||||
"6 digit",
|
||||
}
|
||||
for _, kw := range totpKeywords {
|
||||
if strings.Contains(question, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dialTarget dials the target address, optionally through a SOCKS5 or HTTP
|
||||
// CONNECT proxy as configured in the profile's SSH settings.
|
||||
func (c *Client) dialTarget(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
switch strings.ToLower(sc.ProxyType) {
|
||||
case "socks5":
|
||||
return c.dialSOCKS5(sc, addr, timeout)
|
||||
case "http":
|
||||
return c.dialHTTPProxy(sc, addr, timeout)
|
||||
default:
|
||||
// No proxy — direct TCP dial.
|
||||
return net.DialTimeout("tcp", addr, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// dialSOCKS5 connects to the target via a SOCKS5 proxy.
|
||||
func (c *Client) dialSOCKS5(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
proxyPort := sc.ProxyPort
|
||||
if proxyPort == 0 {
|
||||
proxyPort = 1080
|
||||
}
|
||||
proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort))
|
||||
|
||||
var auth *proxy.Auth
|
||||
if sc.ProxyUsername != "" {
|
||||
auth = &proxy.Auth{
|
||||
User: sc.ProxyUsername,
|
||||
Password: sc.ProxyPassword,
|
||||
}
|
||||
}
|
||||
|
||||
dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: timeout})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 proxy setup failed: %w", err)
|
||||
}
|
||||
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 proxy connection to %s failed: %w", addr, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// dialHTTPProxy connects to the target via an HTTP CONNECT proxy.
|
||||
func (c *Client) dialHTTPProxy(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
proxyPort := sc.ProxyPort
|
||||
if proxyPort == 0 {
|
||||
proxyPort = 8080
|
||||
}
|
||||
proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", proxyAddr, timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP proxy connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Send CONNECT request.
|
||||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr)
|
||||
if sc.ProxyUsername != "" {
|
||||
cred := base64.StdEncoding.EncodeToString([]byte(sc.ProxyUsername + ":" + sc.ProxyPassword))
|
||||
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", cred)
|
||||
}
|
||||
connectReq += "\r\n"
|
||||
|
||||
if _, err := conn.Write([]byte(connectReq)); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("HTTP CONNECT write failed: %w", err)
|
||||
}
|
||||
|
||||
// Read response.
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("HTTP CONNECT response read failed: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("HTTP CONNECT proxy returned %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// connectViaJumpHosts establishes the SSH connection through a chain of
|
||||
// jump hosts. Each hop opens a new net.Conn through the previous hop's
|
||||
// SSH client.
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Package sshconfig parses OpenSSH ~/.ssh/config files and converts
|
||||
// Host blocks into Nexus Terminal connection profiles. Supports a useful
|
||||
// subset of SSH config directives: HostName, Port, User, IdentityFile,
|
||||
// ProxyJump, ForwardAgent, and Compression.
|
||||
//
|
||||
// Limitations (future work):
|
||||
// - Match blocks are ignored
|
||||
// - Wildcards in Host patterns are skipped
|
||||
// - Include directives are not followed
|
||||
// - Token expansion (%h, %p, etc.) is not performed
|
||||
package sshconfig
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HostBlock represents a parsed SSH config Host section.
|
||||
type HostBlock struct {
|
||||
Aliases []string
|
||||
HostName string
|
||||
Port int
|
||||
User string
|
||||
IdentityFile string
|
||||
ProxyJump string
|
||||
ForwardAgent bool
|
||||
Compression bool
|
||||
}
|
||||
|
||||
// ParseFile reads and parses an OpenSSH config file.
|
||||
func ParseFile(path string) ([]HostBlock, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var blocks []HostBlock
|
||||
var current *HostBlock
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments.
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split into directive and value.
|
||||
key, value := splitDirective(line)
|
||||
key = strings.ToLower(key)
|
||||
|
||||
switch key {
|
||||
case "host":
|
||||
// Commit previous block.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
aliases := strings.Fields(value)
|
||||
// Skip wildcard-only hosts (e.g. "Host *").
|
||||
hasWildcard := false
|
||||
for _, a := range aliases {
|
||||
if strings.Contains(a, "*") || strings.Contains(a, "?") {
|
||||
hasWildcard = true
|
||||
}
|
||||
}
|
||||
if hasWildcard && len(aliases) == 1 {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
current = &HostBlock{
|
||||
Aliases: aliases,
|
||||
Port: 22,
|
||||
}
|
||||
|
||||
case "match":
|
||||
// Commit previous block and skip Match blocks.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
current = nil
|
||||
|
||||
case "hostname":
|
||||
if current != nil {
|
||||
current.HostName = value
|
||||
}
|
||||
case "port":
|
||||
if current != nil {
|
||||
if p, err := strconv.Atoi(value); err == nil {
|
||||
current.Port = p
|
||||
}
|
||||
}
|
||||
case "user":
|
||||
if current != nil {
|
||||
current.User = value
|
||||
}
|
||||
case "identityfile":
|
||||
if current != nil {
|
||||
current.IdentityFile = expandTilde(value)
|
||||
}
|
||||
case "proxyjump":
|
||||
if current != nil {
|
||||
current.ProxyJump = value
|
||||
}
|
||||
case "forwardagent":
|
||||
if current != nil {
|
||||
current.ForwardAgent = isTrueish(value)
|
||||
}
|
||||
case "compression":
|
||||
if current != nil {
|
||||
current.Compression = isTrueish(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit final block.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("sshconfig: read error: %w", err)
|
||||
}
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns the standard OpenSSH config file path.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".ssh", "config")
|
||||
}
|
||||
|
||||
// ToProfiles converts parsed Host blocks into Nexus Terminal profiles.
|
||||
// Each block produces one profile; the first alias becomes the display name.
|
||||
func ToProfiles(blocks []HostBlock, folder string) []models.Profile {
|
||||
profiles := make([]models.Profile, 0, len(blocks))
|
||||
|
||||
for _, b := range blocks {
|
||||
if len(b.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
host := b.HostName
|
||||
if host == "" {
|
||||
host = b.Aliases[0]
|
||||
}
|
||||
|
||||
displayName := b.Aliases[0]
|
||||
username := b.User
|
||||
|
||||
sshConf := &models.SSHConfig{
|
||||
AuthMethods: []string{"publickey", "password"},
|
||||
SecretMode: models.SecretModeAskEveryTime,
|
||||
HostKeyPolicy: models.HostKeyAsk,
|
||||
TerminalType: "xterm-256color",
|
||||
AgentAuth: b.ForwardAgent,
|
||||
AgentForwarding: b.ForwardAgent,
|
||||
Compression: b.Compression,
|
||||
}
|
||||
|
||||
if b.IdentityFile != "" {
|
||||
sshConf.PrivateKeyPath = b.IdentityFile
|
||||
}
|
||||
|
||||
// Parse ProxyJump into a jump host.
|
||||
if b.ProxyJump != "" && b.ProxyJump != "none" {
|
||||
jh := parseProxyJump(b.ProxyJump)
|
||||
if jh != nil {
|
||||
sshConf.JumpHosts = []models.JumpHost{*jh}
|
||||
}
|
||||
}
|
||||
|
||||
profile := models.NewProfile(models.ProtocolSSH)
|
||||
profile.ID = uuid.New().String()
|
||||
profile.DisplayName = displayName
|
||||
profile.Host = host
|
||||
profile.Port = b.Port
|
||||
profile.Username = username
|
||||
profile.Folder = folder
|
||||
profile.SSH = sshConf
|
||||
profile.Tags = []string{"ssh-config-import"}
|
||||
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
// parseProxyJump parses a simple ProxyJump value (user@host:port format).
|
||||
// Only the first hop is parsed; chained jumps are future work.
|
||||
func parseProxyJump(value string) *models.JumpHost {
|
||||
// Take only the first hop if comma-separated.
|
||||
if idx := strings.Index(value, ","); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "none" {
|
||||
return nil
|
||||
}
|
||||
|
||||
jh := &models.JumpHost{
|
||||
Port: 22,
|
||||
AuthMethod: "publickey",
|
||||
SecretMode: models.SecretModeAskEveryTime,
|
||||
}
|
||||
|
||||
// Extract user@ prefix.
|
||||
if at := strings.Index(value, "@"); at >= 0 {
|
||||
jh.Username = value[:at]
|
||||
value = value[at+1:]
|
||||
}
|
||||
|
||||
// Extract :port suffix.
|
||||
if colon := strings.LastIndex(value, ":"); colon >= 0 {
|
||||
if p, err := strconv.Atoi(value[colon+1:]); err == nil {
|
||||
jh.Port = p
|
||||
value = value[:colon]
|
||||
}
|
||||
}
|
||||
|
||||
jh.Host = value
|
||||
return jh
|
||||
}
|
||||
|
||||
// splitDirective splits a line into key and value, handling both
|
||||
// "Key Value" and "Key=Value" formats.
|
||||
func splitDirective(line string) (string, string) {
|
||||
// Try = separator first.
|
||||
if idx := strings.Index(line, "="); idx >= 0 {
|
||||
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
// Fall back to whitespace separator.
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) == 1 {
|
||||
parts = strings.SplitN(line, "\t", 2)
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return parts[0], ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
// expandTilde replaces a leading ~ with the user's home directory.
|
||||
func expandTilde(path string) string {
|
||||
if !strings.HasPrefix(path, "~/") {
|
||||
return path
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// isTrueish returns true for "yes", "true", "on", and "1".
|
||||
func isTrueish(val string) bool {
|
||||
switch strings.ToLower(val) {
|
||||
case "yes", "true", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package sshconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testConfig = `# Test SSH config
|
||||
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
|
||||
Host webserver
|
||||
HostName web.example.com
|
||||
Port 2222
|
||||
User deploy
|
||||
IdentityFile ~/.ssh/deploy_key
|
||||
ForwardAgent yes
|
||||
Compression yes
|
||||
|
||||
Host dbserver
|
||||
HostName db.internal.example.com
|
||||
User postgres
|
||||
ProxyJump bastion@jump.example.com:2200
|
||||
|
||||
Host simple
|
||||
HostName 10.0.0.5
|
||||
|
||||
Host wildcard-*
|
||||
User admin
|
||||
`
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config")
|
||||
if err := os.WriteFile(path, []byte(testConfig), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocks, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 3 blocks: webserver, dbserver, simple.
|
||||
// "Host *" and "Host wildcard-*" should be skipped.
|
||||
if len(blocks) != 3 {
|
||||
t.Fatalf("expected 3 blocks, got %d", len(blocks))
|
||||
}
|
||||
|
||||
// Verify webserver block.
|
||||
wb := blocks[0]
|
||||
if wb.Aliases[0] != "webserver" {
|
||||
t.Errorf("expected alias 'webserver', got %q", wb.Aliases[0])
|
||||
}
|
||||
if wb.HostName != "web.example.com" {
|
||||
t.Errorf("expected HostName 'web.example.com', got %q", wb.HostName)
|
||||
}
|
||||
if wb.Port != 2222 {
|
||||
t.Errorf("expected port 2222, got %d", wb.Port)
|
||||
}
|
||||
if wb.User != "deploy" {
|
||||
t.Errorf("expected user 'deploy', got %q", wb.User)
|
||||
}
|
||||
if !wb.ForwardAgent {
|
||||
t.Error("expected ForwardAgent to be true")
|
||||
}
|
||||
if !wb.Compression {
|
||||
t.Error("expected Compression to be true")
|
||||
}
|
||||
|
||||
// Verify dbserver block with ProxyJump.
|
||||
db := blocks[1]
|
||||
if db.Aliases[0] != "dbserver" {
|
||||
t.Errorf("expected alias 'dbserver', got %q", db.Aliases[0])
|
||||
}
|
||||
if db.ProxyJump != "bastion@jump.example.com:2200" {
|
||||
t.Errorf("unexpected ProxyJump: %q", db.ProxyJump)
|
||||
}
|
||||
|
||||
// Verify simple block.
|
||||
sb := blocks[2]
|
||||
if sb.HostName != "10.0.0.5" {
|
||||
t.Errorf("expected HostName '10.0.0.5', got %q", sb.HostName)
|
||||
}
|
||||
if sb.Port != 22 {
|
||||
t.Errorf("expected default port 22, got %d", sb.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToProfiles(t *testing.T) {
|
||||
blocks := []HostBlock{
|
||||
{
|
||||
Aliases: []string{"myserver"},
|
||||
HostName: "srv.example.com",
|
||||
Port: 22,
|
||||
User: "admin",
|
||||
ForwardAgent: true,
|
||||
},
|
||||
{
|
||||
Aliases: []string{"jumpbox"},
|
||||
HostName: "jump.example.com",
|
||||
Port: 2200,
|
||||
User: "jumper",
|
||||
ProxyJump: "gateway@gw.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
profiles := ToProfiles(blocks, "Imported")
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("expected 2 profiles, got %d", len(profiles))
|
||||
}
|
||||
|
||||
p := profiles[0]
|
||||
if p.DisplayName != "myserver" {
|
||||
t.Errorf("expected DisplayName 'myserver', got %q", p.DisplayName)
|
||||
}
|
||||
if p.Host != "srv.example.com" {
|
||||
t.Errorf("expected host 'srv.example.com', got %q", p.Host)
|
||||
}
|
||||
if p.SSH == nil {
|
||||
t.Fatal("expected SSH config to be set")
|
||||
}
|
||||
if !p.SSH.AgentAuth {
|
||||
t.Error("expected AgentAuth to be true for ForwardAgent host")
|
||||
}
|
||||
|
||||
jp := profiles[1]
|
||||
if jp.SSH == nil || len(jp.SSH.JumpHosts) == 0 {
|
||||
t.Fatal("expected jump host from ProxyJump")
|
||||
}
|
||||
jh := jp.SSH.JumpHosts[0]
|
||||
if jh.Host != "gw.example.com" {
|
||||
t.Errorf("expected jump host 'gw.example.com', got %q", jh.Host)
|
||||
}
|
||||
if jh.Username != "gateway" {
|
||||
t.Errorf("expected jump user 'gateway', got %q", jh.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProxyJump(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantHost string
|
||||
wantUser string
|
||||
wantPort int
|
||||
}{
|
||||
{"bastion.example.com", "bastion.example.com", "", 22},
|
||||
{"user@bastion.example.com", "bastion.example.com", "user", 22},
|
||||
{"user@bastion.example.com:2200", "bastion.example.com", "user", 2200},
|
||||
{"host:443", "host", "", 443},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
jh := parseProxyJump(tt.input)
|
||||
if jh == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if jh.Host != tt.wantHost {
|
||||
t.Errorf("host: got %q, want %q", jh.Host, tt.wantHost)
|
||||
}
|
||||
if jh.Username != tt.wantUser {
|
||||
t.Errorf("user: got %q, want %q", jh.Username, tt.wantUser)
|
||||
}
|
||||
if jh.Port != tt.wantPort {
|
||||
t.Errorf("port: got %d, want %d", jh.Port, tt.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration 003: Add snippets table for the command snippet library.
|
||||
-- Stores reusable commands that can be sent to active sessions.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
folder TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/copilot-ssh-client/app/internal/storage/migrations"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/storage/migrations"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -709,3 +709,82 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snippet CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateSnippet inserts a new snippet into the database.
|
||||
func (s *Store) CreateSnippet(snippet *models.Snippet) error {
|
||||
tagsJSON, _ := json.Marshal(snippet.Tags)
|
||||
_, err := s.db.Exec(`INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
snippet.ID, snippet.Name, snippet.Command, snippet.Description,
|
||||
snippet.Folder, string(tagsJSON),
|
||||
snippet.CreatedAt.Format(time.RFC3339),
|
||||
snippet.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSnippet updates an existing snippet.
|
||||
func (s *Store) UpdateSnippet(snippet *models.Snippet) error {
|
||||
tagsJSON, _ := json.Marshal(snippet.Tags)
|
||||
_, err := s.db.Exec(`UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?`,
|
||||
snippet.Name, snippet.Command, snippet.Description,
|
||||
snippet.Folder, string(tagsJSON),
|
||||
snippet.UpdatedAt.Format(time.RFC3339),
|
||||
snippet.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSnippet removes a snippet by ID.
|
||||
func (s *Store) DeleteSnippet(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM snippets WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSnippets returns all snippets ordered by name.
|
||||
func (s *Store) ListSnippets() ([]*models.Snippet, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snippets []*models.Snippet
|
||||
for rows.Next() {
|
||||
sn := &models.Snippet{}
|
||||
var tagsStr, createdAt, updatedAt string
|
||||
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &sn.Tags)
|
||||
if sn.Tags == nil {
|
||||
sn.Tags = []string{}
|
||||
}
|
||||
sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
snippets = append(snippets, sn)
|
||||
}
|
||||
return snippets, rows.Err()
|
||||
}
|
||||
|
||||
// GetSnippet returns a single snippet by ID.
|
||||
func (s *Store) GetSnippet(id string) (*models.Snippet, error) {
|
||||
sn := &models.Snippet{}
|
||||
var tagsStr, createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets WHERE id = ?`, id).
|
||||
Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &sn.Tags)
|
||||
if sn.Tags == nil {
|
||||
sn.Tags = []string{}
|
||||
}
|
||||
sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
return sn, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
|
||||