Author SHA1 Message Date
Andy Dixon 1ea24bf7fa feat: add custom app icon replacement for macOS builds 2026-05-21 13:35:39 +01:00
Andy Dixon 0b6f611110 fix: update dependencies for improved stability and performance 2026-05-21 13:17:52 +01:00
Andy Dixon a7cf28b404 feat: implement local terminal support with shell configuration and UI integration 2026-05-21 13:13:06 +01:00
Andy Dixon 3ff04feb92 Refactor: Improve code readability and formatting across multiple files
- Enhanced formatting in triggers.js for better readability, including consistent line breaks and indentation.
- Updated utility functions in utils.js for improved clarity and organization, ensuring consistent formatting and spacing.
- Refactored VNC button creation in vnc.js to enhance readability with better structured code.
- Modified SQL migration script to improve readability by aligning SQL statements and enhancing the structure of the INSERT statement.
2026-05-21 12:53:08 +01:00
Andy Dixon 96fedb9348 feat: add VNC support with noVNC integration
- Introduced a new VNC viewer panel in the frontend, allowing users to connect to VNC sessions.
- Implemented VNC connection handling with optional SSH tunneling in the backend.
- Added a new SSH key manager for managing SSH keys within the terminal.
- Created a trigger rules engine to monitor terminal output and trigger actions based on defined patterns.
- Added CSS styles for the VNC viewer and its components.
- Updated database schema to support VNC protocol in profiles.
- Implemented WebSocket proxy for VNC connections to facilitate communication between the frontend and backend.
2026-05-21 12:52:57 +01:00
Andy Dixon 523d9b02b4 Refactor SnippetLibrary and State classes for improved readability and consistency
- Updated formatting and indentation in snippets.js for better code clarity.
- Enhanced error handling and notifications in sendToActiveSession method.
- Improved variable resolution logic for command snippets.
- Added new methods for managing session state in state.js.
- Introduced new split pane functionality in terminal.js for better user experience.
- Enhanced modal handling and user notifications across various components.
2026-05-21 12:15:49 +01:00
Andy Dixon 421bdb9ee8 feat: add latency indicator and broadcast target selection
- Added a latency indicator to the status bar, displaying round-trip time for SSH sessions.
- Implemented functionality to select specific sessions for broadcasting input, allowing users to manage broadcast targets.
- Enhanced SFTP functionality with bookmark management, enabling users to bookmark directories and navigate to them easily.
- Introduced a modal for user-defined snippet variables, allowing dynamic command execution with placeholders.
- Added port forwarding management for SSH sessions, including the ability to start and stop port forwards.
- Improved terminal output export functionality, allowing users to download or copy terminal output to clipboard.
2026-05-21 12:09:43 +01:00
Andy Dixon c9b5eb10eb Refactor CSS and JavaScript for improved readability and consistency
- Updated CSS styles for buttons, form controls, badges, and notifications to use consistent indentation and formatting.
- Enhanced JavaScript code in sftp.js for better readability by breaking long lines and ensuring consistent formatting.
2026-05-21 11:28:47 +01:00
Andy Dixon 96c603f3f4 Add context menus and SFTP transfer progress handling
- Implemented context menu styles in components.css.
- Added SFTP transfer progress queue and related styles in sftp.css.
- Enhanced app.js to handle SFTP transfer progress events.
- Updated sftp.js to manage transfer progress and render the transfer queue.
- Modified terminal.js to include context menu shortcuts.
- Adjusted manager.go to emit SFTP transfer progress events with direction.
- Updated sshclient.go to modify terminal modes for better performance.
2026-05-21 11:28:37 +01:00
Andy Dixon 23aaa1b53e Linux build stuff 2026-04-28 14:56:13 +01:00
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
120 changed files with 25825 additions and 5780 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"
+43 -388
View File
@@ -1,399 +1,54 @@
# 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)
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
## Keyboard shortcuts
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
| macOS Shortcut | Windows/Linux Shortcut | Action |
| ----------------- | ---------------------- | --------------------------- |
| Cmd + K | Ctrl + K | Open command palette |
| Cmd + N | Ctrl + N | New connection |
| Cmd + W | Ctrl + W | Close current tab |
| Cmd + Tab | Ctrl + Tab | Next tab |
| Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab |
| Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number |
| Cmd + , | Ctrl + , | Open settings |
| Cmd + F | Ctrl + F | Find in terminal |
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
| Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode |
| Cmd + Shift + S | Ctrl + Shift + S | Open snippet library |
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
| F11 | F11 | Toggle fullscreen |
Executable
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# build-all.sh — Cross-build helper for Nexus Terminal (macOS/Linux hosts)
# Builds all supported targets where the required toolchains are installed.
# Usage: ./build-all.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
build_version() {
local tag
local commit
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
echo "${tag}-${commit}"
}
build_ldflags() {
local version="$1"
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
fi
echo "${flags}"
}
check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
if ! command -v wails >/dev/null 2>&1; then
if [ -f "$HOME/go/bin/wails" ]; then
export PATH="$PATH:$HOME/go/bin"
else
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
exit 1
fi
fi
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails version 2>/dev/null | head -1)"
}
install_deps() {
log "Installing Go dependencies…"
go mod download
log "Installing frontend dependencies…"
cd frontend && npm install && cd ..
}
host_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
pick_cc() {
local target_os="$1"
local target_arch="$2"
if [ "$target_os" = "windows" ]; then
if [ "$target_arch" = "amd64" ]; then
echo "x86_64-w64-mingw32-gcc"
else
echo "aarch64-w64-mingw32-gcc"
fi
return
fi
if [ "$target_os" = "linux" ]; then
if [ "$target_arch" = "amd64" ]; then
echo "x86_64-linux-gnu-gcc"
else
echo "aarch64-linux-gnu-gcc"
fi
return
fi
echo ""
}
build_target() {
local target_os="$1"
local target_arch="$2"
local host="$3"
local version="$4"
local ldflags
ldflags="$(build_ldflags "${version}")"
local tags
tags="$(build_tags)"
local platform="${target_os}/${target_arch}"
local cc=""
if [ "$target_os" != "$host" ]; then
cc="$(pick_cc "$target_os" "$target_arch")"
if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then
warn "Skipping ${platform} (missing C compiler: ${cc})"
return 0
fi
fi
log "Building ${platform}…"
if [ -n "$cc" ]; then
if [ -n "$tags" ]; then
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
else
if [ -n "$tags" ]; then
CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
fi
}
main() {
check_deps
install_deps
local version
version="$(build_version)"
local host
host="$(host_os)"
if [ "$host" = "unknown" ]; then
err "Unsupported host OS for this script. Use macOS or Linux."
exit 1
fi
# Build host targets first
build_target "$host" "amd64" "$host" "$version"
build_target "$host" "arm64" "$host" "$version"
# Cross-build Linux and Windows targets where toolchains are available
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
build_target "linux" "amd64" "$host" "$version"
build_target "linux" "arm64" "$host" "$version"
build_target "windows" "amd64" "$host" "$version"
build_target "windows" "arm64" "$host" "$version"
fi
log "Builds complete. Output: build/bin/"
}
main "$@"
+124
View File
@@ -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
}
}
+46 -1
View File
@@ -17,6 +17,31 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
build_version() {
local tag
local commit
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
echo "${tag}-${commit}"
}
build_ldflags() {
local version="$1"
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
fi
echo "${flags}"
}
# Check prerequisites
check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
@@ -62,7 +87,27 @@ build() {
check_deps
install_deps
log "Building production binary…"
wails build
local version
version="$(build_version)"
local tags
tags="$(build_tags)"
if [ -n "$tags" ]; then
log "Using Wails build tags: ${tags}"
wails build -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails build -ldflags "$(build_ldflags "${version}")"
fi
# Replace Wails-generated icon with custom app icon on macOS.
if [[ "$OSTYPE" == darwin* ]]; then
local app_bundle="build/bin/nexus-terminal.app"
local custom_icns="build/darwin/AppIcon.icns"
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
log "Replaced app icon with custom AppIcon.icns"
fi
fi
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>
+77
View File
@@ -0,0 +1,77 @@
# Build and Release
This project uses [Wails](https://wails.io/) for desktop packaging.
## Ubuntu 24.04 prerequisites
Install the Linux build toolchain and Wails runtime headers with:
```bash
sudo apt-get update
sudo apt-get install -y \
build-essential \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
pkg-config \
zip
```
Notes:
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
- The local build scripts detect that package layout automatically and add the tag when needed.
You will also need these tools available in `PATH`:
- `go`
- `node`
- `npm`
- `wails`
Install the Wails CLI with:
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
export PATH="$PATH:$(go env GOPATH)/bin"
```
## Build commands
Build the app binary for the current host:
```bash
./build.sh
```
Build a packaged release for the current host platform:
```bash
./release.sh
```
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
## Optional cross-compilers
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
```bash
sudo apt-get install -y \
gcc-aarch64-linux-gnu \
gcc-mingw-w64-x86-64
```
Then request explicit targets:
```bash
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
```
## Platform caveats
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
+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.
+262 -113
View File
@@ -1,82 +1,174 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Terminal</title>
<!-- Vendor styles -->
<link rel="stylesheet" href="/src/css/lib/xterm.css">
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
<!-- Design system -->
<link rel="stylesheet" href="/src/css/tokens.css">
<link rel="stylesheet" href="/src/css/base.css">
<link rel="stylesheet" href="/src/css/components.css">
<link rel="stylesheet" href="/src/css/layout.css">
<link rel="stylesheet" href="/src/css/sidebar.css">
<link rel="stylesheet" href="/src/css/tabs.css">
<link rel="stylesheet" href="/src/css/terminal.css">
<link rel="stylesheet" href="/src/css/forms.css">
<link rel="stylesheet" href="/src/css/modals.css">
<link rel="stylesheet" href="/src/css/github.css">
</head>
<body>
<link rel="stylesheet" href="/src/css/tokens.css" />
<link rel="stylesheet" href="/src/css/base.css" />
<link rel="stylesheet" href="/src/css/components.css" />
<link rel="stylesheet" href="/src/css/layout.css" />
<link rel="stylesheet" href="/src/css/sidebar.css" />
<link rel="stylesheet" href="/src/css/tabs.css" />
<link rel="stylesheet" href="/src/css/terminal.css" />
<link rel="stylesheet" href="/src/css/splitpane.css" />
<link rel="stylesheet" href="/src/css/recordings.css" />
<link rel="stylesheet" href="/src/css/sftp.css" />
<link rel="stylesheet" href="/src/css/forms.css" />
<link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" />
</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-sm"
id="btn-command-palette"
title="Command Palette (⌘K)"
>
<span>⌘K</span>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-import"
title="Import Profiles"
>
<!-- Upload icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-export"
title="Export Profiles"
>
<!-- Download icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-recordings"
title="Session Recordings"
>
<!-- Film icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-settings"
title="Settings"
>
<!-- Cog icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
@@ -92,62 +184,109 @@
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
</aside>
1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
</div>
</aside>
<!-- ============================================================
<!-- ============================================================
Tab bar — session tabs
============================================================ -->
<div class="tab-bar" id="tab-bar">
<div class="tab-list" id="tab-list">
<!-- Tabs are injected dynamically by JS -->
</div>
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
<div class="tab-bar" id="tab-bar">
<div class="tab-list" id="tab-list">
<!-- Tabs are injected dynamically by JS -->
</div>
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
<button
class="tab-broadcast"
id="btn-broadcast"
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
>
<!-- Broadcast icon (loudspeaker) -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
<!-- ============================================================
<!-- ============================================================
Workspace — terminal area
============================================================ -->
<main class="workspace" id="workspace">
<!-- Empty state — shown when no sessions are active -->
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">
<!-- Terminal icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</div>
<h2>No active sessions</h2>
<p>Select a connection from the sidebar or create a new one to get started.</p>
<div class="empty-state-actions">
<button class="btn btn-primary" id="btn-empty-connect">New Connection</button>
<button class="btn btn-secondary" id="btn-empty-quick">Quick Connect</button>
</div>
</div>
<main class="workspace" id="workspace">
<!-- Empty state — shown when no sessions are active -->
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">
<!-- Terminal icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</div>
<h2>No active sessions</h2>
<p>
Select a connection from the sidebar or create a new one to get
started.
</p>
<div class="empty-state-actions">
<button class="btn btn-primary" id="btn-empty-connect">
New Connection
</button>
<button class="btn btn-secondary" id="btn-empty-quick">
Quick Connect
</button>
</div>
</div>
<!-- Session panels are injected here dynamically by tabs.js -->
<div id="panel-container" class="panel-container"></div>
</main>
<!-- Session panels are injected here dynamically by tabs.js -->
<div id="panel-container" class="panel-container"></div>
</main>
<!-- ============================================================
<!-- ============================================================
Status bar
============================================================ -->
<footer class="status-bar" id="status-bar">
<span id="status-connection">
<span class="status-dot is-disconnected"></span>
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<span id="status-vault">🔒 Locked</span>
</footer>
<footer class="status-bar" id="status-bar">
<span id="status-connection">
<span class="status-dot is-disconnected"></span>
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-latency"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<button
class="status-update is-hidden"
id="status-update"
type="button"
>
Update available
</button>
<span id="status-vault">🔒 Locked</span>
</footer>
</div>
<!-- Modal container (dynamically populated) -->
@@ -160,6 +299,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,13 +308,22 @@
<script src="/src/js/notifications.js"></script>
<script src="/src/js/sidebar.js"></script>
<script src="/src/js/tabs.js"></script>
<script src="/src/js/themes.js"></script>
<script src="/src/js/terminal.js"></script>
<script src="/src/js/profiles.js"></script>
<script src="/src/js/raw.js"></script>
<script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script>
<script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
<script src="/src/js/recordings.js"></script>
<script src="/src/js/sftp.js"></script>
<script src="/src/js/splitpane.js"></script>
<script src="/src/js/snippets.js"></script>
<script src="/src/js/sshkeys.js"></script>
<script src="/src/js/triggers.js"></script>
<script src="/src/js/app.js"></script>
</body>
</body>
</html>
+487
View File
@@ -9,10 +9,487 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/xterm": {
@@ -42,6 +519,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",
+7 -1
View File
@@ -5,14 +5,20 @@
"description": "Frontend for Nexus Terminal desktop SSH client",
"scripts": {
"install:assets": "node scripts/copy-assets.js",
"bundle:novnc": "node scripts/bundle-novnc.js",
"build": "node scripts/build.js",
"dev": "node scripts/build.js --watch",
"postinstall": "npm run install:assets"
"postinstall": "npm run install:assets && npm run bundle:novnc"
},
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+1 -1
View File
@@ -1 +1 @@
a25ea4f4df93e50eb6c41092b5187a9d
99c6bbb0236daee684251a5f62bbd974
+43
View File
@@ -0,0 +1,43 @@
/**
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
* script that exposes window.noVNC.RFB for use in the frontend.
*
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
*/
"use strict";
const esbuild = require("esbuild");
const path = require("path");
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
const outFile = path.resolve(
__dirname,
"..",
"src",
"js",
"lib",
"novnc.min.js",
);
esbuild
.build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
outfile: outFile,
target: ["safari15"],
logLevel: "info",
banner: { js: ";(async function() {" },
footer: { js: "})();" },
})
.then(() => {
console.log(
"[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js",
);
})
.catch((err) => {
console.error("[bundle-novnc] Failed:", err.message);
process.exit(1);
});
+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.");
+4
View File
@@ -0,0 +1,4 @@
// Entry point for esbuild bundling of noVNC.
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
import RFB from "@novnc/novnc";
window.noVNC = { RFB: RFB };
File diff suppressed because it is too large Load Diff
+477 -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,433 @@
.protocol-telnet .proto-only-telnet.form-section,
.protocol-raw .proto-only-raw.form-section,
.protocol-github .proto-only-github.form-section {
display: block;
display: block;
}
/* -----------------------------------------------------------------------
Tag input
----------------------------------------------------------------------- */
.tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
min-height: 30px;
padding: var(--space-1) var(--space-2);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
cursor: text;
transition: border-color var(--transition-fast);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
min-height: 30px;
padding: var(--space-1) var(--space-2);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
cursor: text;
transition: border-color var(--transition-fast);
}
.tag-input-wrapper:hover {
border-color: var(--border-strong);
border-color: var(--border-strong);
}
.tag-input-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 3px;
height: 20px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
color: var(--accent);
background: var(--accent-muted);
border-radius: var(--radius-sm);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
height: 20px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
color: var(--accent);
background: var(--accent-muted);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-item-remove {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
font-size: 10px;
cursor: pointer;
border-radius: 50%;
color: var(--accent);
transition: background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
font-size: 10px;
cursor: pointer;
border-radius: 50%;
color: var(--accent);
transition: background var(--transition-fast);
}
.tag-item-remove:hover {
background: var(--accent-strong);
background: var(--accent-strong);
}
.tag-input {
flex: 1;
min-width: 60px;
border: none;
background: transparent;
font-size: var(--text-sm);
color: var(--text-primary);
outline: none;
height: 22px;
flex: 1;
min-width: 60px;
border: none;
background: transparent;
font-size: var(--text-sm);
color: var(--text-primary);
outline: none;
height: 22px;
}
.tag-input::placeholder {
color: var(--text-disabled);
color: var(--text-disabled);
}
/* -----------------------------------------------------------------------
Jump host / proxy list
----------------------------------------------------------------------- */
.jump-host-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.jump-host-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.jump-host-item .jump-host-order {
font-size: var(--text-xs);
color: var(--text-disabled);
min-width: 16px;
text-align: center;
font-size: var(--text-xs);
color: var(--text-disabled);
min-width: 16px;
text-align: center;
}
.jump-host-item .jump-host-value {
flex: 1;
font-family: var(--font-mono);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-family: var(--font-mono);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jump-host-item .jump-host-actions {
display: flex;
gap: var(--space-1);
opacity: 0;
transition: opacity var(--transition-fast);
display: flex;
gap: var(--space-1);
opacity: 0;
transition: opacity var(--transition-fast);
}
.jump-host-item:hover .jump-host-actions {
opacity: 1;
opacity: 1;
}
.jump-host-add {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
font-size: var(--text-sm);
color: var(--text-tertiary);
border: 1px dashed var(--border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
font-size: var(--text-sm);
color: var(--text-tertiary);
border: 1px dashed var(--border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.jump-host-add:hover {
border-color: var(--accent);
color: var(--accent);
border-color: var(--accent);
color: var(--accent);
}
/* -----------------------------------------------------------------------
Key file selector row
----------------------------------------------------------------------- */
.file-selector {
display: flex;
gap: var(--space-2);
display: flex;
gap: var(--space-2);
}
.file-selector .form-input {
flex: 1;
flex: 1;
}
.file-selector .btn {
flex-shrink: 0;
flex-shrink: 0;
}
/* File path display */
.file-path {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
background: var(--bg-input);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
background: var(--bg-input);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -----------------------------------------------------------------------
Inline validation
----------------------------------------------------------------------- */
.form-group.is-valid .form-input {
border-color: var(--success);
border-color: var(--success);
}
.form-group.is-valid .form-input:focus {
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.20);
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2);
}
.form-group.is-invalid .form-input {
border-color: var(--error);
border-color: var(--error);
}
.form-group.is-invalid .form-input:focus {
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20);
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
}
.form-validation-icon {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
font-size: var(--text-sm);
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
font-size: var(--text-sm);
}
.form-group.is-valid .form-validation-icon { color: var(--success); }
.form-group.is-invalid .form-validation-icon { color: var(--error); }
.form-group.is-valid .form-validation-icon {
color: var(--success);
}
.form-group.is-invalid .form-validation-icon {
color: var(--error);
}
/* -----------------------------------------------------------------------
Help text under sections
----------------------------------------------------------------------- */
.form-section-help {
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-4) var(--space-3);
line-height: var(--leading-relaxed);
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-4) var(--space-3);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Colour preview (for terminal colour settings)
----------------------------------------------------------------------- */
.colour-preview {
display: inline-block;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
vertical-align: middle;
cursor: pointer;
display: inline-block;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
vertical-align: middle;
cursor: pointer;
}
/* -----------------------------------------------------------------------
Number input with stepper
----------------------------------------------------------------------- */
.form-number {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.form-number .form-input {
border-radius: var(--radius-md) 0 0 var(--radius-md);
text-align: center;
width: 60px;
/* Hide browser spinners */
-moz-appearance: textfield;
border-radius: var(--radius-md) 0 0 var(--radius-md);
text-align: center;
width: 60px;
/* Hide browser spinners */
-moz-appearance: textfield;
}
.form-number .form-input::-webkit-inner-spin-button,
.form-number .form-input::-webkit-outer-spin-button {
appearance: none;
appearance: none;
}
.form-number-btn {
width: 24px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-left: none;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
width: 24px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-left: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.form-number-btn:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.form-number-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
background: var(--bg-hover);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Form action row (test connection, etc.)
----------------------------------------------------------------------- */
.form-actions-inline {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-3);
}
.form-actions-inline .btn {
flex-shrink: 0;
flex-shrink: 0;
}
.form-actions-inline .form-actions-status {
font-size: var(--text-xs);
color: var(--text-tertiary);
flex: 1;
font-size: var(--text-xs);
color: var(--text-tertiary);
flex: 1;
}
/* -----------------------------------------------------------------------
Form hint — inline help text below fields
----------------------------------------------------------------------- */
.form-hint {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
.form-hint.secret-error {
color: var(--danger);
color: var(--danger);
}
.settings-shell-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.settings-shell-row .form-input {
flex: 1;
}
.form-success {
font-size: var(--text-xs);
color: var(--success);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Toggle wrapper — label + toggle switch in a row
----------------------------------------------------------------------- */
.form-toggle-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.form-toggle-label {
font-size: var(--text-sm);
color: var(--text-secondary);
flex: 1;
font-size: var(--text-sm);
color: var(--text-secondary);
flex: 1;
}
/* -----------------------------------------------------------------------
Jump host rows
----------------------------------------------------------------------- */
.jump-hosts-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.jump-host-row {
display: grid;
grid-template-columns: 1fr 80px 1fr auto;
gap: var(--space-2);
align-items: center;
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.jump-host-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
Port-forwarding rows
----------------------------------------------------------------------- */
.port-forwards-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.port-forward-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.port-forward-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
.port-forward-arrow {
color: var(--text-muted);
font-size: var(--text-sm);
flex-shrink: 0;
}
.port-forward-enabled-label {
display: flex;
gap: var(--space-1);
align-items: center;
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
cursor: pointer;
}
.port-forward-enabled-label input[type="checkbox"] {
accent-color: var(--accent);
}
/* -----------------------------------------------------------------------
Payload preset rows
----------------------------------------------------------------------- */
.payload-presets-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preset-row {
display: grid;
grid-template-columns: 120px 1fr 100px auto;
gap: var(--space-2);
align-items: center;
display: grid;
grid-template-columns: 120px 1fr 100px auto;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
@@ -513,7 +592,154 @@
----------------------------------------------------------------------- */
.form-input-sm,
.form-select-sm {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
/* -----------------------------------------------------------------------
Login Script Editor — expect-style send/wait steps
----------------------------------------------------------------------- */
.form-help {
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-3);
line-height: 1.5;
}
.login-script-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.login-script-empty {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
padding: var(--space-2) 0;
}
.login-script-row {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.login-script-type {
width: 90px;
min-width: 90px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-value {
flex: 1;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout {
width: 60px;
min-width: 60px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout-label {
font-size: var(--text-xs);
color: var(--text-muted);
min-width: 10px;
}
.login-script-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.login-script-btn {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
width: 24px;
height: 24px;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s;
}
.login-script-btn:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.login-script-btn-remove:hover {
background: var(--danger);
color: #fff;
}
.login-script-add-row {
display: flex;
gap: var(--space-2);
}
/* -----------------------------------------------------------------------
TOTP / 2FA — secret input row with action buttons
----------------------------------------------------------------------- */
.totp-input-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.totp-input-row .form-input {
flex: 1;
}
/* SSH key path row */
.key-path-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.key-path-row .form-input {
flex: 1;
}
.jump-hosts-list .form-error {
margin-bottom: var(--space-2);
}
.totp-toggle,
.totp-test,
.totp-save,
.totp-delete {
flex-shrink: 0;
white-space: nowrap;
}
.btn-danger {
background: var(--error);
color: #fff;
border: 1px solid transparent;
}
.btn-danger:hover {
background: #d32f2f;
}
+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);
}
+127 -81
View File
@@ -7,149 +7,195 @@
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);
}
/* Latency indicator */
#status-latency {
font-variant-numeric: tabular-nums;
}
#status-latency.latency-good {
color: var(--success);
}
#status-latency.latency-warn {
color: var(--warning);
}
#status-latency.latency-bad {
color: var(--error);
}
.status-dot.is-connected { background: var(--success); }
.status-dot.is-connecting { background: var(--warning); }
.status-dot.is-error { background: var(--error); }
.status-dot.is-disconnected { background: var(--text-tertiary); }
/* -----------------------------------------------------------------------
Resize handle (between sidebar and workspace)
----------------------------------------------------------------------- */
.resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: var(--z-sticky);
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: var(--z-sticky);
}
.resize-handle:hover,
.resize-handle.is-dragging {
background: var(--accent-muted);
background: var(--accent-muted);
}
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);
}
+514
View File
@@ -0,0 +1,514 @@
/* ==========================================================================
Nexus Terminal — SFTP File Browser
Right-side panel for browsing remote files alongside the terminal.
========================================================================== */
/* -----------------------------------------------------------------------
Panel wrapper — overlays the terminal from the right side
----------------------------------------------------------------------- */
.sftp-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 300px;
min-width: 220px;
max-width: 50vw;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
overflow: hidden;
z-index: var(--z-dropdown, 50);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25);
}
/* Resize handle — positioned at the left edge of the SFTP panel */
.sftp-resize-handle {
position: absolute;
top: 0;
left: -3px;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
z-index: var(--z-dropdown, 50);
transition: background var(--transition-fast);
}
.sftp-resize-handle:hover,
.sftp-resize-handle.is-dragging {
background: var(--accent);
}
/* -----------------------------------------------------------------------
Toolbar (top bar with path breadcrumb + action buttons)
----------------------------------------------------------------------- */
.sftp-toolbar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
flex-shrink: 0;
min-height: 34px;
}
.sftp-toolbar-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.sftp-toolbar-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition:
background var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
padding: 0;
}
.sftp-toolbar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-toolbar-btn.is-accent:hover {
color: var(--accent);
}
.sftp-toolbar-btn.is-close:hover {
color: var(--error);
}
/* -----------------------------------------------------------------------
Breadcrumb path
----------------------------------------------------------------------- */
.sftp-breadcrumb {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
font-size: var(--text-xs);
color: var(--text-tertiary);
gap: 1px;
}
.sftp-breadcrumb-segment {
background: none;
border: none;
color: var(--text-secondary);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-sm);
white-space: nowrap;
transition:
background var(--transition-fast),
color var(--transition-fast);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
.sftp-breadcrumb-segment:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-breadcrumb-segment:last-child {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.sftp-breadcrumb-sep {
color: var(--text-disabled);
font-size: 10px;
flex-shrink: 0;
user-select: none;
}
/* -----------------------------------------------------------------------
File list
----------------------------------------------------------------------- */
.sftp-file-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Scrollbar styling */
.sftp-file-list::-webkit-scrollbar {
width: 6px;
}
.sftp-file-list::-webkit-scrollbar-track {
background: transparent;
}
.sftp-file-list::-webkit-scrollbar-thumb {
background: var(--border-strong, var(--bg-active));
border-radius: 3px;
}
.sftp-file-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
File row
----------------------------------------------------------------------- */
.sftp-file-row {
display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
min-height: 30px;
}
.sftp-file-row:hover {
background: var(--bg-hover);
}
.sftp-file-row.is-selected {
background: var(--accent-muted);
}
.sftp-file-row.is-selected:hover {
background: var(--accent-strong);
}
.sftp-file-row.is-readonly .sftp-file-meta {
color: var(--warning);
font-weight: var(--weight-semibold);
}
.sftp-file-row.is-unreadable {
opacity: 0.5;
cursor: not-allowed;
}
.sftp-file-row.is-unreadable .sftp-file-name {
text-decoration: line-through;
}
.sftp-file-icon {
font-size: 13px;
text-align: center;
line-height: 1;
flex-shrink: 0;
color: var(--text-tertiary);
}
.sftp-file-icon.is-dir {
color: var(--accent);
}
.sftp-file-name {
font-size: var(--text-sm);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.sftp-file-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
text-align: right;
}
/* -----------------------------------------------------------------------
Parent directory row (..)
----------------------------------------------------------------------- */
.sftp-file-row.is-parent .sftp-file-name {
color: var(--text-secondary);
font-style: italic;
}
/* -----------------------------------------------------------------------
Context actions bar (shown below file list when a file is selected)
----------------------------------------------------------------------- */
.sftp-context-bar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-top: 1px solid var(--border-subtle);
background: var(--bg-elevated);
flex-shrink: 0;
min-height: 32px;
}
.sftp-context-bar .sftp-ctx-label {
flex: 1;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.sftp-context-bar .sftp-ctx-btn {
height: 24px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
background: transparent;
color: var(--text-secondary);
transition:
background var(--transition-fast),
color var(--transition-fast);
white-space: nowrap;
}
.sftp-context-bar .sftp-ctx-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-context-bar .sftp-ctx-btn.is-danger:hover {
background: rgba(239, 83, 80, 0.12);
color: var(--error);
}
.sftp-context-bar .sftp-ctx-btn.is-accent:hover {
color: var(--accent);
}
/* -----------------------------------------------------------------------
States: loading, empty, error
----------------------------------------------------------------------- */
.sftp-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-6) var(--space-4);
text-align: center;
gap: var(--space-2);
}
.sftp-state-icon {
font-size: 28px;
color: var(--text-disabled);
}
.sftp-state-msg {
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: var(--leading-relaxed);
max-width: 200px;
}
.sftp-state-retry {
margin-top: var(--space-1);
}
/* Loading spinner (pure CSS) */
.sftp-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-subtle);
border-top-color: var(--accent);
border-radius: 50%;
animation: sftp-spin 0.6s linear infinite;
}
@keyframes sftp-spin {
to {
transform: rotate(360deg);
}
}
/* -----------------------------------------------------------------------
Inline input (for rename / mkdir)
----------------------------------------------------------------------- */
.sftp-inline-input {
width: 100%;
height: 26px;
padding: 0 var(--space-2);
font-size: var(--text-sm);
font-family: inherit;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
outline: none;
box-shadow: 0 0 0 2px var(--accent-muted);
}
/* Picker-specific input in the toolbar */
.sftp-picker-filter {
width: 160px;
height: 26px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
}
.sftp-picker-filter:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
outline: none;
}
.sftp-picker-status {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-right: var(--space-2);
}
.sftp-picker-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 18px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-tertiary);
}
.sftp-picker-badge.is-readonly {
border-color: rgba(255, 167, 38, 0.35);
color: var(--warning);
background: rgba(255, 167, 38, 0.12);
}
.sftp-picker-badge.is-noread {
border-color: rgba(239, 83, 80, 0.35);
color: var(--error);
background: rgba(239, 83, 80, 0.12);
}
.sftp-picker-legend {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Transfer progress queue
----------------------------------------------------------------------- */
.sftp-transfer-queue {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
max-height: 140px;
overflow-y: auto;
}
.sftp-transfer-header {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: var(--space-1) var(--space-2) 0;
}
.sftp-transfer-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 4px var(--space-2);
}
.sftp-transfer-icon {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--accent);
flex-shrink: 0;
width: 16px;
text-align: center;
}
.sftp-transfer-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sftp-transfer-name {
font-size: var(--text-xs);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sftp-transfer-progress {
height: 3px;
background: var(--border-subtle);
border-radius: 2px;
overflow: hidden;
}
.sftp-transfer-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.15s ease-out;
}
.sftp-transfer-meta {
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Bookmark list
----------------------------------------------------------------------- */
.sftp-bookmark-list {
max-height: 250px;
overflow-y: auto;
}
.sftp-bookmark-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.sftp-bookmark-row:hover {
background: var(--bg-hover);
}
+294 -177
View File
@@ -7,313 +7,430 @@
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-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18);
color: var(--protocol-ssh);
}
.sidebar-item .protocol-indicator.proto-telnet {
background: rgba(255, 167, 38, 0.18);
color: var(--protocol-telnet);
}
.sidebar-item .protocol-indicator.proto-raw {
background: rgba(171, 71, 188, 0.18);
color: var(--protocol-raw);
}
.sidebar-item .protocol-indicator.proto-github {
background: rgba(124, 138, 255, 0.18);
color: var(--protocol-github);
}
.sidebar-item .protocol-indicator.proto-vnc {
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
}
/* Local machine item separator */
.sidebar-item-local {
border-bottom: 1px solid var(--border-subtle);
margin: var(--space-2) var(--space-2) var(--space-2);
padding-bottom: var(--space-2);
}
.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 +439,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;
}
+319 -164
View File
@@ -8,262 +8,417 @@
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);
}
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* -----------------------------------------------------------------------
Status dot (connection state)
----------------------------------------------------------------------- */
.tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
}
.tab-status.is-connected { background: var(--success); }
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
.tab-status.is-error { background: var(--error); }
.tab-status.is-disconnected { background: var(--text-tertiary); }
.tab-status.is-connected {
background: var(--success);
}
.tab-status.is-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.tab-status.is-reconnecting {
background: var(--warning);
animation: pulse-dot 0.8s ease infinite;
}
.tab-status.is-error {
background: var(--error);
}
.tab-status.is-disconnected {
background: var(--text-tertiary);
}
/* Also match the status-* pattern used by updateSessionState */
.tab-status.status-connected {
background: var(--success);
}
.tab-status.status-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.tab-status.status-reconnecting {
background: var(--warning);
animation: pulse-dot 0.8s ease infinite;
}
.tab-status.status-error {
background: var(--error);
}
.tab-status.status-disconnected {
background: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Activity indicator — pulses when background tab has new output
----------------------------------------------------------------------- */
.tab-activity {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
opacity: 0;
transition: opacity 150ms ease;
}
.tab-has-activity .tab-activity {
opacity: 1;
animation: pulse-dot 1.5s ease infinite;
}
.tab-active .tab-activity {
opacity: 0;
}
/* Trigger flash — brief glow when a trigger rule matches */
.tab-trigger-flash {
animation: trigger-flash 2s ease;
}
@keyframes trigger-flash {
0%,
100% {
background: var(--bg-hover);
}
15% {
background: rgba(239, 83, 80, 0.25);
}
30% {
background: var(--bg-hover);
}
45% {
background: rgba(239, 83, 80, 0.15);
}
}
/* -----------------------------------------------------------------------
SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */
.tab-sftp {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
color: var(--text-tertiary);
flex-shrink: 0;
opacity: 0;
transition:
opacity var(--transition-fast),
background var(--transition-fast),
color var(--transition-fast);
cursor: pointer;
background: none;
border: none;
padding: 0;
margin-left: auto;
}
.tab:hover .tab-sftp,
.tab.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 +426,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);
}
+109 -92
View File
@@ -5,137 +5,154 @@
========================================================================== */
:root {
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Colour palette — dark charcoal theme
----------------------------------------------------------------------- */
--bg-base: #1c1c1c; /* deepest background */
--bg-surface: #232323; /* primary surface (sidebar, bars) */
--bg-elevated: #2a2a2a; /* elevated panels, cards */
--bg-overlay: #303030; /* overlays, dropdowns */
--bg-hover: #363636; /* hover state backgrounds */
--bg-active: #3c3c3c; /* active / pressed state */
--bg-input: #1e1e1e; /* input field backgrounds */
--bg-base: #1c1c1c; /* deepest background */
--bg-surface: #232323; /* primary surface (sidebar, bars) */
--bg-elevated: #2a2a2a; /* elevated panels, cards */
--bg-overlay: #303030; /* overlays, dropdowns */
--bg-hover: #363636; /* hover state backgrounds */
--bg-active: #3c3c3c; /* active / pressed state */
--bg-input: #1e1e1e; /* input field backgrounds */
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Border colours
----------------------------------------------------------------------- */
--border-subtle: rgba(255, 255, 255, 0.07);
--border-default: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--accent);
--border-subtle: rgba(255, 255, 255, 0.07);
--border-default: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--accent);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Text colours
----------------------------------------------------------------------- */
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
--sidebar-group-title: #b89d5f;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Accent colours — teal / cyan
----------------------------------------------------------------------- */
--accent: #4ecdc4;
--accent-hover: #45b7af;
--accent-muted: rgba(78, 205, 196, 0.12);
--accent-strong: rgba(78, 205, 196, 0.22);
--accent: #4ecdc4;
--accent-hover: #45b7af;
--accent-muted: rgba(78, 205, 196, 0.12);
--accent-strong: rgba(78, 205, 196, 0.22);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Status colours
----------------------------------------------------------------------- */
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--info: #42a5f5;
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--info: #42a5f5;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Protocol badge colours
----------------------------------------------------------------------- */
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Spacing scale (4 px base unit)
----------------------------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Typography
----------------------------------------------------------------------- */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco,
'Cascadia Code', monospace;
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
sans-serif;
--font-mono:
"JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Border radius
----------------------------------------------------------------------- */
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Shadows
----------------------------------------------------------------------- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Transitions
----------------------------------------------------------------------- */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Z-index layers
----------------------------------------------------------------------- */
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-notification: 500;
--z-tooltip: 600;
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-notification: 500;
--z-tooltip: 600;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Layout
----------------------------------------------------------------------- */
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 36px;
--toolbar-height: 40px;
--status-bar-height: 24px;
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 36px;
--toolbar-height: 40px;
--status-bar-height: 24px;
}
/* -----------------------------------------------------------------------
Global input overrides — disable macOS/WebKit auto-capitalisation,
autocorrect, and spellcheck on all text fields.
----------------------------------------------------------------------- */
input,
textarea,
select {
autocapitalize: none;
autocorrect: off;
spellcheck: false;
text-transform: none;
}
+78
View File
@@ -0,0 +1,78 @@
/* VNC viewer styles */
.vnc-viewer-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
.vnc-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.vnc-status {
font-size: 0.75rem;
color: var(--text-secondary);
margin-right: auto;
}
.vnc-status-connected {
color: var(--protocol-ssh);
}
.vnc-status-error {
color: var(--danger);
}
.btn-vnc-action {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.btn-vnc-action:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vnc-display {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
}
.vnc-display canvas {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
/* Protocol colour for VNC */
.proto-vnc {
color: var(--protocol-vnc);
}
.badge-vnc {
background: var(--protocol-vnc);
color: #fff;
}
.sidebar-item-indicator--vnc {
background: var(--protocol-vnc);
}
+1446 -418
View File
File diff suppressed because it is too large Load Diff
+339 -280
View File
@@ -1,27 +1,27 @@
(function() {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
(function () {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
class CommandPalette {
class CommandPalette {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = '';
/** @type {string} Current filter query. */
this._query = "";
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
@@ -30,31 +30,31 @@ class CommandPalette {
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = '';
this._selectedIndex = 0;
this._render();
if (this._overlay) return;
this._query = "";
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
@@ -64,151 +64,192 @@ class CommandPalette {
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: 'Connections',
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
// New connections
cmds.push({
id: "new-ssh",
label: "New SSH Connection",
section: "Actions",
shortcut: "⌘N",
action: () => this.app.profileEditor.openNew("ssh"),
});
cmds.push({
id: "new-telnet",
label: "New Telnet Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("telnet"),
});
cmds.push({
id: "new-raw",
label: "New RAW Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("raw"),
});
// Quick connect
cmds.push({
id: 'quick-connect',
label: 'Quick Connect…',
section: 'Actions',
action: () => this.app.quickConnect(),
});
// Quick connect
cmds.push({
id: "quick-connect",
label: "Quick Connect…",
section: "Actions",
action: () => this.app.quickConnect(),
});
// Tab actions
cmds.push({
id: 'close-tab',
label: 'Close Tab',
section: 'Tabs',
shortcut: '⌘W',
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: 'next-tab',
label: 'Next Tab',
section: 'Tabs',
shortcut: '⌘Tab',
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: 'prev-tab',
label: 'Previous Tab',
section: 'Tabs',
shortcut: '⌘⇧Tab',
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Settings & utility
// Tab actions
cmds.push({
id: "close-tab",
label: "Close Tab",
section: "Tabs",
shortcut: "⌘W",
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: "next-tab",
label: "Next Tab",
section: "Tabs",
shortcut: "⌘Tab",
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: "prev-tab",
label: "Previous Tab",
section: "Tabs",
shortcut: "⌘⇧Tab",
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
id: "export-profiles",
label: "Export Profiles",
section: "Application",
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'settings',
label: 'Settings',
section: 'Application',
action: () => this.app.settings.open(),
id: "lock-secrets",
label: "Lock Secret Store",
section: "Security",
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
},
});
}
// SSH Key Manager
cmds.push({
id: "ssh-keys",
label: "SSH Key Manager",
section: "Security",
action: () => this.app.sshKeys.open(),
});
// Trigger Rules
cmds.push({
id: "trigger-rules",
label: "Trigger Rules",
section: "Terminal",
action: () => this.app.triggers.open(),
});
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: "split-vertical",
label: "Split Pane Right",
section: "Terminal",
shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
});
cmds.push({
id: 'import-profiles',
label: 'Import Profiles',
section: 'Application',
action: () => this.app.importProfiles(),
id: "split-horizontal",
label: "Split Pane Down",
section: "Terminal",
shortcut: "⌘⇧E",
action: () => this.app.splitPane("horizontal"),
});
cmds.push({
id: 'export-profiles',
label: 'Export Profiles',
section: 'Application',
action: () => this.app.exportProfiles(),
id: "clear-terminal",
label: "Clear Terminal",
section: "Terminal",
action: () =>
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: "reset-terminal",
label: "Reset Terminal",
section: "Terminal",
action: () =>
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
return cmds;
}
/**
@@ -217,14 +258,17 @@ class CommandPalette {
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
@@ -232,82 +276,85 @@ class CommandPalette {
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === 'function') {
command.action();
}
this.close();
if (command && typeof command.action === "function") {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement('div', 'palette-overlay');
const palette = createElement('div', 'palette');
const overlay = createElement("div", "palette-overlay");
const palette = createElement("div", "palette");
// Search input
const input = createElement('input', 'palette-input', {
type: 'text',
placeholder: 'Type a command…',
autocomplete: 'off',
spellcheck: 'false',
});
// Search input
const input = createElement("input", "palette-input", {
type: "text",
placeholder: "Type a command…",
autocomplete: "off",
spellcheck: "false",
});
palette.appendChild(input);
palette.appendChild(input);
// Results list
const list = createElement('div', 'palette-list');
palette.appendChild(list);
// Results list
const list = createElement("div", "palette-list");
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(''));
// Initial render
this._renderList(list, this.filter(""));
// Focus the input
input.focus();
// Focus the input
input.focus();
// Event handlers
input.addEventListener('input', () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
// Event handlers
input.addEventListener("input", () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener('keydown', (e) => {
const items = list.querySelectorAll('.palette-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
this._highlightItem(items);
break;
case 'ArrowUp':
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case 'Enter': {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case 'Escape':
e.preventDefault();
this.close();
break;
input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll(".palette-item");
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items);
break;
case "ArrowUp":
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case "Enter": {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
});
break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close();
});
// Click outside to close
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
}
/**
@@ -317,52 +364,65 @@ class CommandPalette {
* @private
*/
_renderList(list, commands) {
list.innerHTML = '';
let currentSection = null;
list.innerHTML = "";
let currentSection = null;
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
if (commands.length === 0) {
const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl);
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
// Label
const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label);
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
item._command = cmd;
// Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
// Detail
if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
}
/**
@@ -371,17 +431,16 @@ class CommandPalette {
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
items.forEach((el, i) => {
el.classList.toggle("palette-item-active", i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: "nearest" });
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
})();
+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();
})();
+2212 -755
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
File diff suppressed because it is too large Load Diff
+726 -268
View File
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
(function () {
/**
* Nexus Terminal — snippet / command library.
* Provides a panel for managing and sending reusable commands to active sessions.
*/
const { createElement, escapeHtml, dismissContextMenus } =
window.NexusTerminal;
class SnippetLibrary {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {Array<Object>} */
this.snippets = [];
this.searchQuery = "";
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the snippet library modal.
*/
async open() {
await this._loadSnippets();
this._showModal();
}
/**
* Sends a snippet command to the active session.
* Supports {{variable}} placeholders that prompt the user for values.
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
* @param {string} command
*/
sendToActiveSession(command) {
const sessionId = this.app.state.activeSessionId;
if (!sessionId) {
this.app.notifications.error("No active session");
return;
}
const session = this.app.state.getSession(sessionId);
if (!session || session.state !== "connected") {
this.app.notifications.error("Session is not connected");
return;
}
// Check for template variables
const varPattern = /\{\{([^}]+)\}\}/g;
const matches = [];
let match;
while ((match = varPattern.exec(command)) !== null) {
matches.push(match[1].trim());
}
// Resolve built-in variables first
const builtins = {
hostname: session.host || "unknown",
date: new Date().toISOString().split("T")[0],
time: new Date().toTimeString().split(" ")[0],
timestamp: Math.floor(Date.now() / 1000).toString(),
};
let resolved = command;
const userVars = [];
for (const varName of matches) {
const lower = varName.toLowerCase();
if (builtins[lower] !== undefined) {
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
} else if (!userVars.includes(varName)) {
userVars.push(varName);
}
}
if (userVars.length === 0) {
this._sendCommand(sessionId, resolved);
return;
}
// Prompt for user-defined variables
this._promptVariables(userVars, (values) => {
let final = resolved;
for (let i = 0; i < userVars.length; i++) {
final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
}
this._sendCommand(sessionId, final);
});
}
/** @private */
_sendCommand(sessionId, command) {
try {
const b64 = btoa(command + "\n");
window.go.api.Service.WriteSession(sessionId, b64);
this.app.notifications.info("Snippet sent");
} catch (err) {
this.app.notifications.error("Failed to send snippet: " + err);
}
}
/** @private */
_promptVariables(varNames, onConfirm) {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
let inputsHtml = "";
for (const v of varNames) {
inputsHtml += `
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">${escapeHtml(v)}</label>
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
</div>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Snippet Variables</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">${inputsHtml}</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
const firstInput = modal.querySelector(".snippet-var-input");
if (firstInput) firstInput.focus();
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".snippet-var-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
const inputs = modal.querySelectorAll(".snippet-var-input");
const values = Array.from(inputs).map((inp) => inp.value);
close();
onConfirm(values);
});
// Enter to send
modal.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
modal.querySelector(".snippet-var-send").click();
}
if (e.key === "Escape") close();
});
}
// ── Data ─────────────────────────────────────────────────────────
/** @private */
async _loadSnippets() {
try {
this.snippets = (await window.go.api.Service.ListSnippets()) || [];
} catch (err) {
this.snippets = [];
console.error("Failed to load snippets:", err);
}
}
// ── Modal ────────────────────────────────────────────────────────
/** @private */
_showModal() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Command Library</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body snippet-body" style="display:flex;gap:var(--space-3);height:400px;">
<div class="snippet-list-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<div style="display:flex;gap:var(--space-2);">
<input type="text" class="form-input snippet-search" placeholder="Search snippets…" style="flex:1;" />
<button class="btn btn-sm btn-primary snippet-add-btn">+ New</button>
</div>
<div class="snippet-list" style="flex:1;overflow-y:auto;"></div>
</div>
<div class="snippet-detail-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">
Select a snippet or create a new one
</div>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
// Search
const searchInput = modal.querySelector(".snippet-search");
searchInput.addEventListener("input", () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close);
});
// New snippet button
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
this._showEditor(modal, null, close);
});
this._renderList(modal, close);
}
/** @private */
_renderList(modal, closeFn) {
const listEl = modal.querySelector(".snippet-list");
if (!listEl) return;
listEl.innerHTML = "";
const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true;
return (
sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || "").toLowerCase().includes(this.searchQuery)
);
});
if (filtered.length === 0) {
listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return;
}
for (const sn of filtered) {
const item = createElement("div", "snippet-item", {
dataset: { id: sn.id },
});
item.style.cssText =
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
item.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
`;
item.addEventListener("click", () =>
this._showDetail(modal, sn, closeFn),
);
item.addEventListener("mousedown", (e) => e.preventDefault());
item.addEventListener("mouseenter", () => {
item.style.background = "var(--bg-hover)";
});
item.addEventListener("mouseleave", () => {
item.style.background = "";
});
listEl.appendChild(item);
}
}
/** @private */
_showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
const nameEl = createElement("div", "", {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
});
detailPanel.appendChild(nameEl);
if (snippet.description) {
const descEl = createElement("div", "", {
textContent: snippet.description,
});
descEl.style.cssText =
"font-size:var(--text-sm);color:var(--text-secondary);";
detailPanel.appendChild(descEl);
}
const cmdEl = createElement("pre", "", {
textContent: snippet.command,
});
cmdEl.style.cssText =
"flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement("div", "", {});
actions.style.cssText = "display:flex;gap:var(--space-2);";
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send to Session",
});
sendBtn.addEventListener("click", () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Edit",
});
editBtn.addEventListener("click", () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error("Failed to delete: " + err);
}
});
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
}
/** @private */
_showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
const isNew = !snippet;
const title = isNew ? "New Snippet" : "Edit Snippet";
detailPanel.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Name</label>
<input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Description</label>
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
</div>
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
<label class="form-label">Command</label>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Folder</label>
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
</div>
<div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
<button class="btn btn-sm snippet-cancel-btn">Cancel</button>
</div>
`;
detailPanel
.querySelector(".snippet-save-btn")
.addEventListener("click", async () => {
const name = detailPanel
.querySelector(".snippet-ed-name")
.value.trim();
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
const description = detailPanel
.querySelector(".snippet-ed-desc")
.value.trim();
const folder = detailPanel
.querySelector(".snippet-ed-folder")
.value.trim();
if (!name) {
this.app.notifications.error("Name is required");
return;
}
if (!command) {
this.app.notifications.error("Command is required");
return;
}
try {
const data = {
id: isNew ? "" : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : snippet.tags || [],
};
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(
isNew ? "Snippet created" : "Snippet updated",
);
} catch (err) {
this.app.notifications.error("Failed to save snippet: " + err);
}
});
detailPanel
.querySelector(".snippet-cancel-btn")
.addEventListener("click", () => {
if (snippet) {
this._showDetail(modal, snippet, closeFn);
} else {
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
}
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
})();
+600
View File
@@ -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;
})();
+310
View File
@@ -0,0 +1,310 @@
(function () {
/**
* Nexus Terminal — SSH Key Manager.
* Panel for listing, generating, copying, and deleting SSH key pairs.
*/
const { createElement, dismissContextMenus } = window.NexusTerminal;
class SSHKeyManager {
constructor(app) {
this.app = app;
this._backdrop = null;
}
// ── Public API ───────────────────────────────────────────────────
open() {
if (this._backdrop) return;
this._render();
this._loadKeys();
}
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
// ── Rendering ────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">SSH Key Manager</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="sshkey-toolbar">
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
</div>
<div class="sshkey-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
// Events
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".sshkey-generate-btn")
.addEventListener("click", () => this._showGenerateForm());
}
async _loadKeys() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
try {
const keys = await window.go.api.Service.ListSSHKeyPairs();
if (keys.length === 0) {
list.innerHTML =
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
return;
}
list.innerHTML = "";
for (const key of keys) {
list.appendChild(this._renderKeyRow(key));
}
} catch (err) {
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
}
}
_renderKeyRow(key) {
const row = createElement("div", "sshkey-row");
const info = createElement("div", "sshkey-info");
const name = createElement("div", "sshkey-name", {
textContent: key.name,
});
const meta = createElement("div", "sshkey-meta", {
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
});
info.appendChild(name);
info.appendChild(meta);
const actions = createElement("div", "sshkey-actions");
const copyBtn = createElement("button", "btn btn-xs", {
textContent: "Copy Public Key",
});
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
actions.appendChild(copyBtn);
actions.appendChild(deleteBtn);
row.appendChild(info);
row.appendChild(actions);
return row;
}
async _copyPublicKey(key) {
try {
const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name);
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(pubKey);
} else {
await navigator.clipboard.writeText(pubKey);
}
this.app.notifications.success("Public key copied to clipboard");
} catch (err) {
this.app.notifications.error("Failed to copy: " + err);
}
}
_confirmDelete(key) {
const confirmed = this._showConfirm(
"Delete Key",
`Delete key pair "${key.name}"? This cannot be undone.`,
"Delete",
);
confirmed.then((ok) => {
if (ok) this._deleteKey(key);
});
}
async _deleteKey(key) {
try {
await window.go.api.Service.DeleteSSHKey(key.name);
this.app.notifications.success(`Key "${key.name}" deleted`);
this._loadKeys();
} catch (err) {
this.app.notifications.error("Delete failed: " + err);
}
}
// ── Generate form ────────────────────────────────────────────────
_showGenerateForm() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
const form = createElement("div", "sshkey-generate-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Key Name</label>
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input sshkey-type-select">
<option value="ed25519" selected>Ed25519 (recommended)</option>
<option value="rsa">RSA</option>
<option value="ecdsa">ECDSA</option>
</select>
</div>
<div class="form-group sshkey-bits-group" style="display:none">
<label class="form-label">Bits</label>
<select class="form-input sshkey-bits-select">
<option value="4096" selected>4096</option>
<option value="2048">2048</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passphrase (optional)</label>
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
</div>
<div class="sshkey-form-actions">
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
</div>
`;
// Insert form above the key list
list.parentNode.insertBefore(form, list);
list.style.display = "none";
// Show bits field for RSA
const typeSelect = form.querySelector(".sshkey-type-select");
const bitsGroup = form.querySelector(".sshkey-bits-group");
typeSelect.addEventListener("change", () => {
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
});
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
form.remove();
list.style.display = "";
});
form
.querySelector(".sshkey-create-btn")
.addEventListener("click", async () => {
const nameInput = form.querySelector(".sshkey-name-input");
const name = nameInput.value.trim();
const keyType = typeSelect.value;
const bits = parseInt(
form.querySelector(".sshkey-bits-select").value,
10,
);
const passphrase = form.querySelector(".sshkey-pass-input").value;
if (!name) {
this.app.notifications.error("Key name is required");
return;
}
try {
await window.go.api.Service.GenerateSSHKey(
name,
keyType,
bits,
passphrase,
);
this.app.notifications.success(
`Key "${name}" generated successfully`,
);
form.remove();
list.style.display = "";
this._loadKeys();
} catch (err) {
this.app.notifications.error("Generation failed: " + err);
}
});
form.querySelector(".sshkey-name-input").focus();
}
// ── Confirmation helper ──────────────────────────────────────────
_showConfirm(title, message, confirmLabel) {
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
resolve(val);
};
modal
.querySelector(".modal-close")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-cancel")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-ok")
.addEventListener("click", () => close(true));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(false);
});
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
})();
+90 -84
View File
@@ -1,32 +1,38 @@
(function() {
/**
* Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
(function () {
/**
* Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
class State {
class State {
constructor() {
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {string|null} */
this.activeSessionId = null;
/** @type {string|null} */
this.activeSessionId = null;
/** @type {Object} */
this.preferences = {};
/** @type {Object} */
this.preferences = {};
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
this.broadcastMode = false;
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
this.broadcastTargets = null;
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
}
// ── Event emitter ────────────────────────────────────────────────
@@ -37,10 +43,10 @@ class State {
* @param {Function} callback
*/
on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
}
/**
@@ -49,10 +55,10 @@ class State {
* @param {Function} callback
*/
off(event, callback) {
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
}
/**
@@ -61,16 +67,16 @@ class State {
* @param {*} data
*/
emit(event, data) {
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
}
}
// ── Profiles ─────────────────────────────────────────────────────
@@ -80,8 +86,8 @@ class State {
* @param {Array<Object>} profiles
*/
setProfiles(profiles) {
this.profiles = profiles;
this.emit('profiles:changed', this.profiles);
this.profiles = profiles;
this.emit("profiles:changed", this.profiles);
}
/**
@@ -89,9 +95,9 @@ class State {
* @param {Object} profile
*/
addProfile(profile) {
this.profiles.push(profile);
this.emit('profiles:changed', this.profiles);
this.emit('profile:added', profile);
this.profiles.push(profile);
this.emit("profiles:changed", this.profiles);
this.emit("profile:added", profile);
}
/**
@@ -99,12 +105,12 @@ class State {
* @param {Object} profile
*/
updateProfile(profile) {
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit('profiles:changed', this.profiles);
this.emit('profile:updated', profile);
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit("profiles:changed", this.profiles);
this.emit("profile:updated", profile);
}
/**
@@ -112,9 +118,9 @@ class State {
* @param {string} id
*/
removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit('profiles:changed', this.profiles);
this.emit('profile:removed', id);
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit("profiles:changed", this.profiles);
this.emit("profile:removed", id);
}
/**
@@ -123,7 +129,7 @@ class State {
* @returns {Object|undefined}
*/
getProfile(id) {
return this.profiles.find((p) => p.id === id);
return this.profiles.find((p) => p.id === id);
}
// ── Sessions ─────────────────────────────────────────────────────
@@ -133,9 +139,9 @@ class State {
* @param {Object} sessionInfo
*/
addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit('sessions:changed', this.sessions);
this.emit('session:added', sessionInfo);
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit("sessions:changed", this.sessions);
this.emit("session:added", sessionInfo);
}
/**
@@ -144,12 +150,12 @@ class State {
* @param {Object} updates
*/
updateSession(id, updates) {
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit('sessions:changed', this.sessions);
this.emit('session:updated', existing);
}
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit("sessions:changed", this.sessions);
this.emit("session:updated", existing);
}
}
/**
@@ -157,14 +163,15 @@ class State {
* @param {string} id
*/
removeSession(id) {
this.sessions.delete(id);
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
}
this.emit('sessions:changed', this.sessions);
this.emit('session:removed', id);
this.sessions.delete(id);
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
this.activeSessionId =
remaining.length > 0 ? remaining[remaining.length - 1] : null;
}
this.emit("sessions:changed", this.sessions);
this.emit("session:removed", id);
}
/**
@@ -173,7 +180,7 @@ class State {
* @returns {Object|undefined}
*/
getSession(id) {
return this.sessions.get(id);
return this.sessions.get(id);
}
/**
@@ -181,8 +188,8 @@ class State {
* @param {string|null} id
*/
setActiveSession(id) {
this.activeSessionId = id;
this.emit('session:activated', id);
this.activeSessionId = id;
this.emit("session:activated", id);
}
/**
@@ -190,7 +197,7 @@ class State {
* @returns {Array<Object>}
*/
getAllSessions() {
return [...this.sessions.values()];
return [...this.sessions.values()];
}
// ── Preferences ──────────────────────────────────────────────────
@@ -200,8 +207,8 @@ class State {
* @param {Object} prefs
*/
setPreferences(prefs) {
this.preferences = prefs;
this.emit('preferences:changed', this.preferences);
this.preferences = prefs;
this.emit("preferences:changed", this.preferences);
}
// ── Secret store ─────────────────────────────────────────────────
@@ -211,8 +218,8 @@ class State {
* @param {boolean} unlocked
*/
setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked);
this.secretStoreUnlocked = unlocked;
this.emit("secretStore:changed", unlocked);
}
/**
@@ -220,12 +227,11 @@ class State {
* @param {boolean} initialised
*/
setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised;
this.emit('secretStore:init', initialised);
this.secretStoreInitialised = initialised;
this.emit("secretStore:init", initialised);
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
})();
+460 -342
View File
File diff suppressed because it is too large Load Diff
+1300 -173
View File
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
(function () {
/**
* Nexus Terminal — terminal colour themes.
* Provides a collection of preset xterm.js colour schemes and a manager
* for applying them to terminal instances.
*/
/**
* Preset terminal colour themes.
* Each entry contains the full xterm.js theme object.
*/
const THEMES = {
charcoal: {
label: "Charcoal (Default)",
theme: {
background: "#1c1c1c",
foreground: "#e4e4e4",
cursor: "#4ecdc4",
cursorAccent: "#1c1c1c",
selectionBackground: "rgba(78, 205, 196, 0.3)",
selectionForeground: "#ffffff",
black: "#1c1c1c",
red: "#ef5350",
green: "#66bb6a",
yellow: "#ffa726",
blue: "#42a5f5",
magenta: "#ab47bc",
cyan: "#4ecdc4",
white: "#e4e4e4",
brightBlack: "#555555",
brightRed: "#ff7043",
brightGreen: "#81c784",
brightYellow: "#ffcc02",
brightBlue: "#64b5f6",
brightMagenta: "#ce93d8",
brightCyan: "#80deea",
brightWhite: "#ffffff",
},
},
"solarized-dark": {
label: "Solarized Dark",
theme: {
background: "#002b36",
foreground: "#839496",
cursor: "#93a1a1",
cursorAccent: "#002b36",
selectionBackground: "rgba(147, 161, 161, 0.3)",
selectionForeground: "#fdf6e3",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#586e75",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
monokai: {
label: "Monokai",
theme: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f0",
cursorAccent: "#272822",
selectionBackground: "rgba(73, 72, 62, 0.6)",
selectionForeground: "#f8f8f2",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
nord: {
label: "Nord",
theme: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "rgba(136, 192, 208, 0.3)",
selectionForeground: "#eceff4",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
dracula: {
label: "Dracula",
theme: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "rgba(68, 71, 90, 0.6)",
selectionForeground: "#f8f8f2",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
"one-dark": {
label: "One Dark",
theme: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "rgba(82, 139, 255, 0.3)",
selectionForeground: "#abb2bf",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
"gruvbox-dark": {
label: "Gruvbox Dark",
theme: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "rgba(214, 93, 14, 0.3)",
selectionForeground: "#ebdbb2",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
"tokyo-night": {
label: "Tokyo Night",
theme: {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "rgba(41, 46, 66, 0.9)",
selectionForeground: "#c0caf5",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
"catppuccin-mocha": {
label: "Catppuccin Mocha",
theme: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "rgba(88, 91, 112, 0.5)",
selectionForeground: "#cdd6f4",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
"rose-pine": {
label: "Rosé Pine",
theme: {
background: "#191724",
foreground: "#e0def4",
cursor: "#524f67",
cursorAccent: "#e0def4",
selectionBackground: "rgba(110, 106, 134, 0.4)",
selectionForeground: "#e0def4",
black: "#26233a",
red: "#eb6f92",
green: "#31748f",
yellow: "#f6c177",
blue: "#9ccfd8",
magenta: "#c4a7e7",
cyan: "#ebbcba",
white: "#e0def4",
brightBlack: "#6e6a86",
brightRed: "#eb6f92",
brightGreen: "#31748f",
brightYellow: "#f6c177",
brightBlue: "#9ccfd8",
brightMagenta: "#c4a7e7",
brightCyan: "#ebbcba",
brightWhite: "#e0def4",
},
},
"solarized-light": {
label: "Solarized Light",
theme: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#586e75",
cursorAccent: "#fdf6e3",
selectionBackground: "rgba(7, 54, 66, 0.2)",
selectionForeground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
};
/**
* Returns the theme object for the given theme name.
* Falls back to 'charcoal' if not found.
* @param {string} name
* @returns {Object} xterm.js theme object
*/
function getTerminalTheme(name) {
const entry = THEMES[name || "charcoal"];
return entry ? entry.theme : THEMES.charcoal.theme;
}
/**
* Returns all theme names and labels for building a selector.
* @returns {Array<{value: string, label: string}>}
*/
function listTerminalThemes() {
return Object.entries(THEMES).map(([value, entry]) => ({
value,
label: entry.label,
}));
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
})();
+381
View File
@@ -0,0 +1,381 @@
(function () {
/**
* Nexus Terminal — Trigger Rules Engine.
* Monitors terminal output and fires actions (highlight, notify, sound)
* when patterns match. Rules are stored in localStorage.
*/
const { createElement } = window.NexusTerminal;
const STORAGE_KEY = "nexus:trigger-rules";
class TriggerEngine {
constructor(app) {
this.app = app;
this._rules = this._load();
this._backdrop = null;
// Buffer per session to accumulate partial lines
this._buffers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Checks incoming terminal data against all enabled rules.
* Called from the session:data event handler.
* @param {string} sessionId
* @param {string} text - decoded terminal text
*/
check(sessionId, text) {
if (this._rules.length === 0) return;
// Accumulate text and check line by line
const buf = (this._buffers.get(sessionId) || "") + text;
const lines = buf.split("\n");
// Keep the last incomplete line in the buffer
this._buffers.set(sessionId, lines.pop());
for (const line of lines) {
this._checkLine(sessionId, line);
}
}
/**
* Opens the trigger rules management panel.
*/
open() {
if (this._backdrop) return;
this._render();
}
/**
* Closes the trigger rules panel.
*/
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
/**
* Cleans up session buffer on disconnect.
* @param {string} sessionId
*/
cleanup(sessionId) {
this._buffers.delete(sessionId);
}
// ── Rule matching ────────────────────────────────────────────────
_checkLine(sessionId, line) {
// Strip ANSI escape sequences for matching
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
for (const rule of this._rules) {
if (!rule.enabled) continue;
let matched = false;
if (rule.isRegex) {
try {
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
matched = re.test(clean);
} catch (_) {
// Invalid regex — skip
}
} else {
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
const needle = rule.caseSensitive
? rule.pattern
: rule.pattern.toLowerCase();
matched = haystack.includes(needle);
}
if (!matched) continue;
// Fire actions
if (rule.action === "notify" || rule.action === "both") {
const session = this.app.state.getSession(sessionId);
const label = session
? session.tabName || session.profileName
: sessionId;
this.app.notifications.info(
`[${label}] Trigger: ${rule.name || rule.pattern}`,
);
}
if (rule.action === "highlight" || rule.action === "both") {
// Flash the tab briefly
const tabEl = document.querySelector(
`.tab[data-session-id="${sessionId}"]`,
);
if (tabEl) {
tabEl.classList.add("tab-trigger-flash");
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
}
}
if (rule.action === "sound") {
this._playBeep();
}
}
}
_playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.value = 0.1;
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (_) {
// Audio not available — silent
}
}
// ── Storage ──────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
_save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
} catch (_) {
// localStorage full or unavailable
}
}
// ── UI ───────────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Trigger Rules</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
Rules match against terminal output and fire actions when a pattern is detected.
</p>
<div class="trigger-toolbar">
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
</div>
<div class="trigger-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".trigger-add-btn")
.addEventListener("click", () => this._addRule());
this._renderList();
}
_renderList() {
const list = this._backdrop.querySelector(".trigger-list");
if (!list) return;
list.innerHTML = "";
if (this._rules.length === 0) {
list.innerHTML =
'<div class="trigger-empty">No trigger rules defined</div>';
return;
}
for (let i = 0; i < this._rules.length; i++) {
list.appendChild(this._renderRuleRow(this._rules[i], i));
}
}
_renderRuleRow(rule, index) {
const row = createElement("div", "trigger-row");
const toggle = createElement("input", "trigger-toggle");
toggle.type = "checkbox";
toggle.checked = rule.enabled;
toggle.addEventListener("change", () => {
this._rules[index].enabled = toggle.checked;
this._save();
});
const info = createElement("div", "trigger-info");
const name = createElement("div", "trigger-name", {
textContent: rule.name || rule.pattern,
});
const meta = createElement("div", "trigger-meta", {
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
});
info.appendChild(name);
info.appendChild(meta);
const pattern = createElement("code", "trigger-pattern", {
textContent: rule.pattern,
});
const actions = createElement("div", "trigger-actions");
const editBtn = createElement("button", "btn btn-xs", {
textContent: "Edit",
});
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
editBtn.addEventListener("click", () => this._editRule(index));
const delBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "✕",
});
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
delBtn.addEventListener("click", () => {
this._rules.splice(index, 1);
this._save();
this._renderList();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(toggle);
row.appendChild(info);
row.appendChild(pattern);
row.appendChild(actions);
return row;
}
_addRule() {
this._showRuleForm(null, (rule) => {
this._rules.push(rule);
this._save();
this._renderList();
});
}
_editRule(index) {
this._showRuleForm(this._rules[index], (rule) => {
this._rules[index] = rule;
this._save();
this._renderList();
});
}
_showRuleForm(existing, onSave) {
const list = this._backdrop.querySelector(".trigger-list");
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
if (!list) return;
// Hide list and toolbar
list.style.display = "none";
if (toolbar) toolbar.style.display = "none";
const form = createElement("div", "trigger-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Name (optional)</label>
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
</div>
<div class="form-group">
<label class="form-label">Pattern</label>
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Match Type</label>
<select class="form-input trigger-type-select">
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Action</label>
<select class="form-input trigger-action-select">
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
Case-sensitive
</label>
</div>
<div class="trigger-form-actions">
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
</div>
`;
list.parentNode.insertBefore(form, list);
form
.querySelector(".trigger-cancel-btn")
.addEventListener("click", () => {
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
});
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
const pattern = form
.querySelector(".trigger-pattern-input")
.value.trim();
if (!pattern) {
this.app.notifications.error("Pattern is required");
return;
}
const rule = {
name: form.querySelector(".trigger-name-input").value.trim(),
pattern: pattern,
isRegex: form.querySelector(".trigger-type-select").value === "regex",
action: form.querySelector(".trigger-action-select").value,
caseSensitive: form.querySelector(".trigger-case-check").checked,
enabled: existing ? existing.enabled : true,
};
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
onSave(rule);
});
form.querySelector(".trigger-pattern-input").focus();
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TriggerEngine = TriggerEngine;
})();
+262 -187
View File
@@ -1,120 +1,132 @@
(function() {
/**
* Nexus Terminal — utility functions.
* General-purpose helpers used throughout the application.
*/
(function () {
/**
* Nexus Terminal — utility functions.
* General-purpose helpers used throughout the application.
*/
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
function $(selector) {
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
function $(selector) {
return document.querySelector(selector);
}
}
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
function $$(selector) {
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
function $$(selector) {
return document.querySelectorAll(selector);
}
}
/**
* Creates a DOM element with optional class name and attributes.
* @param {string} tag - HTML tag name.
* @param {string} [className] - Space-separated class names.
* @param {Object} [attrs] - Attribute key/value pairs.
* @returns {HTMLElement}
*/
function createElement(tag, className, attrs = {}) {
/**
* Creates a DOM element with optional class name and attributes.
* @param {string} tag - HTML tag name.
* @param {string} [className] - Space-separated class names.
* @param {Object} [attrs] - Attribute key/value pairs.
* @returns {HTMLElement}
*/
function createElement(tag, className, attrs = {}) {
const el = document.createElement(tag);
if (className) {
el.className = className;
el.className = className;
}
for (const [key, value] of Object.entries(attrs)) {
if (key === 'textContent') {
el.textContent = value;
} else if (key === 'innerHTML') {
el.innerHTML = value;
} else if (key === 'dataset') {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
if (key === "textContent") {
el.textContent = value;
} else if (key === "innerHTML") {
el.innerHTML = value;
} else if (key === "dataset") {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
return el;
}
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - Raw string.
* @returns {string} Escaped string safe for innerHTML.
*/
function escapeHtml(str) {
if (!str) return '';
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - Raw string.
* @returns {string} Escaped string safe for innerHTML.
*/
function escapeHtml(str) {
if (!str) return "";
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
return str.replace(/[&<>"']/g, (ch) => map[ch]);
}
}
/**
* Formats a Date (or ISO string) into a concise timestamp.
* @param {Date|string} date
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/
function formatTimestamp(date) {
if (!date) return '';
/**
* Formats a Date (or ISO string) into a concise timestamp.
* @param {Date|string} date
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/
function formatTimestamp(date) {
if (!date) return "";
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
if (isNaN(d.getTime())) return "";
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const hours = String(d.getHours()).padStart(2, '0');
const mins = String(d.getMinutes()).padStart(2, '0');
const secs = String(d.getSeconds()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
const secs = String(d.getSeconds()).padStart(2, "0");
if (isToday) {
return `${hours}:${mins}:${secs}`;
return `${hours}:${mins}:${secs}`;
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
}
}
/**
* Returns a debounced version of the given function.
* @param {Function} fn
* @param {number} ms - Delay in milliseconds.
* @returns {Function}
*/
function debounce(fn, ms) {
/**
* Returns a debounced version of the given function.
* @param {Function} fn
* @param {number} ms - Delay in milliseconds.
* @returns {Function}
*/
function debounce(fn, ms) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
}
/**
* Performs a simple fuzzy match — checks whether every character in
* the query appears in order within the text (case-insensitive).
* @param {string} text
* @param {string} query
* @returns {{ match: boolean, score: number }}
*/
function fuzzyMatch(text, query) {
/**
* Performs a simple fuzzy match — checks whether every character in
* the query appears in order within the text (case-insensitive).
* @param {string} text
* @param {string} query
* @returns {{ match: boolean, score: number }}
*/
function fuzzyMatch(text, query) {
if (!query) return { match: true, score: 1 };
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
@@ -124,139 +136,202 @@ function fuzzyMatch(text, query) {
let consecutiveBonus = 0;
for (let qi = 0; qi < lowerQuery.length; qi++) {
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
}
return { match: true, score };
}
}
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
function base64Encode(str) {
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
function base64Encode(str) {
return btoa(str);
}
}
/**
* Base64-decodes a string.
* @param {string} str
* @returns {string}
*/
function base64Decode(str) {
/**
* Base64-decodes a string.
* @param {string} str
* @returns {string}
*/
function base64Decode(str) {
return atob(str);
}
}
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
function generateId() {
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
function generateId() {
return crypto.randomUUID();
}
}
/**
* Formats a byte count into a human-friendly string.
* @param {number} bytes
* @returns {string} e.g. "1.23 KB"
*/
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
/**
* Formats a byte count into a human-friendly string.
* @param {number} bytes
* @returns {string} e.g. "1.23 KB"
*/
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
}
/**
* Truncates a string to the given length, appending an ellipsis if needed.
* @param {string} str
* @param {number} len
* @returns {string}
*/
function truncate(str, len) {
if (!str || str.length <= len) return str || '';
return str.slice(0, len) + '…';
}
/**
* Truncates a string to the given length, appending an ellipsis if needed.
* @param {string} str
* @param {number} len
* @returns {string}
*/
function truncate(str, len) {
if (!str || str.length <= len) return str || "";
return str.slice(0, len) + "…";
}
/**
* Removes any open context menus from the DOM.
*/
function dismissContextMenus() {
document.querySelectorAll('.context-menu').forEach((m) => m.remove());
}
/**
* Removes any open context menus from the DOM.
*/
function dismissContextMenus() {
document.querySelectorAll(".context-menu").forEach((m) => m.remove());
}
/**
* Returns the protocol badge colour class.
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
* @returns {string}
*/
function protocolColour(protocol) {
/**
* Returns the protocol badge colour class.
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
* @returns {string}
*/
function protocolColour(protocol) {
switch (protocol) {
case 'ssh': return 'proto-ssh';
case 'telnet': return 'proto-telnet';
case 'raw': return 'proto-raw';
case 'github': return 'proto-github';
default: return '';
case "ssh":
return "proto-ssh";
case "telnet":
return "proto-telnet";
case "raw":
return "proto-raw";
case "github":
return "proto-github";
case "vnc":
return "proto-vnc";
case "local":
return "proto-local";
default:
return "";
}
}
}
/**
* Returns a human-friendly relative time string for an ISO date.
* @param {string} isoDate - ISO 8601 date string.
* @returns {string} e.g. "3 hours ago", "2 days ago".
*/
function relativeTime(isoDate) {
if (!isoDate) return '';
/**
* Returns a human-friendly relative time string for an ISO date.
* @param {string} isoDate - ISO 8601 date string.
* @returns {string} e.g. "3 hours ago", "2 days ago".
*/
function relativeTime(isoDate) {
if (!isoDate) return "";
const d = new Date(isoDate);
if (isNaN(d.getTime())) return '';
if (isNaN(d.getTime())) return "";
const now = Date.now();
const diff = now - d.getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
if (months < 12)
return months === 1 ? "1 month ago" : `${months} months ago`;
const years = Math.floor(months / 12);
return years === 1 ? '1 year ago' : `${years} years ago`;
}
return years === 1 ? "1 year ago" : `${years} years ago`;
}
/**
* Returns a human-friendly label for a session state.
* @param {string} state
* @returns {string}
*/
function stateLabel(state) {
/**
* Returns a human-friendly label for a session state.
* @param {string} state
* @returns {string}
*/
function stateLabel(state) {
switch (state) {
case 'connecting': return 'Connecting';
case 'connected': return 'Connected';
case 'disconnected': return 'Disconnected';
case 'error': return 'Error';
default: return state || 'Unknown';
case "connecting":
return "Connecting";
case "connected":
return "Connected";
case "disconnected":
return "Disconnected";
case "reconnecting":
return "Reconnecting";
case "error":
return "Error";
default:
return state || "Unknown";
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime });
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, {
$,
$$,
createElement,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
base64Decode,
generateId,
formatBytes,
truncate,
dismissContextMenus,
protocolColour,
stateLabel,
relativeTime,
});
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
function disableAutoCapitalise(el) {
el.setAttribute("autocapitalize", "none");
el.setAttribute("autocorrect", "off");
el.setAttribute("spellcheck", "false");
}
// Apply to existing inputs
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
// Observe for dynamically added inputs
var inputObserver = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var nodes = mutations[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
var node = nodes[j];
if (node.nodeType !== 1) continue;
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
disableAutoCapitalise(node);
}
if (node.querySelectorAll) {
node
.querySelectorAll("input, textarea")
.forEach(disableAutoCapitalise);
}
}
}
});
inputObserver.observe(document.body, { childList: true, subtree: true });
})();
+295
View File
@@ -0,0 +1,295 @@
(function () {
/**
* Nexus Terminal — VNC viewer.
* Renders a noVNC canvas inside a session panel, connecting to the local
* WebSocket proxy started by the Go backend.
*/
const { createElement, $ } = window.NexusTerminal;
class VNCViewer {
constructor() {
/** @type {Map<string, VNCViewerInstance>} sessionID → viewer */
this._viewers = new Map();
}
/**
* Creates a VNC viewer panel for a session inside the given container.
* @param {string} sessionID
* @param {HTMLElement} container - The .session-panel element.
* @param {object} profile - The profile object with VNC config.
*/
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID)) return;
if (!container) return;
const viewer = new VNCViewerInstance(sessionID, container, profile);
this._viewers.set(sessionID, viewer);
}
/**
* Connects the viewer to the WebSocket URL from the backend.
* @param {string} sessionID
* @param {string} wsURL
*/
connect(sessionID, wsURL) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.connect(wsURL);
}
}
/**
* Destroys a viewer for the given session.
* @param {string} sessionID
*/
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.destroy();
this._viewers.delete(sessionID);
}
}
/**
* Resizes the VNC display for the given session.
* @param {string} sessionID
*/
resize(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.resize();
}
}
}
class VNCViewerInstance {
/**
* @param {string} sessionID
* @param {HTMLElement} container
* @param {object} profile
*/
constructor(sessionID, container, profile) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile;
this.rfb = null;
this._buildUI();
}
_buildUI() {
this.container.innerHTML = "";
this.container.classList.add("vnc-viewer-panel");
// Toolbar
this.toolbar = createElement("div", "vnc-toolbar");
const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", {
textContent: "Ctrl+Alt+Del",
title: "Send Ctrl+Alt+Del",
});
ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel());
const clipboardBtn = createElement(
"button",
"btn btn-sm btn-vnc-action",
{
textContent: "Clipboard",
title: "Sync clipboard",
},
);
clipboardBtn.addEventListener("click", () => this._syncClipboard());
const fullscreenBtn = createElement(
"button",
"btn btn-sm btn-vnc-action",
{
textContent: "Fullscreen",
title: "Toggle fullscreen",
},
);
fullscreenBtn.addEventListener("click", () => this._toggleFullscreen());
this.statusEl = createElement("span", "vnc-status", {
textContent: "Connecting…",
});
this.toolbar.appendChild(this.statusEl);
this.toolbar.appendChild(ctrlAltDel);
this.toolbar.appendChild(clipboardBtn);
this.toolbar.appendChild(fullscreenBtn);
// VNC display container
this.displayEl = createElement("div", "vnc-display");
this.container.appendChild(this.toolbar);
this.container.appendChild(this.displayEl);
}
/**
* Connects to the VNC WebSocket proxy.
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
*/
connect(wsURL) {
if (!window.noVNC || !window.noVNC.RFB) {
this.statusEl.textContent = "Error: noVNC not loaded";
this.statusEl.classList.add("vnc-status-error");
return;
}
try {
const vncConfig = this.profile.vnc || {};
this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, {
shared: vncConfig.sharedSession !== false,
credentials: {},
});
this.rfb.viewOnly = !!vncConfig.viewOnly;
this.rfb.scaleViewport = vncConfig.scaleToFit !== false;
this.rfb.resizeSession = false;
this.rfb.clipViewport = false;
this.rfb.qualityLevel = vncConfig.quality || 6;
this.rfb.compressionLevel = 2;
// Event handlers
this.rfb.addEventListener("connect", () => {
this.statusEl.textContent = "Connected";
this.statusEl.classList.remove("vnc-status-error");
this.statusEl.classList.add("vnc-status-connected");
});
this.rfb.addEventListener("disconnect", (ev) => {
const clean = ev.detail.clean;
this.statusEl.textContent = clean
? "Disconnected"
: "Connection lost";
this.statusEl.classList.remove("vnc-status-connected");
if (!clean) {
this.statusEl.classList.add("vnc-status-error");
}
});
this.rfb.addEventListener("credentialsrequired", () => {
this.statusEl.textContent = "Credentials required";
// TODO: prompt for VNC password via modal
this._promptCredentials();
});
this.rfb.addEventListener("desktopname", (ev) => {
this.statusEl.textContent = "Connected — " + ev.detail.name;
});
} catch (err) {
this.statusEl.textContent = "Error: " + err.message;
this.statusEl.classList.add("vnc-status-error");
}
}
_sendCtrlAltDel() {
if (this.rfb) {
this.rfb.sendCtrlAltDel();
}
}
async _syncClipboard() {
if (!this.rfb) return;
try {
const text = await navigator.clipboard.readText();
if (text) {
this.rfb.clipboardPasteFrom(text);
}
} catch (_) {
// Clipboard API may not be available in WKWebView
}
}
_toggleFullscreen() {
if (!document.fullscreenElement) {
this.container.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
_promptCredentials() {
// Build a simple modal for VNC password
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">VNC Password</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
</div>
`;
document.body.appendChild(backdrop);
document.body.appendChild(modal);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector("#vnc-password-input");
const submit = modal.querySelector("#vnc-password-submit");
const cancel = modal.querySelector("#vnc-password-cancel");
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
backdrop.remove();
modal.remove();
}, 200);
};
submit.addEventListener("click", () => {
const pw = input.value;
close();
if (this.rfb && pw) {
this.rfb.sendCredentials({ password: pw });
}
});
cancel.addEventListener("click", () => {
close();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit.click();
if (e.key === "Escape") cancel.click();
});
setTimeout(() => input.focus(), 50);
}
resize() {
// noVNC handles resizing internally via ResizeObserver on its canvas,
// but we can trigger a re-scale if needed.
if (this.rfb && this.rfb.scaleViewport) {
this.rfb.scaleViewport = true;
}
}
destroy() {
if (this.rfb) {
this.rfb.disconnect();
this.rfb = null;
}
this.container.innerHTML = "";
this.container.classList.remove("vnc-viewer-panel");
}
}
// Export to global namespace.
window.NexusTerminal.VNCViewer = new VNCViewer();
})();
+91
View File
@@ -1,42 +1,91 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
import {api} from '../models';
import {context} from '../models';
export function BrowseForShell():Promise<string>;
export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>;
export function ClearSessionHistory(arg1:string):Promise<void>;
export function CloseAllSessions():Promise<void>;
export function ConnectLocal():Promise<models.SessionInfo>;
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
export function CreateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
export function DeleteKnownHost(arg1:string):Promise<void>;
export function DeleteProfile(arg1:string):Promise<void>;
export function DeleteProfileSecrets(arg1:string):Promise<void>;
export function DeleteRecording(arg1:string):Promise<void>;
export function DeleteSSHKey(arg1:string):Promise<void>;
export function DeleteSnippet(arg1:string):Promise<void>;
export function DeleteTOTPSecret(arg1:string):Promise<void>;
export function DisconnectSession(arg1:string):Promise<void>;
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
export function ExportProfiles(arg1:boolean):Promise<string>;
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function GetAppInfo():Promise<Record<string, string>>;
export function GetDefaultShell():Promise<string>;
export function GetPreferences():Promise<models.AppPreferences>;
export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetSSHPublicKey(arg1:string):Promise<string>;
export function GetSessionCwd(arg1:string):Promise<string>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>;
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
export function ImportSSHConfig(arg1:string,arg2:string):Promise<number>;
export function InitialiseSecretStore(arg1:string):Promise<void>;
export function IsRecording(arg1:string):Promise<boolean>;
export function IsSecretStoreInitialised():Promise<boolean>;
export function IsSecretStoreUnlocked():Promise<boolean>;
@@ -47,6 +96,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,14 +106,24 @@ export function ListProfilesByProtocol(arg1:string):Promise<Array<models.Profile
export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>;
export function ListSnippets():Promise<Array<models.Snippet>>;
export function LockSecretStore():Promise<void>;
export function NewProfile(arg1:string):Promise<models.Profile>;
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
export function PingSession(arg1:string):Promise<number>;
export function ReconnectSession(arg1:string):Promise<void>;
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
@@ -73,6 +134,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 +170,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>;
+180
View File
@@ -2,6 +2,18 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function BrowseForShell() {
return window['go']['api']['Service']['BrowseForShell']();
}
export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1);
}
export function CheckForUpdates() {
return window['go']['api']['Service']['CheckForUpdates']();
}
export function ClearSessionHistory(arg1) {
return window['go']['api']['Service']['ClearSessionHistory'](arg1);
}
@@ -10,6 +22,10 @@ export function CloseAllSessions() {
return window['go']['api']['Service']['CloseAllSessions']();
}
export function ConnectLocal() {
return window['go']['api']['Service']['ConnectLocal']();
}
export function ConnectQuick(arg1) {
return window['go']['api']['Service']['ConnectQuick'](arg1);
}
@@ -22,6 +38,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 +54,30 @@ export function DeleteProfileSecrets(arg1) {
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
}
export function DeleteRecording(arg1) {
return window['go']['api']['Service']['DeleteRecording'](arg1);
}
export function DeleteSSHKey(arg1) {
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
}
export function DeleteSnippet(arg1) {
return window['go']['api']['Service']['DeleteSnippet'](arg1);
}
export function DeleteTOTPSecret(arg1) {
return window['go']['api']['Service']['DeleteTOTPSecret'](arg1);
}
export function DisconnectSession(arg1) {
return window['go']['api']['Service']['DisconnectSession'](arg1);
}
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
}
export function DuplicateProfile(arg1) {
return window['go']['api']['Service']['DuplicateProfile'](arg1);
}
@@ -46,10 +86,30 @@ export function ExportProfiles(arg1) {
return window['go']['api']['Service']['ExportProfiles'](arg1);
}
export function ExportProfilesEncrypted(arg1, arg2) {
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
}
export function FileExists(arg1) {
return window['go']['api']['Service']['FileExists'](arg1);
}
export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
}
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
}
export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo']();
}
export function GetDefaultShell() {
return window['go']['api']['Service']['GetDefaultShell']();
}
export function GetPreferences() {
return window['go']['api']['Service']['GetPreferences']();
}
@@ -58,18 +118,54 @@ export function GetProfile(arg1) {
return window['go']['api']['Service']['GetProfile'](arg1);
}
export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1);
}
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1);
}
export function GetSessionHistory(arg1) {
return window['go']['api']['Service']['GetSessionHistory'](arg1);
}
export function GetTOTPCode(arg1) {
return window['go']['api']['Service']['GetTOTPCode'](arg1);
}
export function GetVNCWebSocketURL(arg1) {
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
}
export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
}
export function ImportProfiles(arg1, arg2) {
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
}
export function ImportProfilesEncrypted(arg1, arg2) {
return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2);
}
export function ImportSSHConfig(arg1, arg2) {
return window['go']['api']['Service']['ImportSSHConfig'](arg1, arg2);
}
export function InitialiseSecretStore(arg1) {
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
}
export function IsRecording(arg1) {
return window['go']['api']['Service']['IsRecording'](arg1);
}
export function IsSecretStoreInitialised() {
return window['go']['api']['Service']['IsSecretStoreInitialised']();
}
@@ -90,6 +186,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 +206,26 @@ export function ListProfilesByTag(arg1) {
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
}
export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings']();
}
export function ListSSHKeyPairs() {
return window['go']['api']['Service']['ListSSHKeyPairs']();
}
export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys']();
}
export function ListSessions() {
return window['go']['api']['Service']['ListSessions']();
}
export function ListSnippets() {
return window['go']['api']['Service']['ListSnippets']();
}
export function LockSecretStore() {
return window['go']['api']['Service']['LockSecretStore']();
}
@@ -122,6 +238,10 @@ export function PinSession(arg1, arg2) {
return window['go']['api']['Service']['PinSession'](arg1, arg2);
}
export function PingSession(arg1) {
return window['go']['api']['Service']['PingSession'](arg1);
}
export function ReconnectSession(arg1) {
return window['go']['api']['Service']['ReconnectSession'](arg1);
}
@@ -142,6 +262,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 +334,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 +366,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);
}
+209 -74
View File
@@ -1,7 +1,33 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
this.privateKeyPath = source["privateKeyPath"];
}
}
}
export namespace models {
export class AppPreferences {
theme: string;
terminalTheme: string;
defaultProtocol: string;
confirmOnClose: boolean;
confirmOnDisconnect: boolean;
@@ -11,6 +37,8 @@ export namespace models {
defaultFontSize: number;
defaultScrollback: number;
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) {
return new AppPreferences(source);
@@ -19,6 +47,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 +57,13 @@ export namespace models {
this.defaultFontSize = source["defaultFontSize"];
this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
}
}
export class AuditEntry {
id: string;
// Go type: time
timestamp: any;
timestamp: string;
action: string;
profileId?: string;
sessionId?: string;
@@ -46,30 +76,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 +105,9 @@ export namespace models {
owner: string;
description: string;
htmlURL: string;
defaultBranch: string;
latestCommitter: string;
latestCommitAt: string;
private: boolean;
fork: boolean;
archived: boolean;
@@ -112,6 +127,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 +168,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 +184,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 +223,72 @@ export namespace models {
this.format = source["format"];
}
}
export class PortForward {
id: string;
direction: string;
bindHost: string;
bindPort: number;
destHost: string;
destPort: number;
enabled: boolean;
label: string;
static createFrom(source: any = {}) {
return new PortForward(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.direction = source["direction"];
this.bindHost = source["bindHost"];
this.bindPort = source["bindPort"];
this.destHost = source["destHost"];
this.destPort = source["destPort"];
this.enabled = source["enabled"];
this.label = source["label"];
}
}
export class VNCConfig {
vncHost: string;
vncPort: number;
secretMode: string;
viewOnly: boolean;
scaleToFit: boolean;
quality: number;
sharedSession: boolean;
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
static createFrom(source: any = {}) {
return new VNCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.vncHost = source["vncHost"];
this.vncPort = source["vncPort"];
this.secretMode = source["secretMode"];
this.viewOnly = source["viewOnly"];
this.scaleToFit = source["scaleToFit"];
this.quality = source["quality"];
this.sharedSession = source["sharedSession"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
}
}
export class RAWConfig {
autoReconnect: boolean;
lineEnding: string;
@@ -256,6 +336,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 +352,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 +385,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 +416,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 +464,11 @@ export namespace models {
environment: string;
notes: string;
favourite: boolean;
// Go type: time
lastConnected?: any;
lastConnected?: string;
connectTimeout: number;
readTimeout: number;
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
createdAt: string;
updatedAt: string;
terminalFontFamily: string;
terminalFontSize: number;
terminalScrollback: number;
@@ -352,6 +477,7 @@ export namespace models {
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -371,11 +497,11 @@ export namespace models {
this.environment = source["environment"];
this.notes = source["notes"];
this.favourite = source["favourite"];
this.lastConnected = this.convertValues(source["lastConnected"], null);
this.lastConnected = source["lastConnected"];
this.connectTimeout = source["connectTimeout"];
this.readTimeout = source["readTimeout"];
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.terminalFontFamily = source["terminalFontFamily"];
this.terminalFontSize = source["terminalFontSize"];
this.terminalScrollback = source["terminalScrollback"];
@@ -384,6 +510,7 @@ export namespace models {
this.telnet = this.convertValues(source["telnet"], TelnetConfig);
this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -416,8 +543,7 @@ export namespace models {
state: string;
tabName: string;
pinned: boolean;
// Go type: time
connectedAt: any;
connectedAt: string;
error?: string;
static createFrom(source: any = {}) {
@@ -435,28 +561,37 @@ export namespace models {
this.state = source["state"];
this.tabName = source["tabName"];
this.pinned = source["pinned"];
this.connectedAt = this.convertValues(source["connectedAt"], null);
this.connectedAt = source["connectedAt"];
this.error = source["error"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Snippet {
id: string;
name: string;
command: string;
description: string;
folder: string;
tags: string[];
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new Snippet(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.command = source["command"];
this.description = source["description"];
this.folder = source["folder"];
this.tags = source["tags"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
}
+23 -19
View File
@@ -1,42 +1,46 @@
module github.com/copilot-ssh-client/app
module github.com/andydixon/nexus
go 1.22.0
go 1.25.0
require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.44
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.33.0
golang.org/x/text v0.22.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/text v0.37.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)
+50 -41
View File
@@ -1,27 +1,33 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
@@ -35,61 +41,64 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
+963 -6
View File
File diff suppressed because it is too large Load Diff
+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"
)
// ---------------------------------------------------------------------------
+190
View File
@@ -0,0 +1,190 @@
// Package localshell provides a local PTY-based terminal session.
// It spawns a shell process and bridges I/O for the frontend terminal.
package localshell
import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty"
)
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
func DefaultShell() string {
if runtime.GOOS == "windows" {
// Prefer PowerShell if available, else cmd.exe.
if ps, err := exec.LookPath("powershell.exe"); err == nil {
return ps
}
return "cmd.exe"
}
// Unix: use SHELL env var, fallback to /bin/sh.
if sh := os.Getenv("SHELL"); sh != "" {
return sh
}
return "/bin/sh"
}
// NewClient creates a new local shell client.
func NewClient(cfg Config) *Client {
shell := cfg.Shell
if shell == "" {
shell = DefaultShell()
}
cols := cfg.Cols
if cols == 0 {
cols = 80
}
rows := cfg.Rows
if rows == 0 {
rows = 24
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
// Start spawns the shell process and begins reading output.
func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = os.Environ()
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
Rows: c.rows,
})
if err != nil {
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
}
c.cmd = cmd
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
// Wait for process exit.
go func() {
_ = cmd.Wait()
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
c.onClose()
}
}()
return nil
}
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return fmt.Errorf("shell not running")
}
_, err := ptmx.Write(data)
return err
}
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return nil
}
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
if err != nil {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
c.onError(err)
}
return
}
}
}
+17
View File
@@ -12,6 +12,7 @@ const (
DefaultTelnetPort = 23
DefaultRAWPort = 23
DefaultGitHubPort = 443
DefaultVNCPort = 5900
)
// Default timeout values (in seconds).
@@ -39,6 +40,7 @@ const (
func DefaultAppPreferences() AppPreferences {
return AppPreferences{
Theme: "dark",
TerminalTheme: "charcoal",
DefaultProtocol: string(ProtocolSSH),
ConfirmOnClose: true,
ConfirmOnDisconnect: true,
@@ -48,6 +50,8 @@ func DefaultAppPreferences() AppPreferences {
DefaultFontSize: DefaultFontSize,
DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false,
LocalShell: "", // empty means use system default
}
}
@@ -80,6 +84,7 @@ func NewProfile(protocol Protocol) Profile {
KeepaliveInterval: DefaultKeepaliveInterval,
KeepaliveCountMax: DefaultKeepaliveCountMax,
JumpHosts: []JumpHost{},
PortForwards: []PortForward{},
EnvVars: map[string]string{},
}
case ProtocolTelnet:
@@ -105,6 +110,18 @@ func NewProfile(protocol Protocol) Profile {
SecretMode: SecretModeSaved,
RefreshIntervalSecs: 300,
}
case ProtocolVNC:
p.Port = DefaultVNCPort
p.VNC = &VNCConfig{
VNCPort: DefaultVNCPort,
SecretMode: SecretModeSaved,
ScaleToFit: true,
SharedSession: true,
Quality: 6,
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
}
return p
+113 -13
View File
@@ -12,6 +12,8 @@ const (
ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
ProtocolLocal Protocol = "local"
)
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -57,11 +59,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"`
@@ -80,6 +82,9 @@ type Profile struct {
// GitHub-specific configuration
GitHub *GitHubConfig `json:"github,omitempty"`
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
}
// SSHConfig holds SSH-specific settings for a connection profile.
@@ -93,11 +98,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 +132,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.
@@ -150,6 +197,40 @@ type GitHubConfig struct {
RefreshIntervalSecs int `json:"refreshIntervalSecs"`
}
// VNCConfig holds VNC-specific settings for a connection profile.
type VNCConfig struct {
// VNCHost is the VNC server address (defaults to profile.Host if empty).
VNCHost string `json:"vncHost"`
// VNCPort is the VNC server port (5900 + display number, default 5900).
VNCPort int `json:"vncPort"`
// SecretMode controls how the VNC password is stored.
SecretMode SecretMode `json:"secretMode"`
// ViewOnly disables sending keyboard and mouse events to the server.
ViewOnly bool `json:"viewOnly"`
// ScaleToFit scales the remote framebuffer to fit the panel.
ScaleToFit bool `json:"scaleToFit"`
// Quality controls JPEG compression quality (1-9, 0 = default).
Quality int `json:"quality"`
// SharedSession allows other VNC viewers to remain connected.
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct {
FullName string `json:"fullName"`
@@ -157,6 +238,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 +257,7 @@ const (
SessionConnecting SessionState = "connecting"
SessionConnected SessionState = "connected"
SessionDisconnected SessionState = "disconnected"
SessionReconnecting SessionState = "reconnecting"
SessionError SessionState = "error"
)
@@ -187,7 +272,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 +284,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 +302,28 @@ type AppPreferences struct {
DefaultFontSize int `json:"defaultFontSize"`
DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
}
// AuditEntry records an auditable action within the application.
type AuditEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
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
}

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