Author SHA1 Message Date
Andy Dixon 88be0ceb41 Missing file from prev commit 2026-04-28 10:32:44 +01:00
Andy DixonandCopilot d0bfb894f0 Additional Build Stuff
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:30:28 +01:00
Andy Dixon 190bf59085 Missing file 2026-04-28 09:36:25 +01:00
Andy Dixon c656d2ff44 Final pre-launch version: Update logic 2026-04-28 09:35:53 +01:00
Andy Dixon 5699d17fb6 Focus fix for tabs and window 2026-04-24 15:16:18 +01:00
Andy Dixon 9577b829a0 Fix for Mac double-pasting 2026-04-23 11:39:47 +01:00
Andy Dixon 382f3ca8af refactor: adjust indentation and formatting in release-builds.yml 2026-04-22 15:14:05 +01:00
Andy Dixon 7f63928dd8 Release foo 2026-04-22 15:13:53 +01:00
Andy Dixon 025ce1ffeb Documentation Update 2026-04-22 15:00:10 +01:00
Andy Dixon 98ca4743e5 refactor: improve code readability and formatting in profiles.js and tabs.js
- Updated formatting for better readability, including consistent use of line breaks and indentation.
- Enhanced the clarity of event listener functions in profiles.js.
- Refactored tab management logic in tabs.js for improved maintainability.
- Ensured consistent use of single quotes and double quotes across the files.
2026-04-22 14:57:01 +01:00
Andy Dixon 8fa9fb5be5 feat: enhance sidebar edit functionality and prevent text selection
- Added aria-label and type attributes to the edit button in the sidebar.
- Implemented a mechanism to prevent multiple edit triggers using requestAnimationFrame.
- Updated mousedown event handling to allow real controls while preventing text selection in WKWebView.

feat: add reconnectSession method to TabManager

- Introduced reconnectSession method to facilitate session reconnections by ID.

feat: implement disconnected overlay in terminal

- Added setDisconnectedOverlay method to show/hide a disconnected overlay for terminal sessions.
- Created _ensureDisconnectOverlay method to manage the overlay's existence and content.

feat: extend Service API with file-related functions

- Added FileExists and FileIsPrivateKey functions to check file existence and identify private keys.
- Introduced ListSSHKeys function to retrieve SSH private key paths from the user's .ssh directory.
- Implemented SFTPStat and SFTPUploadData functions for enhanced SFTP operations.

refactor: clean up code formatting and improve readability

- Adjusted code formatting for better readability across various files, including consistent use of line breaks and indentation.
2026-04-22 14:56:52 +01:00
Andy Dixon 2ce145ba0f feat: add SFTP drag-and-drop file handling and upload functionality
- Implemented drag-and-drop support for file uploads in the terminal.
- Added a modal for users to choose between uploading files or pasting contents.
- Enhanced SFTP browser with a directory picker for selecting upload destinations.
- Introduced methods for handling file uploads and reading file contents from the local system.
- Added a new API endpoint for uploading raw file data to the server.
- Implemented session management improvements to retrieve the current working directory for SSH sessions.
- Updated documentation to include an overview and quick start guide for Nexus Terminal.
2026-04-21 13:20:03 +01:00
Andy Dixon 7146063da2 UI Updates, and fix UTF8 issues 2026-04-21 08:59:49 +01:00
Andy Dixon c5debd9c77 GitHub improvements 2026-04-20 13:40:00 +01:00
Andy Dixon 1d97e907e0 Fix clipboard on OSX, build-all and unicode support 2026-04-20 12:49:24 +01:00
Andy Dixon f5aa35ac27 Few UI cleanups 2026-04-20 11:24:14 +01:00
Andy Dixon b072731aed Updated module fqdn 2026-04-17 15:30:22 +01:00
andy e50473c142 Update all documentation to cover new features
Comprehensively update README.md, in-app help, and copilot-instructions.md
to document all 9 recently added features.

README.md:
- Add new feature highlights: TOTP/2FA, proxy, auto-reconnect, login scripts,
  broadcast, snippets, search, context menu, quick connect
- Add Power Features section covering broadcast, snippets, search, context
  menu, auto-login scripts, auto-reconnect, and quick connect
- Expand SSH section with TOTP, proxy, login scripts, auto-reconnect
- Expand Telnet section with login scripts and auto-reconnect
- Add new keyboard shortcuts: Cmd+Shift+B/S/Q
- Add troubleshooting entries for proxy, TOTP, login scripts, auto-reconnect
- Update architecture: 18 JS modules, sshclient with proxy/TOTP

In-app help (settings.js):
- Add new Power Features section documenting snippets, search, quick connect,
  login scripts, and auto-reconnect
- Expand SSH section: TOTP/2FA, proxy, auto-login scripts, auto-reconnect
- Expand Telnet section: login scripts, auto-reconnect
- Expand Sessions & Tabs: broadcast mode, right-click context menu
- Expand Security section: TOTP secret storage
- Add new keyboard shortcuts to shortcuts table
- Add new troubleshooting entries (proxy, TOTP, login scripts, reconnect)
- Add broadcast mode to Fun Things section

copilot-instructions.md:
- Update JS module count and script load order
- Document sshclient proxy/TOTP capabilities
- Add SFTP, recording, export/import, updater packages
- Add login script and TOTP profile editor patterns
2026-04-17 00:00:00 +01:00
andy 9d3b3ff507 Add SOCKS5/HTTP proxy support for SSH connections
Allow SSH connections to be routed through SOCKS5 or HTTP CONNECT proxies.

Backend:
- Add ProxyType, ProxyHost, ProxyPort, ProxyUsername, ProxyPassword to SSHConfig
- dialTarget() dispatches to direct TCP, SOCKS5, or HTTP CONNECT based on config
- SOCKS5: uses golang.org/x/net/proxy with optional username/password auth
- HTTP CONNECT: sends CONNECT request with optional Basic auth, validates 200 OK
- Proxy dialling replaces ssh.Dial for non-jump-host connections; the resulting
  net.Conn is passed to ssh.NewClientConn for the SSH handshake

Frontend:
- Add Proxy section to SSH profile editor (collapsed by default)
- Proxy type selector: None / SOCKS5 / HTTP CONNECT
- Host, port, username, password fields
- Collect proxy values on save
2026-04-17 00:00:00 +01:00
andy 2446e037ce Add TOTP/2FA support for SSH keyboard-interactive auth
Automatically generate and submit TOTP codes during SSH keyboard-interactive
authentication when a secret is configured for the profile.

Backend:
- Add TOTPSecret field to SSHConfig model
- Detect TOTP/2FA prompts in keyboard-interactive handler using keyword
  matching (verification code, otp, authenticator, 2fa, etc.)
- Generate 6-digit TOTP codes using pquerna/otp library
- Retrieve TOTP secret from vault during connection (keychain or file vault)
- API: SetTOTPSecret, GetTOTPCode, HasTOTPSecret, DeleteTOTPSecret

Frontend:
- Add TOTP/2FA section to SSH profile editor (collapsed by default)
- Password-masked secret input with show/hide toggle
- Save button: encrypts and stores in vault
- Test button: generates a live 6-digit code (shown for 5 seconds)
- Delete button: removes stored secret
- Auto-detect whether secret is already saved (placeholder indicator)
- CSS for TOTP input row, danger button style
2026-04-17 00:00:00 +01:00
andy 1463394cbb Add auto-login scripts (expect-style send/wait sequences)
Implement expect-style auto-login scripts for SSH and Telnet connections.
Each script is an ordered list of 'wait' (match text in output) and 'send'
(write text + newline) steps that execute sequentially after connection.

Backend:
- Add LoginScriptStep model (type, value, timeout) to models.go
- Add loginScripts field to SSHConfig and TelnetConfig
- Add loginBuf/loginDone on Session struct for output buffering
- Hook onData callbacks to feed login buffer during script execution
- executeLoginScripts() runs steps in a goroutine after connect
- waitForOutput() polls session buffer with configurable timeout

Frontend:
- Add _renderLoginScriptSection() to profile editor
- Interactive step list: add/remove/reorder wait and send steps
- Type selector, value input, timeout for wait steps
- Collect loginScripts as JSON from hidden field
- CSS for login script rows, actions, add buttons
2026-04-17 00:00:00 +01:00
andy 63c27470f6 Enhance quick connect with protocol/username/port fields
Replaces the simple host:port text prompt with a proper dialog featuring:
- Protocol selector (SSH, Telnet, RAW TCP)
- Host input field
- Port field (auto-updates default when protocol changes)
- Username field (shown only for SSH)
- Enter key submits, Escape cancels
- Uses ConnectQuick API for unsaved ad-hoc connections
2026-04-17 00:00:00 +01:00
andy b6d77e485c Add snippet/command library with CRUD and send-to-session
Full command snippet library for saving and sending reusable commands:

Backend:
- SQLite migration 003 creates snippets table
- Snippet model with name, command, description, folder, tags
- Store CRUD methods and API endpoints

Frontend:
- SnippetLibrary class with searchable list + detail/editor split view
- Create, edit, delete snippets in modal dialog
- 'Send to Session' button writes command to active terminal
- 'Send Snippet' option in terminal right-click context menu
- Ctrl+Shift+S keyboard shortcut to open library
2026-04-17 00:00:00 +01:00
andy 9cf2010c70 Add broadcast input mode for multi-session typing
Broadcast mode sends keystrokes from the active terminal to all other
connected sessions simultaneously. Useful for applying the same
commands across multiple servers.

- Toggle via Ctrl+Shift+B keyboard shortcut or broadcast button in tab bar
- Visual indicator: broadcast button pulses red when active
- Only sends to sessions in 'connected' state
- Notification on toggle
- State stored in app state object
2026-04-17 00:00:00 +01:00
andy 79e6f20d3f Add right-click context menu on terminal
Right-clicking the terminal viewport shows a context menu with:
- Copy (enabled when text is selected)
- Paste (reads from clipboard)
- Select All
- Find (opens the search bar)
- Clear Terminal
- Reset Terminal

Uses the existing context-menu CSS classes for consistency with the
tab context menu. WKWebView mousedown prevention on menu items to
avoid deselecting text before copy.
2026-04-17 00:00:00 +01:00
andy 6ec8c304a9 Polish terminal search UI with case/regex toggles and match status
Replaces the basic find bar with a polished overlay that matches the
terminal-search CSS. Features:
- Case sensitivity toggle (Aa button)
- Regular expression toggle (.* button)
- Match status indicator (found/no matches)
- Smooth entrance/exit animation
- Clears search decorations on close
- Re-focuses existing bar if already open
2026-04-17 00:00:00 +01:00
andy e56496d423 Add auto-reconnect with exponential backoff
Sessions configured with auto-reconnect will automatically attempt to
re-establish the connection when unexpectedly disconnected. Uses
exponential backoff (1s, 2s, 4s, 8s... up to 30s) with a configurable
maximum number of attempts (default 5).

- New SessionReconnecting state with pulsing indicator in tab bar
- SSH and Telnet profiles gain autoReconnect and maxReconnectTries fields
- RAW protocol already had autoReconnect; now uses the shared mechanism
- CancelReconnect API to abort in-progress reconnection
- Frontend shows reconnect attempt notifications
- Profile editor exposes toggle and max attempts in Advanced/Reconnection
2026-04-17 00:00:00 +01:00
andy f42662c44e Add clipboard support — Cmd+C to copy, Cmd+V to paste in terminal
Uses Wails runtime ClipboardGetText/ClipboardSetText for reliable
clipboard access in WKWebView. Falls back to navigator.clipboard API.
Cmd+C copies selected text (passes through as SIGINT when no selection).
Cmd+V pastes clipboard contents via xterm.js term.paste().
2026-04-17 00:00:00 +01:00
andy 750ed80e02 Fix help nav showing literal '&amp;' instead of '&' 2026-04-16 00:00:00 +01:00
andy d84f3e3f10 Fix terminal left clipping — consolidate duplicate padding
Removed conflicting padding from layout.css and terminal.css that
stacked to ~11px. Now a single 3px top+left padding is applied
directly on the .xterm element, keeping text visible and tidy.
2026-04-16 00:00:00 +01:00
andy 8a9c17046d Add comprehensive in-app help and rewrite README
In-app documentation (Settings → Help):
- Navigable two-column layout with sidebar sections
- Getting Started guide for creating first connection
- SSH, Telnet, RAW TCP, and GitHub connection guides
- Sessions & Tabs (split panes, recording, tab management)
- Security & Credentials (vault, keychain, known hosts explained)
- Full keyboard shortcuts reference table
- Import & Export guide (encrypted .nexus, OpenSSH config)
- Troubleshooting section (common issues and fixes)
- Fun Things to Try (Star Wars ASCII, MapSCII, BBS, GitHub SSH test)

All documentation in British English, written for average users.

README.md fully rewritten to reflect current feature set — removed
all outdated 'planned'/'in progress' items, updated architecture,
added troubleshooting, corrected encryption parameters.
2026-04-16 00:00:00 +01:00
andy 0c0d9d0d32 Rewrite README to reflect current implemented feature set
Remove outdated 'planned' and 'in progress' sections. All features
(SFTP, split panes, port forwarding, jump hosts, SSH agent forwarding,
session recording, encrypted export, themes, update checker, etc.) are
now documented as fully implemented.

Restructured with table of contents, keyboard shortcuts table,
connection type descriptions, user-friendly security section,
architecture overview, data locations, and troubleshooting guide.
2026-04-16 00:00:00 +01:00
andy a186be1da6 Disable macOS auto-capitalisation on all text inputs
Added MutationObserver in utils.js that sets autocapitalize=none,
autocorrect=off, and spellcheck=false on every input and textarea,
including dynamically created ones (profile editor, SFTP modals, etc).
2026-04-16 00:00:00 +01:00
andy 00f6b5af2f Change bundle identifier to com.andydixon.nexusterminal 2026-04-16 00:00:00 +01:00
andy 82aa812f98 Track cross-platform icons and build metadata in git
Unignored build/darwin/, build/windows/, build/linux/ so icon assets,
Info.plist, and platform metadata are version-controlled.
2026-04-16 00:00:00 +01:00
andy 7cb55e2416 Add cross-platform app icons and update Info.plist
- Generated new terminal-themed icon (>_ prompt on dark charcoal)
- macOS: .icns with all retina sizes (16-1024), via iconutil
- Windows: multi-resolution .ico (16-256)
- Linux: individual PNGs (16-512)
- Info.plist: set bundle ID to com.nexusterminal.app, added
  LSApplicationCategoryType (developer-tools), disabled automatic
  and sudden termination
- wails.json: added Windows icon path
2026-04-16 00:00:00 +01:00
andy f23391881c Fix terminal left clipping and set 3px padding on terminal container 2026-04-16 00:00:00 +01:00
andy 2eb9406f05 Fix SFTP panel to overlay terminal as a slide-out panel
Changed from flex-sibling layout (broken by absolute-positioned terminal
container) to an overlay approach: the SFTP panel is now position:absolute
on the right edge with a drop shadow, sliding over the terminal. The
resize handle is embedded inside the panel at its left edge.
2026-04-16 00:00:00 +01:00
andy d5d0a5c27a Add SFTP toggle button to SSH tabs and enable fullscreen support
- Added 📁 folder icon button on SSH tabs to toggle the SFTP file browser
- Button appears on hover/active, styled consistently with the close button
- Removed conflicting Cmd+Shift+F shortcut (clashes with macOS find)
- Added platform-specific window options (Mac, Windows, Linux)
- Added F11 fullscreen toggle and Escape to exit fullscreen
- Cleaned up SFTP panel on session close
2026-04-16 00:00:00 +01:00
andy 5e40d06a6c Phase 4: SFTP file browser, SSH config import, and update checking
SFTP file browser:
- Created internal/sftpclient wrapping github.com/pkg/sftp for remote file
  operations (list, stat, mkdir, remove, rename, download, upload, pwd)
- Integrated SFTP into session manager with lazy client creation per session
- Added API methods for all SFTP operations with native file dialogs
- Built SFTPBrowser frontend component — right-side resizable panel with
  breadcrumb navigation, sorted file listing, context actions (download,
  rename, delete), upload, new folder, and custom modal dialogs
- Toggle via Cmd+Shift+F on active SSH sessions
- Auto-refits terminal when panel opens/closes/resizes
- Panel cleaned up automatically when session closes

OpenSSH config import:
- Created internal/sshconfig parser for ~/.ssh/config Host blocks
- Converts to profiles with hostname, port, user, identity file,
  ProxyJump (first hop), ForwardAgent, and Compression support
- Skips wildcard-only hosts and Match blocks
- Import button added to Settings > About tab

Automatic update checking:
- Created internal/updater querying GitHub Releases API
- Simple semver comparison, 10-second timeout
- Dev builds never trigger update prompts
- Check button added to Settings > About tab
2026-04-16 00:00:00 +01:00
andy 8c9ce67684 Phase 3: Session recording/playback and encrypted profile export/import
Session Recording & Playback:
- Add recording package with asciicast v2 format writer and reader
- Recorder captures terminal I/O with timestamps from SSH and Telnet sessions
- Input and output events recorded via hooks in session manager callbacks
- Recording store manages .cast files in ~/Library/Application Support/NexusTerminal/recordings
- Recording browser modal lists all saved recordings with metadata
- Full playback viewer with embedded xterm.js terminal, progress scrubber,
  play/pause/restart controls, and 0.5x-10x speed adjustment
- Recording indicator (pulsing red dot) shown on active tab during capture
- Keyboard shortcut Cmd+Shift+R to toggle recording on active session
- Tests for recording, store listing, deletion, and double-close safety

Encrypted Profile Export/Import:
- Add exportimport package with AES-256-GCM encryption (Argon2id key derivation)
- File format: version byte + 32-byte salt + 12-byte nonce + ciphertext
- Same Argon2id parameters as vault (128 MiB, 3 iterations, 4 threads)
- Export modal with profile selection checkboxes and password entry
- Import modal with password entry and conflict strategy (skip/overwrite/duplicate)
- Native save/open file dialogs for .nexus files
- Tests for round-trip encryption, wrong password rejection, corrupt files
- Replaced placeholder import/export with full encrypted implementation

UI:
- Added export and recordings buttons to sidebar footer
- Recording and playback CSS with charcoal design tokens
- Wired RecordingManager and ExportImportUI into app.js
2026-04-16 00:00:00 +01:00
andy da6320c04b Phase 2: Split pane terminals, custom themes, and password strength meter
Split Pane Layout:
- Add SplitPaneManager with recursive tree-based pane splitting
- Support horizontal (Cmd+Shift+E) and vertical (Cmd+Shift+D) splits
- Draggable resize dividers with 100px minimum pane size
- Hover-visible close button to unsplit panes
- Session picker in new panes for connecting separate terminals
- Automatic xterm refit on all pane resize operations

Custom Terminal Themes:
- Add themes.js with 8 preset colour schemes: Charcoal (default),
  Solarized Dark, Monokai, Nord, Dracula, One Dark, Gruvbox Dark,
  Tokyo Night
- Add TerminalTheme preference to AppPreferences model
- Add colour scheme selector to Settings → Terminal tab
- Live-apply theme changes to all active terminals on save
- TerminalManager now uses themed colours instead of hardcoded values

Master Password Strength Meter:
- Add visual strength bar to vault creation dialog
- Entropy estimation based on length, character diversity, uniqueness
- Four levels: Weak (red), Fair (amber), Good (green), Strong (green)
- Animated transitions on strength changes
- Charcoal-themed CSS matching modal design
2026-04-16 00:00:00 +01:00
andy 5a998aefb9 Phase 1: Increase Argon2id to 128 MiB and add SSH agent forwarding
Argon2id:
- Increase memory parameter from 64 MiB to 128 MiB for stronger KDF

SSH Agent:
- Add agent.go with connectAgent(), agentAuthMethod(), requestAgentForwarding()
- Integrate agent auth into buildAuthMethods() (tried first when enabled)
- Set up agent forwarding after PTY allocation when agentForwarding is true
- Track and clean up agent socket connection on session close
- Add AgentAuth and AgentForwarding fields to SSHConfig model
- Add SSH Agent section to profile editor with toggle controls
2026-04-16 00:00:00 +01:00
andy 6d300f7dba Implement SSH port forwarding and improve jump host UI
Port Forwarding (new):
- Add PortForward model with local/remote direction, bind/dest addresses
- Implement ForwardManager in sshclient/portforward.go with full
  bidirectional tunnel support for both local and remote forwarding
- Integrate ForwardManager into SSH client lifecycle (auto-start on
  connect, clean shutdown on close)
- Add session manager methods: ListPortForwards, StartPortForward,
  StopPortForward with event emission
- Add API surface: ListPortForwards, StartPortForward, StopPortForward
- Add validation for port-forward rules in ValidateSSHConfig
- Add port forwarding section to profile editor with direction, bind/dest
  host/port, label, and enable toggle
- Add frontend event handling for forward:started/stopped/error
- Style port-forward rows with charcoal card design matching jump hosts

Jump Host UI improvements:
- Add private key path field to jump host editor rows
- Collect privateKeyPath in form value collection
- Restyle jump host rows as two-line cards with clear field grouping
2026-04-16 00:00:00 +01:00
andy 5e397195b9 Add .github/copilot-instructions.md for AI-assisted development
Documents build commands, architecture, WKWebView constraints, conventions,
the protocol addition checklist, and database migration patterns.
2026-04-16 00:00:00 +01:00
108 changed files with 19478 additions and 4597 deletions
+109
View File
@@ -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)
+137
View File
@@ -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
+8 -3
View File
@@ -4,11 +4,11 @@
# ── Build output ──────────────────────────────────────────────────────────
build/bin/
build/darwin/
build/windows/
build/linux/
dist/
# ── Build platform resources (tracked — icons, plists, manifests) ─────────
# build/darwin/, build/windows/, build/linux/ are tracked for icons & metadata
# ── Go ────────────────────────────────────────────────────────────────────
vendor/
*.exe
@@ -58,3 +58,8 @@ vault.json
*.pem
*.key
!build/appicon.png
# Builds
build/releases/v*
build/releases/manifest*
+9 -2
View File
@@ -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"
+42 -388
View File
@@ -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 |
Executable
+152
View File
@@ -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 "$@"
+124
View File
@@ -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
}
}
+20 -1
View File
@@ -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/"
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

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

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+137
View File
@@ -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)">&gt;_</text>
<!-- Blinking cursor line at bottom of terminal -->
<rect x="152" y="726" width="22" height="36" rx="2" ry="2"
fill="#00D4FF" opacity="0.9" filter="url(#softGlow)"/>
<!-- Bottom status line -->
<text x="740" y="748" font-family="'SF Mono', 'Courier New', monospace"
font-size="22" fill="#00FF88" fill-opacity="0.6" text-anchor="end">SSH v2 AES-256</text>
</g>
<!-- Outer border glow (subtle cyan rim) -->
<rect x="2" y="2" width="1020" height="1020" rx="223" ry="223"
fill="none"
stroke="url(#cyanGrad)" stroke-width="4" stroke-opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

+15
View File
@@ -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}}"
}
}
}
+15
View File
@@ -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>
+29
View File
@@ -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).
+62
View File
@@ -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)
+255
View File
@@ -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.
+249 -113
View File
@@ -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>
+11
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -1 +1 @@
a25ea4f4df93e50eb6c41092b5187a9d
ce0d75b28075a7688ba4535c94b4784c
+21 -17
View File
@@ -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.");
+467 -251
View File
@@ -8,132 +8,132 @@
Form panel (profile editor container)
----------------------------------------------------------------------- */
.form-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.form-panel-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.form-panel-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
flex: 1;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
flex: 1;
}
.form-panel-body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.form-panel-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
Form sections (disclosure-based grouping)
----------------------------------------------------------------------- */
.form-section {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.form-section-header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
background: var(--bg-elevated);
border: none;
cursor: pointer;
user-select: none;
text-align: left;
transition: background var(--transition-fast);
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
background: var(--bg-elevated);
border: none;
cursor: pointer;
user-select: none;
text-align: left;
transition: background var(--transition-fast);
}
.form-section-header:hover {
background: var(--bg-hover);
background: var(--bg-hover);
}
.form-section-chevron {
width: 14px;
height: 14px;
color: var(--text-tertiary);
transition: transform var(--transition-normal);
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--text-tertiary);
transition: transform var(--transition-normal);
flex-shrink: 0;
}
.form-section.is-open .form-section-chevron {
transform: rotate(90deg);
transform: rotate(90deg);
}
.form-section-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.form-section-body,
.form-section-content {
padding: var(--space-4);
display: none;
padding: var(--space-4);
display: none;
}
.form-section.is-open .form-section-body,
.form-section.is-open .form-section-content {
display: block;
display: block;
}
/* -----------------------------------------------------------------------
Field grid (two-column layout for compact forms)
----------------------------------------------------------------------- */
.form-fields {
display: flex;
flex-direction: column;
gap: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* Single-column override */
.form-row.full-width {
grid-template-columns: 1fr;
grid-template-columns: 1fr;
}
/* Three-column for port + protocol combos */
.form-row.triple {
grid-template-columns: 1fr 100px 120px;
grid-template-columns: 1fr 100px 120px;
}
/* -----------------------------------------------------------------------
@@ -143,14 +143,14 @@
.proto-only-ssh,
.proto-only-telnet,
.proto-only-raw {
display: none;
display: none;
}
.protocol-ssh .proto-only-ssh,
.protocol-telnet .proto-only-telnet,
.protocol-raw .proto-only-raw,
.protocol-github .proto-only-github {
display: flex;
display: flex;
}
/* For block-level protocol fields */
@@ -158,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;
}
+177 -139
View File
@@ -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);
}
+113 -81
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+167
View File
@@ -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);
}
+421
View File
@@ -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);
}
+273 -177
View File
@@ -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;
}
+223
View File
@@ -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;
}
+268 -164
View File
@@ -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);
}
+275 -203
View File
@@ -7,10 +7,10 @@
Terminal wrapper — fills the workspace panel
----------------------------------------------------------------------- */
.terminal-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* -----------------------------------------------------------------------
@@ -18,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);
}
+12
View File
@@ -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;
}
+1156 -423
View File
File diff suppressed because it is too large Load Diff
+353 -241
View File
@@ -1,17 +1,17 @@
(function() {
/**
* Nexus Terminal — GitHub repository viewer.
* Renders a scrollable list of repositories grouped by owner/organisation,
* sorted by last commit date (newest first). Automatically refreshes via
* Wails events emitted by the Go backend.
*/
(function () {
/**
* Nexus Terminal — GitHub repository viewer.
* Renders a scrollable list of repositories grouped by owner/organisation,
* sorted by last commit date (newest first). Automatically refreshes via
* Wails events emitted by the Go backend.
*/
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
class GitHubViewer {
class GitHubViewer {
constructor() {
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
this._viewers = new Map();
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
this._viewers = new Map();
}
/**
@@ -21,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();
})();
+2031 -756
View File
File diff suppressed because it is too large Load Diff
+628
View File
@@ -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">&times;</button>
</div>
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
${listHTML}
</div>
<div class="modal-footer">
<button class="btn btn-sm modal-close-btn">Close</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.modal-close-btn').addEventListener('click', close);
// Play buttons
modal.querySelectorAll('.rec-play').forEach(btn => {
btn.addEventListener('click', () => {
close();
this.playback(btn.dataset.id);
});
});
// Delete buttons
modal.querySelectorAll('.rec-delete').forEach(btn => {
btn.addEventListener('click', async () => {
await this.deleteRecording(btn.dataset.id);
const item = btn.closest('.recording-item');
if (item) item.remove();
});
});
}
// ── Playback modal with embedded terminal ────────────────────────
/**
* Shows a playback modal with an xterm.js terminal, progress bar, and speed controls.
* @param {Object} data — { header, events }
* @private
*/
_showPlaybackModal(data) {
const header = data.header;
const events = data.events || [];
if (events.length === 0) {
this.app.notifications.info('Recording is empty — nothing to play back.');
return;
}
const totalDuration = events[events.length - 1][0] || 1;
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-playback');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${_esc(header.title || 'Recording Playback')}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body playback-body">
<div id="playback-terminal" class="playback-terminal"></div>
<div class="playback-controls">
<button class="btn btn-xs btn-primary playback-play-pause" title="Play/Pause">▶</button>
<button class="btn btn-xs btn-outline playback-restart" title="Restart">⟲</button>
<input type="range" class="playback-progress" min="0" max="1000" value="0" />
<span class="playback-time">0:00 / ${_formatSeconds(totalDuration)}</span>
<select class="playback-speed form-input" style="width:auto;padding:2px 6px;font-size:11px;">
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="5">5×</option>
<option value="10">10×</option>
</select>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
// Create xterm instance for playback.
const termContainer = modal.querySelector('#playback-terminal');
const { getTerminalTheme } = window.NexusTerminal;
const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal';
const theme = getTerminalTheme ? getTerminalTheme(themeName) : {};
const term = new window.Terminal({
cols: header.width || 80,
rows: header.height || 24,
theme: theme,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace',
fontSize: 13,
cursorBlink: false,
disableStdin: true,
scrollback: 10000,
});
term.open(termContainer);
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
setTimeout(() => fitAddon.fit(), 50);
// Playback state.
let playing = false;
let speed = 1;
let eventIndex = 0;
let playbackTimer = null;
let startRealTime = 0;
let pausedAt = 0; // elapsed virtual time when paused
const playPauseBtn = modal.querySelector('.playback-play-pause');
const restartBtn = modal.querySelector('.playback-restart');
const progressInput = modal.querySelector('.playback-progress');
const timeLabel = modal.querySelector('.playback-time');
const speedSelect = modal.querySelector('.playback-speed');
const updateTimeDisplay = (elapsed) => {
timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`;
progressInput.value = Math.round((elapsed / totalDuration) * 1000);
};
const scheduleNext = () => {
if (!playing || eventIndex >= events.length) {
if (eventIndex >= events.length) {
playing = false;
playPauseBtn.textContent = '▶';
}
return;
}
const evt = events[eventIndex];
const evtTime = evt[0]; // seconds from start
const realElapsed = (Date.now() - startRealTime) / 1000;
const virtualElapsed = pausedAt + realElapsed * speed;
const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000);
playbackTimer = setTimeout(() => {
if (!playing) return;
// Write output events to the terminal.
if (evt[1] === 'o') {
term.write(evt[2]);
}
eventIndex++;
const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed;
updateTimeDisplay(Math.min(currentVirtual, totalDuration));
scheduleNext();
}, delay);
};
const play = () => {
if (eventIndex >= events.length) {
// Restart from beginning.
eventIndex = 0;
pausedAt = 0;
term.reset();
}
playing = true;
playPauseBtn.textContent = '⏸';
startRealTime = Date.now();
scheduleNext();
};
const pause = () => {
playing = false;
playPauseBtn.textContent = '▶';
if (playbackTimer) {
clearTimeout(playbackTimer);
playbackTimer = null;
}
const realElapsed = (Date.now() - startRealTime) / 1000;
pausedAt = pausedAt + realElapsed * speed;
};
const seekTo = (fraction) => {
const wasPlaying = playing;
if (playing) pause();
const targetTime = fraction * totalDuration;
term.reset();
eventIndex = 0;
// Replay all events up to targetTime.
for (let i = 0; i < events.length; i++) {
if (events[i][0] > targetTime) break;
if (events[i][1] === 'o') {
term.write(events[i][2]);
}
eventIndex = i + 1;
}
pausedAt = targetTime;
updateTimeDisplay(targetTime);
if (wasPlaying) play();
};
playPauseBtn.addEventListener('click', () => {
if (playing) pause(); else play();
});
restartBtn.addEventListener('click', () => {
if (playing) pause();
term.reset();
eventIndex = 0;
pausedAt = 0;
updateTimeDisplay(0);
});
progressInput.addEventListener('input', () => {
seekTo(parseInt(progressInput.value, 10) / 1000);
});
speedSelect.addEventListener('change', () => {
const newSpeed = parseFloat(speedSelect.value);
if (playing) {
const realElapsed = (Date.now() - startRealTime) / 1000;
pausedAt = pausedAt + realElapsed * speed;
speed = newSpeed;
startRealTime = Date.now();
if (playbackTimer) clearTimeout(playbackTimer);
scheduleNext();
} else {
speed = newSpeed;
}
});
// Auto-play on open.
setTimeout(play, 200);
// Close handler.
const close = () => {
if (playing) pause();
term.dispose();
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
}
}
// ---------------------------------------------------------------------------
// Encrypted Export/Import UI
// ---------------------------------------------------------------------------
class ExportImportUI {
constructor(app) {
this.app = app;
}
/**
* Shows the encrypted export modal.
*/
async showExportModal() {
const profiles = this.app.state.profiles || [];
if (profiles.length === 0) {
this.app.notifications.info('No profiles to export.');
return;
}
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-md');
let profileListHTML = profiles.map(p => `
<label class="export-profile-item">
<input type="checkbox" value="${p.id}" checked />
<span>${_esc(p.displayName || p.host)} <small class="text-muted">(${p.protocol})</small></span>
</label>
`).join('');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Export Profiles (Encrypted)</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Select profiles to export</label>
<div class="export-profile-list" style="max-height:200px;overflow-y:auto;margin-bottom:12px;">
${profileListHTML}
</div>
</div>
<div class="form-group">
<label class="form-label">Export password</label>
<input type="password" class="form-input export-password" placeholder="Enter a strong password" />
<small class="text-muted">This password will be required to import the file.</small>
</div>
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input export-password-confirm" placeholder="Confirm password" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm export-cancel">Cancel</button>
<button class="btn btn-sm btn-primary export-confirm">Export</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.export-cancel').addEventListener('click', close);
modal.querySelector('.export-confirm').addEventListener('click', async () => {
const password = modal.querySelector('.export-password').value;
const confirm = modal.querySelector('.export-password-confirm').value;
if (!password) {
this.app.notifications.error('Please enter a password.');
return;
}
if (password !== confirm) {
this.app.notifications.error('Passwords do not match.');
return;
}
const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked');
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
if (selectedIds.length === 0) {
this.app.notifications.error('Please select at least one profile.');
return;
}
try {
const path = await window.go.api.Service.ExportProfilesEncrypted(selectedIds, password);
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
close();
} catch (err) {
if (String(err).includes('cancelled')) {
return;
}
this.app.notifications.error('Export failed: ' + err);
}
});
}
/**
* Shows the encrypted import modal.
*/
async showImportModal() {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-md');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Import Profiles (Encrypted)</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Decryption password</label>
<input type="password" class="form-input import-password" placeholder="Enter the export password" />
</div>
<div class="form-group">
<label class="form-label">Conflict strategy</label>
<select class="form-input import-strategy">
<option value="skip">Skip existing</option>
<option value="overwrite">Overwrite existing</option>
<option value="duplicate">Create duplicates</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm import-cancel">Cancel</button>
<button class="btn btn-sm btn-primary import-confirm">Import</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.import-cancel').addEventListener('click', close);
modal.querySelector('.import-confirm').addEventListener('click', async () => {
const password = modal.querySelector('.import-password').value;
const strategy = modal.querySelector('.import-strategy').value;
if (!password) {
this.app.notifications.error('Please enter the decryption password.');
return;
}
try {
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
this.app.notifications.success(`Imported ${count} profile(s)`);
await this.app.loadProfiles();
close();
} catch (err) {
if (String(err).includes('cancelled')) {
return;
}
this.app.notifications.error('Import failed: ' + err);
}
});
}
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
function _esc(s) {
const el = document.createElement('span');
el.textContent = s || '';
return el.innerHTML;
}
function _formatSeconds(secs) {
if (!secs || secs <= 0) return '0:00';
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function _formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let val = bytes;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
// ---------------------------------------------------------------------------
// Export to global namespace
// ---------------------------------------------------------------------------
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.RecordingManager = RecordingManager;
window.NexusTerminal.ExportImportUI = ExportImportUI;
})();
+53
View File
@@ -134,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;
File diff suppressed because it is too large Load Diff
+846
View File
@@ -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;
})();
+693 -268
View File
File diff suppressed because it is too large Load Diff
+306
View File
@@ -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">&times;</button>
</div>
<div class="modal-body snippet-body" style="display:flex;gap:var(--space-3);height:400px;">
<div class="snippet-list-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<div style="display:flex;gap:var(--space-2);">
<input type="text" class="form-input snippet-search" placeholder="Search snippets…" style="flex:1;" />
<button class="btn btn-sm btn-primary snippet-add-btn">+ New</button>
</div>
<div class="snippet-list" style="flex:1;overflow-y:auto;"></div>
</div>
<div class="snippet-detail-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">
Select a snippet or create a new one
</div>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
});
const close = () => {
backdrop.classList.remove('is-visible');
modal.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
// Search
const searchInput = modal.querySelector('.snippet-search');
searchInput.addEventListener('input', () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close);
});
// New snippet button
modal.querySelector('.snippet-add-btn').addEventListener('click', () => {
this._showEditor(modal, null, close);
});
this._renderList(modal, close);
}
/** @private */
_renderList(modal, closeFn) {
const listEl = modal.querySelector('.snippet-list');
if (!listEl) return;
listEl.innerHTML = '';
const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true;
return sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || '').toLowerCase().includes(this.searchQuery);
});
if (filtered.length === 0) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return;
}
for (const sn of filtered) {
const item = createElement('div', 'snippet-item', {
dataset: { id: sn.id },
});
item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);';
item.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
`;
item.addEventListener('click', () => this._showDetail(modal, sn, closeFn));
item.addEventListener('mousedown', (e) => e.preventDefault());
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
listEl.appendChild(item);
}
}
/** @private */
_showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel');
if (!detailPanel) return;
detailPanel.innerHTML = '';
const nameEl = createElement('div', '', {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
});
detailPanel.appendChild(nameEl);
if (snippet.description) {
const descEl = createElement('div', '', {
textContent: snippet.description,
});
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);';
detailPanel.appendChild(descEl);
}
const cmdEl = createElement('pre', '', {
textContent: snippet.command,
});
cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;';
detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement('div', '', {});
actions.style.cssText = 'display:flex;gap:var(--space-2);';
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' });
sendBtn.addEventListener('click', () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' });
editBtn.addEventListener('click', () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' });
deleteBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error('Failed to delete: ' + err);
}
});
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
}
/** @private */
_showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel');
if (!detailPanel) return;
detailPanel.innerHTML = '';
const isNew = !snippet;
const title = isNew ? 'New Snippet' : 'Edit Snippet';
detailPanel.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Name</label>
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" />
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Description</label>
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" />
</div>
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
<label class="form-label">Command</label>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea>
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Folder</label>
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" />
</div>
<div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
<button class="btn btn-sm snippet-cancel-btn">Cancel</button>
</div>
`;
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => {
const name = detailPanel.querySelector('.snippet-ed-name').value.trim();
const command = detailPanel.querySelector('.snippet-ed-cmd').value;
const description = detailPanel.querySelector('.snippet-ed-desc').value.trim();
const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim();
if (!name) {
this.app.notifications.error('Name is required');
return;
}
if (!command) {
this.app.notifications.error('Command is required');
return;
}
try {
const data = {
id: isNew ? '' : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : (snippet.tags || []),
};
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated');
} catch (err) {
this.app.notifications.error('Failed to save snippet: ' + err);
}
});
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => {
if (snippet) {
this._showDetail(modal, snippet, closeFn);
} else {
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
}
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
})();
+600
View File
@@ -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;
})();
+3
View File
@@ -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();
}
+428 -341
View File
@@ -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">&times;</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;
})();
+1053 -173
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -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;
})();
+29
View File
@@ -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 });
})();
+72
View File
@@ -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>;
+144
View File
@@ -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);
}
+138 -73
View File
@@ -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"];
}
}
}
+10 -6
View File
@@ -1,25 +1,30 @@
module github.com/copilot-ssh-client/app
module github.com/andydixon/nexus
go 1.24.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.45.0
golang.org/x/text v0.31.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.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+21 -10
View File
@@ -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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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=
+712 -6
View File
@@ -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) {
+39 -9
View File
@@ -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)
}
+1 -1
View File
@@ -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"
)
+188
View File
@@ -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
}
+131
View File
@@ -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")
}
}
+155 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package knownhosts
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/andydixon/nexus/internal/models"
)
// ---------------------------------------------------------------------------
+3
View File
@@ -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:
+73 -13
View File
@@ -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"`
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+368
View File
@@ -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
}
+165
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -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 {
+326
View File
@@ -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
}
+80
View File
@@ -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
}
+1 -1
View File
@@ -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"
)
+269
View File
@@ -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()
}
+225 -8
View File
@@ -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.
+274
View File
@@ -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
}
+172
View File
@@ -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'));
+1 -1
View File
@@ -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"
)
+81 -2
View File
@@ -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
}
+1 -1
View File
@@ -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"
)

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