diff --git a/README.md b/README.md index af9c7f3..2cb110e 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,33 @@ # Nexus Terminal -**A cross-platform desktop client for SSH, Telnet, RAW TCP, and GitHub — with encrypted credential storage and a dark Material-inspired interface.** +**A desktop client for SSH, Telnet, RAW TCP, and GitHub — with secure credential handling and a focused, multi-session workflow.** -Built with [Wails v2](https://wails.io/) (Go backend, HTML/CSS/JS frontend, [xterm.js](https://xtermjs.org/) terminal emulator). +Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence. ---- +## Highlights -### Feature Highlights +- SSH, Telnet, RAW TCP, and GitHub sessions in one app +- Tabbed sessions with split panes, pinning, and reordering +- SFTP browser with drag-and-drop uploads and a directory picker +- Broadcast input, snippets, and terminal search +- Session recording and playback +- Secure credential storage with OS keychain and encrypted vault fallback -- ✅ SSH with password, public key, keyboard-interactive, SSH agent, and TOTP/2FA -- ✅ Telnet with option negotiation, encoding, and local echo -- ✅ RAW TCP with text, hex, and split views -- ✅ GitHub repository browser grouped by organisation -- ✅ Tabbed multi-session with split panes, pinning, and reordering -- ✅ SFTP file browser overlay for SSH sessions -- ✅ Session recording and playback (asciicast v2) -- ✅ Encrypted vault (AES-256-GCM + Argon2id) and OS keychain integration -- ✅ Jump hosts, port forwarding, and SSH agent forwarding -- ✅ SOCKS5 and HTTP CONNECT proxy support for SSH -- ✅ Auto-reconnect with exponential backoff -- ✅ Auto-login scripts (expect-style send/wait sequences) -- ✅ Broadcast input — type into multiple sessions at once -- ✅ Snippet/command library for frequently-used commands -- ✅ Terminal search with regex and case-sensitivity toggles -- ✅ Right-click context menu on terminal -- ✅ Quick connect without saving a full profile -- ✅ OpenSSH `~/.ssh/config` import -- ✅ Encrypted profile export/import (`.nexus` format) -- ✅ 8 built-in colour themes and command palette -- ✅ Automatic update checking via GitHub Releases +## Quick start ---- +1. Launch Nexus Terminal. +2. Click **New** in the sidebar and create a connection profile. +3. Click **Connect** to open a session in a new tab. -## Table of Contents +Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connect. -1. [Getting Started](#getting-started) -2. [Keyboard Shortcuts](#keyboard-shortcuts) -3. [Connection Types](#connection-types) -4. [Power Features](#power-features) -5. [Security](#security) -6. [Architecture](#architecture) -7. [Building & Testing](#building--testing) -8. [Data Locations](#data-locations) -9. [Troubleshooting](#troubleshooting) -10. [Licence](#licence) +## 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) -## Getting Started - -### What You'll Need - -| Tool | Version | Where to get it | -| --------- | ------- | ---------------------------------------------------------- | -| Go | 1.23+ | [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` | - -> **Linux users:** you'll also need GTK and WebKit system libraries. Run `wails doctor` to check your setup. - -### Clone and Run - -```bash -git clone https://github.com/andydixon/nexus.git -cd nexus -make deps # install Go modules and npm packages -make dev # launch with hot reload -``` - -The first build may take a minute or two while dependencies are fetched. Once it's running you'll see the Nexus Terminal window appear — ready to create your first connection profile. - -### Quick Connect - -If you just want to connect to a server without setting up a full profile, press **Cmd+Shift+Q** (macOS) or **Ctrl+Shift+Q** (Windows/Linux). Choose a protocol (SSH, Telnet, or RAW), enter the host and port, and you're connected immediately. The session won't be saved as a profile unless you choose to save it afterwards. - ---- - -## Keyboard Shortcuts +## Keyboard shortcuts All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux. @@ -97,285 +48,3 @@ All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux. | Cmd + Shift + S | Open snippet library | | Cmd + Shift + Q | Quick connect | | F11 | Toggle fullscreen (Windows/Linux) | - ---- - -## Connection Types - -### SSH - -Full-featured SSH client with support for: - -- **Authentication** — password, public key, keyboard-interactive, SSH agent, and agent forwarding -- **TOTP/2FA** — auto-fill time-based one-time passwords during keyboard-interactive auth (configure a TOTP secret in the profile editor) -- **Jump hosts** — connect through one or more bastion servers (multi-hop) -- **Port forwarding** — local, remote, and dynamic (SOCKS) tunnels -- **Proxy support** — route connections through SOCKS5 or HTTP CONNECT proxies (with optional authentication) -- **SFTP** — built-in file browser that slides open as a right-side overlay panel (click the 📁 icon on the tab) -- **Auto-login scripts** — define expect-style send/wait sequences to automate login flows after connecting -- **Auto-reconnect** — automatically reconnect with exponential backoff when the connection drops unexpectedly -- **Host key verification** — four policies: strict, ask, accept-new, and insecure -- **Session recording** — capture terminal output to asciicast v2 files for later playback -- **OpenSSH config import** — reads your existing `~/.ssh/config` to create connection profiles - -### Telnet - -- Option negotiation, configurable character encoding (UTF-8 and others) -- Local echo and line mode settings -- Configurable line endings -- Auto-login scripts (expect-style send/wait sequences) -- Auto-reconnect with exponential backoff - -### RAW TCP - -- Binary-safe connection with three view modes: text, hex, and split (text + hex side by side) -- Payload presets for quick sending of common data -- Configurable character encoding and line endings -- Auto-reconnect on disconnection - -### GitHub - -- Browse your repositories grouped by organisation -- Sorted by most recent commit, with automatic refresh -- Quick access from the sidebar alongside your other connections - ---- - -## Power Features - -### Broadcast Input - -Press **Cmd+Shift+B** (or click the broadcast button in the tab bar) to toggle broadcast mode. When active, everything you type is sent to **all connected sessions** simultaneously — ideal for running the same command across multiple servers. The broadcast button pulses red to remind you it's active. - -### Snippet Library - -Press **Cmd+Shift+S** to open the snippet library. Save frequently-used commands (e.g. `sudo systemctl restart nginx`) with a name, description, and optional folder/tags. You can send a snippet to the active session with one click, or access snippets from the right-click context menu. - -### Terminal Search - -Press **Cmd+F** to open the search overlay on the active terminal. Features include: - -- Case-sensitive toggle (**Aa** button) -- Regular expression toggle (**.\*** button) -- Match count indicator -- Navigate through matches with up/down arrows or Enter/Shift+Enter - -### Right-Click Context Menu - -Right-click anywhere in the terminal to access: - -- **Copy** — copy selected text to clipboard -- **Paste** — paste from clipboard -- **Select All** — select all terminal content -- **Find** — open the search overlay -- **Send Snippet** — quickly send a saved command -- **Clear** / **Reset** — clear the terminal buffer - -### Auto-Login Scripts - -For SSH and Telnet connections, you can define expect-style login scripts in the profile editor. Each script is a sequence of **wait** (match text from the server) and **send** (send text + newline) steps that run automatically after connection. Useful for: - -- Automating password prompts on bastion hosts -- Navigating menu-driven systems -- Running initial setup commands - -Each wait step has a configurable timeout (default 10 seconds). - -### Auto-Reconnect - -Enable auto-reconnect in the profile's Advanced section. When an SSH or Telnet connection drops unexpectedly, Nexus Terminal will automatically attempt to reconnect using exponential backoff (1s, 2s, 4s, 8s… up to 30s between attempts). You can configure the maximum number of attempts (default 5). - -### Quick Connect - -Press **Cmd+Shift+Q** to open a lightweight connect dialog. Choose SSH, Telnet, or RAW; enter the host and port; and connect immediately without creating a saved profile. - ---- - -## Security - -Nexus Terminal takes credential storage seriously. Your passwords and private key passphrases are never stored in plain text. - -### Two-Tier Credential Storage - -1. **OS keychain (preferred)** — on macOS this is Keychain, on Linux it's Secret Service via D-Bus, and on Windows it's Credential Manager. If your system's keychain is available, Nexus Terminal uses it automatically. - -2. **Encrypted file vault (fallback)** — if the OS keychain isn't available, credentials are stored in a local vault file encrypted with AES-256-GCM. You set a master password when creating the vault, and a strength metre helps you choose a good one. - -### How the Encryption Works - -| Detail | Value | -| -------------- | -------------------------------------------------- | -| Cipher | AES-256-GCM | -| Key derivation | Argon2id — 128 MiB memory, 3 iterations, 4 threads | -| Key length | 256 bits (32 bytes) | -| Salt | 32 random bytes (unique per vault) | -| Nonce | Random, prepended to ciphertext | - -### TOTP/2FA - -For SSH servers that use two-factor authentication, you can store a TOTP secret in the profile's encrypted vault. During keyboard-interactive authentication, Nexus Terminal detects 2FA prompts (verification code, OTP, authenticator, etc.) and automatically generates and submits a 6-digit time-based code. You can test the secret from the profile editor before connecting. - -### Known Hosts - -When you connect to an SSH server for the first time, Nexus Terminal can remember the server's fingerprint. You choose one of four policies per profile: - -- **Strict** — reject any server whose key doesn't match a saved fingerprint -- **Ask** — prompt you to accept or reject unknown keys -- **Accept new** — automatically trust first-time connections, reject changes -- **Insecure** — accept any key (not recommended) - -### Profile Export & Import - -Profiles can be exported as encrypted `.nexus` bundles (AES-256-GCM, same as the vault). By default, exports exclude secrets — passwords and passphrases are only included when you choose an encrypted export. Secrets are keyed internally by profile: `profile::password`, `profile::passphrase`. - ---- - -## Architecture - -> This section is aimed at developers who want to understand or contribute to the codebase. - -Nexus Terminal is a [Wails v2](https://wails.io/) desktop application: a Go backend handles all connection logic and data storage, whilst an HTML/CSS/JS frontend (rendered in a native webview) provides the interface. - -``` -internal/ - app/ — lifecycle bootstrap: opens database, initialises secrets, wires everything together - api/ — single Service struct bound to Wails; every public method is callable from JS - sessions/ — manages concurrent sessions; owns SSH/Telnet/RAW/GitHub client lifecycles - storage/ — SQLite repository (WAL mode) with embedded sequential migrations - secrets/ — two-tier credential storage (OS keychain + encrypted file vault) - models/ — shared domain types, protocol configs, and defaults - sshclient/ — SSH client with agent, proxy, and TOTP support - telnetclient/ — Telnet with option negotiation - rawclient/ — RAW TCP with hex encoding - githubclient/ — GitHub API integration - sftpclient/ — SFTP file operations - sshconfig/ — OpenSSH config parser - recording/ — asciicast v2 session recording - 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/ - src/js/ — 18 IIFE-wrapped JS modules (no framework, no bundler) - src/css/ — modular stylesheets with design tokens (tokens.css) -``` - -### How the Frontend and Backend Communicate - -- **Frontend → Backend:** calling `window.go.api.Service.MethodName()` returns a Promise. Every public method on the Go `Service` struct is available this way. -- **Backend → Frontend:** the Go side pushes events via `wailsRuntime.EventsEmit()`. The frontend listens with `window.runtime.EventsOn()`. -- **Interactive prompts** (e.g. password requests, host key verification) use a request/response event pattern with a unique `requestID`, so the Go side can block on a channel until the user responds in the UI. - ---- - -## Building & Testing - -| Command | What it does | -| ------------ | --------------------------------------------------------- | -| `make deps` | Install Go modules and npm packages | -| `make build` | Production build → `build/bin/nexus-terminal.app` (macOS) | -| `make dev` | Development mode with hot reload | -| `make test` | Run all Go tests (`go test ./... -v -count=1`) | -| `make lint` | Run golangci-lint | - -To run tests for a single package: - -```bash -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 libraries from `node_modules` into the frontend source tree. All frontend assets are embedded at compile time. - -### Cross-Build Helper (macOS/Linux) - -If you want to build multiple targets from a macOS or Linux host, use the helper script: - -```bash -./build-all.sh -``` - -It builds the host platform plus Linux and Windows (both `amd64` and `arm64`) **where a suitable C compiler is available**. SQLite requires cgo, so missing toolchains will cause a target to be skipped. Common compiler names: - -- Linux: `x86_64-linux-gnu-gcc`, `aarch64-linux-gnu-gcc` -- Windows: `x86_64-w64-mingw32-gcc`, `aarch64-w64-mingw32-gcc` - -### Windows Builds - -SQLite requires cgo on Windows, which means you need a C compiler in `PATH` (for example MSYS2 with mingw-w64). Use the Windows build script to set `CGO_ENABLED=1` and run the right steps: - -```powershell -.\build.ps1 deps -.\build.ps1 build -``` - -If the build fails with a cgo/SQLite error, install MSYS2, then install the toolchain: - -```powershell -pacman -S --needed mingw-w64-x86_64-toolchain -``` - -Then reopen your terminal so `gcc` is on `PATH` and retry `build.ps1`. - ---- - -## Data Locations - -| Platform | Database | Vault | Recordings | -| -------- | ------------------------------------------------------ | ----------- | ----------------- | -| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` | -| Linux | `~/.config/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` | -| Windows | `%APPDATA%\NexusTerminal\nexus.db` | `...\vault` | `...\recordings\` | - -The database uses SQLite in WAL (Write-Ahead Logging) mode. Migrations are embedded at compile time and applied automatically on startup. - ---- - -## Troubleshooting - -**The app won't start on Linux** -You likely need GTK and WebKit system libraries. Run `wails doctor` to see what's missing, then install the packages it recommends for your distribution. - -**"Vault locked" on every launch** -If you're using the encrypted file vault (rather than the OS keychain), you'll need to enter your master password each time. If your system supports a keychain, Nexus Terminal will use it instead and you won't see this prompt. - -**SSH connection fails with "host key verification failed"** -Your profile's host key policy may be set to **strict** and the server's key has changed (or hasn't been saved yet). Open the profile settings and choose **ask** if you'd like to be prompted, or verify the server's key and update your known hosts. - -**SSH connection fails through a proxy** -Check that your proxy host, port, and type (SOCKS5 or HTTP CONNECT) are correct in the profile's Proxy section. If the proxy requires authentication, make sure the username and password are filled in. SOCKS5 defaults to port 1080; HTTP CONNECT defaults to port 8080. - -**TOTP/2FA code is rejected** -Ensure the TOTP secret is correct (base32-encoded) and that your system clock is accurate. Time-based codes are only valid for 30-second windows. Use the "Test" button in the TOTP section of the profile editor to verify the secret generates valid codes. - -**Auto-reconnect keeps failing** -The connection must have been successfully established at least once. Auto-reconnect uses the same credentials as the original connection. If the server is genuinely unreachable, reconnection attempts will exhaust after the configured maximum (default 5 attempts). - -**Login script times out** -Each "wait" step has a configurable timeout (default 10 seconds). If the server takes longer to produce the expected text, increase the timeout in the profile editor. Check that the wait pattern matches exactly — it's a plain text substring match, not a regex. - -**Terminal text looks wrong or garbled** -Check the character encoding setting on the connection profile. Most modern servers use UTF-8, but some older systems may need a different encoding. - -**Build fails with "wails: command not found"** -Install the Wails CLI: - -```bash -go install github.com/wailsapp/wails/v2/cmd/wails@latest -``` - -Make sure `$GOPATH/bin` (or `$HOME/go/bin`) is in your `PATH`. - ---- - -## Licence - -This project is licensed under the MIT Licence. See [LICENCE](LICENCE) for details. - -**Bundle identifier:** `com.andydixon.nexusterminal` -**Author:** Andy Dixon — [www.dixon.cx/nexus](https://www.dixon.cx/nexus) - ---- - -Built with [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/). diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..8bd9696 --- /dev/null +++ b/docs/overview.md @@ -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). diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..2773605 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,61 @@ +# 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** to send the same command to all connected sessions. +- **Snippets**: Save common commands and run them with one click. +- **Search**: Press **Cmd+F** to search within the active terminal. + +## 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 + +| Shortcut | Action | +| ----------------- | --------------------------- | +| Cmd + K | Command palette | +| Cmd + N | New connection | +| Cmd + W | Close tab | +| Cmd + F | Find in terminal | +| Cmd + Shift + S | Snippet library | +| Cmd + Shift + B | Broadcast input | +| Cmd + Shift + Q | Quick connect | +| Cmd + Shift + R | Toggle session recording | +| Cmd + Shift + D | Split pane vertically | +| Cmd + Shift + E | Split pane horizontally | + +## Need more detail? + +- Product overview: [docs/overview.md](overview.md) +- User manual: [docs/user-manual.md](user-manual.md) diff --git a/docs/user-manual.md b/docs/user-manual.md new file mode 100644 index 0000000..f9ffc08 --- /dev/null +++ b/docs/user-manual.md @@ -0,0 +1,250 @@ +# 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. + +### 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** 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** 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**. 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**. 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+,** 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 + +### 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. diff --git a/frontend/src/css/modals.css b/frontend/src/css/modals.css index a2b4356..6483cbf 100644 --- a/frontend/src/css/modals.css +++ b/frontend/src/css/modals.css @@ -113,6 +113,13 @@ padding: var(--space-5); } +.modal-body.sftp-picker-body { + display: flex; + flex-direction: column; + padding: 0; + min-height: 320px; +} + .modal-body p { font-size: var(--text-sm); color: var(--text-secondary); diff --git a/frontend/src/css/sftp.css b/frontend/src/css/sftp.css index 4cb2016..9d52ea9 100644 --- a/frontend/src/css/sftp.css +++ b/frontend/src/css/sftp.css @@ -189,6 +189,20 @@ 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; @@ -337,3 +351,63 @@ 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); +} diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css index 11b38a2..6ffb6b7 100644 --- a/frontend/src/css/tabs.css +++ b/frontend/src/css/tabs.css @@ -8,335 +8,366 @@ Individual tab ----------------------------------------------------------------------- */ .tab { - display: inline-flex; - align-items: center; - gap: var(--space-2); - height: var(--tab-bar-height); - padding: 0 var(--space-3); - font-size: var(--text-sm); - color: var(--text-secondary); - cursor: pointer; - white-space: nowrap; - position: relative; - flex-shrink: 0; - border-right: 1px solid var(--border-subtle); - transition: - background var(--transition-fast), - color var(--transition-fast); - user-select: none; - max-width: 200px; + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: var(--tab-bar-height); + padding: 0 var(--space-3); + font-size: var(--text-sm); + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + position: relative; + flex-shrink: 0; + border-right: 1px solid var(--border-subtle); + transition: + background var(--transition-fast), + color var(--transition-fast); + user-select: none; + max-width: 200px; + min-width: 0; } .tab:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } /* Active tab */ .tab.active { - background: var(--bg-base); - color: var(--text-primary); + background: var(--bg-base); + color: var(--text-primary); } /* Active indicator — accent bar at the bottom */ .tab.active::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 2px; - background: var(--accent); - border-radius: var(--radius-full) var(--radius-full) 0 0; + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + border-radius: var(--radius-full) var(--radius-full) 0 0; } /* ----------------------------------------------------------------------- Tab icon (small terminal/protocol icon) ----------------------------------------------------------------------- */ .tab-icon { - width: 14px; - height: 14px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - color: var(--text-tertiary); + width: 14px; + height: 14px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--text-tertiary); } .tab.active .tab-icon { - color: var(--text-secondary); + color: var(--text-secondary); } /* ----------------------------------------------------------------------- Tab label ----------------------------------------------------------------------- */ -.tab-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; +.tab-label, +.tab-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; } /* ----------------------------------------------------------------------- Protocol mini-badge inside tab ----------------------------------------------------------------------- */ .tab-badge { - display: inline-flex; - align-items: center; - height: 14px; - padding: 0 3px; - font-size: 9px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - border-radius: 2px; - flex-shrink: 0; - line-height: 1; + display: inline-flex; + align-items: center; + height: 14px; + padding: 0 3px; + font-size: 9px; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 2px; + flex-shrink: 0; + line-height: 1; } .tab-badge.badge-ssh { - background: rgba(78, 205, 196, 0.15); - color: var(--protocol-ssh); + background: rgba(78, 205, 196, 0.15); + color: var(--protocol-ssh); } .tab-badge.badge-telnet { - background: rgba(255, 167, 38, 0.15); - color: var(--protocol-telnet); + background: rgba(255, 167, 38, 0.15); + color: var(--protocol-telnet); } .tab-badge.badge-raw { - background: rgba(171, 71, 188, 0.15); - color: var(--protocol-raw); + background: rgba(171, 71, 188, 0.15); + color: var(--protocol-raw); } .tab-badge.badge-github { - background: rgba(124, 138, 255, 0.15); - color: var(--protocol-github); + background: rgba(124, 138, 255, 0.15); + color: var(--protocol-github); } /* ----------------------------------------------------------------------- Status dot (connection state) ----------------------------------------------------------------------- */ .tab-status { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - background: var(--text-tertiary); + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-tertiary); } -.tab-status.is-connected { background: var(--success); } -.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } -.tab-status.is-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); } +.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); } +.tab-status.status-connected { + background: var(--success); +} +.tab-status.status-connecting { + background: var(--warning); + animation: pulse-dot 1.2s ease infinite; +} +.tab-status.status-reconnecting { + background: var(--warning); + animation: pulse-dot 0.8s ease infinite; +} +.tab-status.status-error { + background: var(--error); +} +.tab-status.status-disconnected { + background: var(--text-tertiary); +} /* ----------------------------------------------------------------------- SFTP toggle button — revealed on hover (SSH tabs only) ----------------------------------------------------------------------- */ .tab-sftp { - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - font-size: 10px; - color: var(--text-tertiary); - flex-shrink: 0; - opacity: 0; - transition: - opacity var(--transition-fast), - background var(--transition-fast), - color var(--transition-fast); - cursor: pointer; - background: none; - border: none; - padding: 0; - margin-left: auto; + 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; + opacity: 1; } .tab-sftp:hover { - background: var(--bg-active); - color: var(--accent); + 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: 2px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-size: 11px; + color: var(--text-tertiary); + flex-shrink: 0; + opacity: 0; + transition: + opacity var(--transition-fast), + background var(--transition-fast), + color var(--transition-fast); + cursor: pointer; + margin-left: 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; + 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; + 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; + color: var(--error); + opacity: 1; + animation: pulse-dot 1.5s ease infinite; } /* ----------------------------------------------------------------------- @@ -344,41 +375,41 @@ ----------------------------------------------------------------------- */ .tab-bar-overflow-left, .tab-bar-overflow-right { - display: none; - align-items: center; - justify-content: center; - width: 20px; - flex-shrink: 0; - font-size: var(--text-sm); - color: var(--text-tertiary); - cursor: pointer; - background: linear-gradient(90deg, var(--bg-surface), transparent); + display: none; + align-items: center; + justify-content: center; + width: 20px; + flex-shrink: 0; + font-size: var(--text-sm); + color: var(--text-tertiary); + cursor: pointer; + background: linear-gradient(90deg, var(--bg-surface), transparent); } .tab-bar-overflow-right { - background: linear-gradient(-90deg, var(--bg-surface), transparent); + background: linear-gradient(-90deg, var(--bg-surface), transparent); } .tab-bar.has-overflow-left .tab-bar-overflow-left, .tab-bar.has-overflow-right .tab-bar-overflow-right { - display: flex; + display: flex; } /* ----------------------------------------------------------------------- Tab context menu trigger (on right-click) ----------------------------------------------------------------------- */ .tab-context-area { - position: absolute; - inset: 0; - z-index: 1; - /* Transparent hit area for right-click context menu */ + position: absolute; + inset: 0; + z-index: 1; + /* Transparent hit area for right-click context menu */ } /* ----------------------------------------------------------------------- Modified indicator (unsaved changes) ----------------------------------------------------------------------- */ .tab.is-modified .tab-close::before { - content: '●'; - font-size: 8px; - color: var(--accent); + content: "●"; + font-size: 8px; + color: var(--accent); } diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js index bfee8c6..72b06a9 100644 --- a/frontend/src/js/sftp.js +++ b/frontend/src/js/sftp.js @@ -158,6 +158,16 @@ class SFTPBrowser { } } + /** + * Returns the current directory for an open SFTP panel, or an empty string. + * @param {string} sessionId + * @returns {string} + */ + getCwd(sessionId) { + var state = this.panels.get(sessionId); + return state ? state.cwd : ''; + } + /* ── Toolbar ──────────────────────────────────────────────────── */ _buildToolbar(sessionId, state) { diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 1cbeaea..206cb64 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -102,6 +102,21 @@ }); } + // Drag-and-drop file handling + container.addEventListener("dragover", (e) => { + if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }); + container.addEventListener("drop", (e) => { + const files = e.dataTransfer ? e.dataTransfer.files : null; + if (!files || files.length === 0) return; + e.preventDefault(); + e.stopPropagation(); + this._handleFileDrop(sessionId, term, files); + }); + // Force a synchronous layout reflow so the container reports its // true dimensions, then fit the terminal to fill the space. // eslint-disable-next-line no-unused-expressions @@ -509,6 +524,526 @@ menu.style.top = `${window.innerHeight - rect.height - 8}px`; } + _handleFileDrop(sessionId, term, fileList) { + const session = this.app.state.getSession(sessionId); + if (!session) return; + + const files = Array.from(fileList || []); + if (files.length === 0) return; + + const canUpload = session.protocol === "ssh"; + this._promptFileDropAction(files, canUpload).then((choice) => { + if (!choice) return; + if (choice === "paste") { + this._pasteFileContents(files, term); + return; + } + if (choice === "upload") { + if (!canUpload) { + this.app.notifications.error("Upload is only available for SSH sessions."); + return; + } + this._uploadDroppedFiles(sessionId, files); + } + }); + } + + _promptFileDropAction(files, canUpload) { + const { createElement, formatBytes } = window.NexusTerminal; + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + const count = files.length; + const first = files[0]; + const sizeLabel = first && typeof first.size === "number" ? formatBytes(first.size) : ""; + const title = count === 1 ? (first ? first.name : "File") : count + " files"; + + modal.innerHTML = ` + + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); + }); + + const uploadBtn = modal.querySelector(".action-upload"); + if (!canUpload) { + uploadBtn.disabled = true; + uploadBtn.title = "Upload is only available for SSH sessions."; + } + + const close = (value) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(value); + }; + + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(null)); + modal + .querySelector(".prompt-cancel") + .addEventListener("click", () => close(null)); + modal + .querySelector(".action-paste") + .addEventListener("click", () => close("paste")); + uploadBtn.addEventListener("click", () => close("upload")); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(null); + }); + }); + } + + _pasteFileContents(files, term) { + const readFile = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result || ""); + reader.onerror = () => reject(new Error("read failed")); + reader.readAsText(file); + }); + + const run = async () => { + try { + let combined = ""; + for (let i = 0; i < files.length; i += 1) { + const text = await readFile(files[i]); + if (text) { + if (combined) combined += "\n"; + combined += text; + } + } + if (combined) term.paste(combined); + } catch (_) { + this.app.notifications.error("Failed to read file contents."); + } + }; + + run(); + } + + _uploadDroppedFiles(sessionId, files) { + const run = async () => { + const remoteDir = await this._promptUploadDirectory(sessionId); + if (!remoteDir) return; + + if (files.length > 1) { + this.app.notifications.info("Uploading " + files.length + " files..."); + } + + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + try { + const buffer = await this._readFileBuffer(file); + const base64Data = this._arrayBufferToBase64(buffer); + await window.go.api.Service.SFTPUploadData( + sessionId, + remoteDir, + file.name, + base64Data, + ); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + this.app.notifications.error("Upload failed for " + file.name + ": " + msg); + return; + } + } + + if (files.length === 1) { + this.app.notifications.success("Uploaded " + files[0].name); + } else { + this.app.notifications.success("Uploaded " + files.length + " files"); + } + }; + + run().catch((err) => { + const msg = err && err.message ? err.message : String(err); + this.app.notifications.error("Upload failed: " + msg); + }); + } + + _readFileBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error("read failed")); + reader.readAsArrayBuffer(file); + }); + } + + async _promptUploadDirectory(sessionId) { + return this._openUploadDirectoryPicker(sessionId); + } + + _openUploadDirectoryPicker(sessionId) { + const { createElement, escapeHtml } = window.NexusTerminal; + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-lg"); + modal.innerHTML = ` + + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); + }); + + const listEl = modal.querySelector(".sftp-picker-list"); + const breadcrumbEl = modal.querySelector("[data-role=\"breadcrumb\"]"); + const pathLabel = modal.querySelector(".sftp-picker-path"); + const upBtn = modal.querySelector(".sftp-picker-up"); + const refreshBtn = modal.querySelector(".sftp-picker-refresh"); + const useBtn = modal.querySelector(".prompt-ok"); + const filterInput = modal.querySelector(".sftp-picker-filter"); + const statusLabel = modal.querySelector(".sftp-picker-status"); + + const state = { + cwd: "", + selected: "", + selectedWritable: true, + cwdWritable: true, + entries: [], + visibleEntries: [], + loading: false, + filter: "", + cursorIndex: -1, + }; + + const close = (value) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(value); + }; + + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(null)); + modal + .querySelector(".prompt-cancel") + .addEventListener("click", () => close(null)); + useBtn.addEventListener("click", () => close(state.selected || state.cwd)); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(null); + }); + + const renderBreadcrumb = () => { + breadcrumbEl.innerHTML = ""; + const segments = this._pathSegments(state.cwd); + for (let i = 0; i < segments.length; i += 1) { + if (i > 0) { + breadcrumbEl.appendChild( + createElement("span", "sftp-breadcrumb-sep", { textContent: ">" }), + ); + } + const seg = segments[i]; + const btn = createElement("button", "sftp-breadcrumb-segment", { + textContent: seg.label, + title: seg.path, + }); + btn.addEventListener("click", () => loadDir(seg.path)); + breadcrumbEl.appendChild(btn); + } + }; + + const updateStatus = () => { + if (state.selected) { + statusLabel.textContent = state.selectedWritable ? "" : "No write permission"; + } else { + statusLabel.textContent = state.cwdWritable ? "" : "Current folder is read only"; + } + }; + + const setSelection = (entry, index) => { + listEl.querySelectorAll(".sftp-file-row").forEach((el) => { + el.classList.remove("is-selected"); + }); + const row = listEl.querySelector(`.sftp-file-row[data-index="${index}"]`); + if (row) row.classList.add("is-selected"); + state.selected = entry.path; + state.selectedWritable = entry.canWrite !== false; + state.cursorIndex = index; + pathLabel.textContent = entry.path; + useBtn.disabled = !state.selectedWritable; + useBtn.title = state.selectedWritable ? "" : "No write permission"; + updateStatus(); + }; + + const renderList = () => { + listEl.innerHTML = ""; + const basePath = state.selected || state.cwd || ""; + const suffix = state.selected ? "" : (state.cwdWritable ? "" : " (read only)"); + pathLabel.textContent = basePath + suffix; + + if (state.cwd && state.cwd !== "/") { + const parentRow = this._buildPickerRow({ + name: "..", + isDir: true, + path: this._parentPath(state.cwd), + }); + parentRow.classList.add("is-parent"); + parentRow.addEventListener("dblclick", () => loadDir(parentRow.dataset.path)); + listEl.appendChild(parentRow); + } + + const filtered = state.filter + ? state.entries.filter((entry) => entry.name.toLowerCase().includes(state.filter)) + : state.entries; + state.visibleEntries = filtered; + + if (!filtered.length) { + const empty = createElement("div", "sftp-state"); + empty.innerHTML = "
D
No folders found
"; + listEl.appendChild(empty); + state.cursorIndex = -1; + updateStatus(); + return; + } + + filtered.forEach((entry, index) => { + const row = this._buildPickerRow(entry, index); + if (entry.canRead === false) { + row.classList.add("is-unreadable"); + } + if (entry.canWrite === false) { + row.classList.add("is-readonly"); + } + row.addEventListener("click", () => { + if (entry.canRead === false) return; + setSelection(entry, index); + }); + row.addEventListener("dblclick", () => { + if (entry.canRead === false) return; + loadDir(entry.path); + }); + listEl.appendChild(row); + }); + updateStatus(); + }; + + const renderLoading = () => { + listEl.innerHTML = ""; + const s = createElement("div", "sftp-state"); + s.innerHTML = "
Loading...
"; + listEl.appendChild(s); + }; + + const renderError = (msg) => { + listEl.innerHTML = ""; + const s = createElement("div", "sftp-state"); + s.innerHTML = + "
!
" + + "
" + escapeHtml(msg) + "
" + + ""; + const retryBtn = s.querySelector(".sftp-state-retry"); + if (retryBtn) retryBtn.addEventListener("click", () => loadDir(state.cwd)); + listEl.appendChild(s); + }; + + const loadDir = async (path) => { + if (state.loading) return; + state.loading = true; + renderLoading(); + try { + const targetPath = path || (await window.go.api.Service.SFTPPwd(sessionId)); + const files = await window.go.api.Service.SFTPList(sessionId, targetPath); + const stat = await window.go.api.Service.SFTPStat(sessionId, targetPath); + state.cwd = targetPath; + state.selected = ""; + state.selectedWritable = true; + state.cwdWritable = stat && this._modeCanWrite(stat.mode); + state.entries = (files || []).filter((e) => e.isDir).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + state.entries = state.entries.map((entry) => { + const canRead = this._modeCanRead(entry.mode); + const canWrite = this._modeCanWrite(entry.mode); + return Object.assign({}, entry, { canRead, canWrite }); + }); + useBtn.disabled = !state.cwdWritable; + useBtn.title = state.cwdWritable ? "" : "No write permission"; + renderBreadcrumb(); + renderList(); + } catch (err) { + renderError(err.message || String(err)); + } finally { + state.loading = false; + } + }; + + upBtn.addEventListener("click", () => { + if (state.cwd) loadDir(this._parentPath(state.cwd)); + }); + refreshBtn.addEventListener("click", () => loadDir(state.cwd)); + + filterInput.addEventListener("input", () => { + state.filter = filterInput.value.trim().toLowerCase(); + renderList(); + }); + + const moveSelection = (delta) => { + if (!state.visibleEntries.length) return; + let idx = state.cursorIndex; + const max = state.visibleEntries.length - 1; + let attempts = 0; + do { + idx = idx + delta; + if (idx < 0) idx = max; + if (idx > max) idx = 0; + const entry = state.visibleEntries[idx]; + if (entry && entry.canRead !== false) { + setSelection(entry, idx); + return; + } + attempts += 1; + } while (attempts <= max); + }; + + modal.addEventListener("keydown", (e) => { + const isInput = e.target === filterInput; + if (isInput && e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + moveSelection(1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + moveSelection(-1); + } else if (e.key === "Enter") { + if (state.selected && state.selectedWritable) { + e.preventDefault(); + close(state.selected); + } else if (!state.selected && state.cwd && state.cwdWritable) { + e.preventDefault(); + close(state.cwd); + } + } else if (e.key === "Escape") { + e.preventDefault(); + close(null); + } + }); + + const initial = this.app.sftp && typeof this.app.sftp.getCwd === "function" + ? this.app.sftp.getCwd(sessionId) + : ""; + loadDir(initial || ""); + }); + } + + _buildPickerRow(entry, index) { + const { createElement } = window.NexusTerminal; + const dataset = { path: entry.path || "" }; + if (typeof index === "number") { + dataset.index = String(index); + } + const row = createElement("div", "sftp-file-row", { dataset }); + const icon = createElement("span", "sftp-file-icon is-dir", { textContent: "DIR" }); + const name = createElement("span", "sftp-file-name", { + textContent: entry.name || entry.path || "", + title: entry.name || entry.path || "", + }); + let metaLabel = ""; + if (entry.canRead === false) { + metaLabel = "NO READ"; + } else if (entry.canWrite === false) { + metaLabel = "READ ONLY"; + } + const meta = createElement("span", "sftp-file-meta", { textContent: metaLabel }); + row.appendChild(icon); + row.appendChild(name); + row.appendChild(meta); + return row; + } + + _modeCanRead(mode) { + if (!mode || typeof mode !== "string") return true; + return mode.indexOf("r") !== -1; + } + + _modeCanWrite(mode) { + if (!mode || typeof mode !== "string") return true; + return mode.indexOf("w") !== -1; + } + + _pathSegments(p) { + const parts = (p || "").split("/").filter(Boolean); + const segs = [{ label: "/", path: "/" }]; + let acc = ""; + for (let i = 0; i < parts.length; i += 1) { + acc += "/" + parts[i]; + segs.push({ label: parts[i], path: acc }); + } + return segs; + } + + _parentPath(p) { + if (!p || p === "/") return "/"; + const trimmed = p.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx <= 0 ? "/" : trimmed.substring(0, idx); + } + + _arrayBufferToBase64(buffer) { + if (!buffer) return ""; + const bytes = new Uint8Array(buffer); + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk); + } + return btoa(binary); + } + /** * Builds the xterm.js colour theme. * @returns {Object} diff --git a/internal/api/api.go b/internal/api/api.go index be82230..7527a50 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net" + "path" "path/filepath" "runtime" "strings" @@ -1044,6 +1045,22 @@ func (s *Service) SFTPMkdir(sessionID string, remotePath string) error { return s.sessions.SFTPMkdir(sessionID, remotePath) } +// SFTPStat returns information about a remote path. +func (s *Service) SFTPStat(sessionID string, remotePath string) (map[string]interface{}, error) { + entry, err := s.sessions.SFTPStat(sessionID, remotePath) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "name": entry.Name, + "path": entry.Path, + "size": entry.Size, + "isDir": entry.IsDir, + "mode": entry.Mode, + "modifiedAt": entry.ModifiedAt, + }, nil +} + // SFTPRemove deletes a remote file or empty directory. func (s *Service) SFTPRemove(sessionID string, remotePath string) error { return s.sessions.SFTPRemove(sessionID, remotePath) @@ -1096,11 +1113,37 @@ func (s *Service) SFTPUpload(sessionID string, remotePath string) error { return s.sessions.SFTPUpload(sessionID, localPath, destPath) } +// SFTPUploadData uploads raw file data to a remote directory. +// Data is expected as base64-encoded bytes from the frontend. +func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName string, base64Data string) error { + if base64Data == "" { + return fmt.Errorf("empty upload payload") + } + + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return fmt.Errorf("failed to decode upload data: %w", err) + } + + safeName := filepath.Base(fileName) + if safeName == "" || safeName == "." || safeName == "/" { + return fmt.Errorf("invalid file name") + } + + destPath := path.Join(remoteDir, safeName) + return s.sessions.SFTPUploadBytes(sessionID, destPath, data) +} + // SFTPPwd returns the current working directory on the remote host. func (s *Service) SFTPPwd(sessionID string) (string, error) { return s.sessions.SFTPPwd(sessionID) } +// GetSessionCwd returns a best-effort working directory for SSH sessions. +func (s *Service) GetSessionCwd(sessionID string) (string, error) { + return s.sessions.GetSessionCwd(sessionID) +} + // --------------------------------------------------------------------------- // OpenSSH config import // --------------------------------------------------------------------------- diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 6116ae3..1f6695a 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -258,6 +258,26 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error { return nil } +// GetSessionCwd returns a best-effort working directory for SSH sessions. +func (m *Manager) GetSessionCwd(sessionID string) (string, error) { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return "", fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.sshClient == nil { + return "", fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) + } + + cwd, err := sess.sshClient.RunCommand("pwd") + if err != nil { + return "", err + } + return strings.TrimSpace(cwd), nil +} + // GetSession returns session info by ID. func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { m.mu.RLock() @@ -1074,6 +1094,23 @@ func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error { }) } +// SFTPUploadBytes uploads in-memory data to a remote path. +func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) { + m.emitEvent("session:sftp:progress", map[string]interface{}{ + "sessionID": sessionID, + "transferred": p.Transferred, + "total": p.Total, + "path": p.Path, + "done": p.Done, + }) + }) +} + // SFTPPwd returns the current working directory on the remote host. func (m *Manager) SFTPPwd(sessionID string) (string, error) { sc, err := m.getSFTP(sessionID) diff --git a/internal/sftpclient/sftpclient.go b/internal/sftpclient/sftpclient.go index 44044b0..dede192 100644 --- a/internal/sftpclient/sftpclient.go +++ b/internal/sftpclient/sftpclient.go @@ -8,6 +8,7 @@ package sftpclient import ( + "bytes" "fmt" "io" "os" @@ -247,6 +248,25 @@ func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferPr 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() diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index fb1c218..cad7962 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -261,6 +261,29 @@ func (c *Client) Resize(cols, rows int) error { return session.WindowChange(rows, cols) } +// RunCommand executes a single command using a new SSH session. +func (c *Client) RunCommand(command string) (string, error) { + c.mu.Lock() + if !c.connected || c.sshClient == nil { + c.mu.Unlock() + return "", fmt.Errorf("not connected") + } + client := c.sshClient + c.mu.Unlock() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("failed to open SSH session: %w", err) + } + defer session.Close() + + out, err := session.Output(command) + if err != nil { + return "", fmt.Errorf("command failed: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + // Close cleanly shuts down the SSH session and connection, cancels the // background context, and invokes the onClose callback. func (c *Client) Close() error {