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.
This commit is contained in:
Andy Dixon
2026-04-21 13:20:03 +01:00
parent 7146063da2
commit 2ce145ba0f
13 changed files with 1349 additions and 560 deletions
+19 -350
View File
@@ -1,82 +1,33 @@
# Nexus Terminal # 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 ## Quick start
- ✅ 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
--- 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) ## Documentation
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)
--- - 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 ## Keyboard shortcuts
### 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
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux. 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 + S | Open snippet library |
| Cmd + Shift + Q | Quick connect | | Cmd + Shift + Q | Quick connect |
| F11 | Toggle fullscreen (Windows/Linux) | | 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:<uuid>:password`, `profile:<uuid>: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)
---
<sub>Built with [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/).</sub>
+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).
+61
View File
@@ -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)
+250
View File
@@ -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.
+7
View File
@@ -113,6 +113,13 @@
padding: var(--space-5); padding: var(--space-5);
} }
.modal-body.sftp-picker-body {
display: flex;
flex-direction: column;
padding: 0;
min-height: 320px;
}
.modal-body p { .modal-body p {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
+74
View File
@@ -189,6 +189,20 @@
background: var(--accent-strong); 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 { .sftp-file-icon {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
@@ -337,3 +351,63 @@
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--accent-muted); 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);
}
+241 -210
View File
@@ -8,335 +8,366 @@
Individual tab Individual tab
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab { .tab {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
height: var(--tab-bar-height); height: var(--tab-bar-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
border-right: 1px solid var(--border-subtle); border-right: 1px solid var(--border-subtle);
transition: transition:
background var(--transition-fast), background var(--transition-fast),
color var(--transition-fast); color var(--transition-fast);
user-select: none; user-select: none;
max-width: 200px; max-width: 200px;
min-width: 0;
} }
.tab:hover { .tab:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
/* Active tab */ /* Active tab */
.tab.active { .tab.active {
background: var(--bg-base); background: var(--bg-base);
color: var(--text-primary); color: var(--text-primary);
} }
/* Active indicator — accent bar at the bottom */ /* Active indicator — accent bar at the bottom */
.tab.active::after { .tab.active::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
background: var(--accent); background: var(--accent);
border-radius: var(--radius-full) var(--radius-full) 0 0; border-radius: var(--radius-full) var(--radius-full) 0 0;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Tab icon (small terminal/protocol icon) Tab icon (small terminal/protocol icon)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-icon { .tab-icon {
width: 14px; width: 14px;
height: 14px; height: 14px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.tab.active .tab-icon { .tab.active .tab-icon {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Tab label Tab label
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-label { .tab-label,
overflow: hidden; .tab-name {
text-overflow: ellipsis; overflow: hidden;
white-space: nowrap; text-overflow: ellipsis;
min-width: 0; white-space: nowrap;
min-width: 0;
flex: 1;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Protocol mini-badge inside tab Protocol mini-badge inside tab
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-badge { .tab-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: 14px; height: 14px;
padding: 0 3px; padding: 0 3px;
font-size: 9px; font-size: 9px;
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
border-radius: 2px; border-radius: 2px;
flex-shrink: 0; flex-shrink: 0;
line-height: 1; line-height: 1;
} }
.tab-badge.badge-ssh { .tab-badge.badge-ssh {
background: rgba(78, 205, 196, 0.15); background: rgba(78, 205, 196, 0.15);
color: var(--protocol-ssh); color: var(--protocol-ssh);
} }
.tab-badge.badge-telnet { .tab-badge.badge-telnet {
background: rgba(255, 167, 38, 0.15); background: rgba(255, 167, 38, 0.15);
color: var(--protocol-telnet); color: var(--protocol-telnet);
} }
.tab-badge.badge-raw { .tab-badge.badge-raw {
background: rgba(171, 71, 188, 0.15); background: rgba(171, 71, 188, 0.15);
color: var(--protocol-raw); color: var(--protocol-raw);
} }
.tab-badge.badge-github { .tab-badge.badge-github {
background: rgba(124, 138, 255, 0.15); background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github); color: var(--protocol-github);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Status dot (connection state) Status dot (connection state)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-status { .tab-status {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
background: var(--text-tertiary); background: var(--text-tertiary);
} }
.tab-status.is-connected { background: var(--success); } .tab-status.is-connected {
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } background: var(--success);
.tab-status.is-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; } }
.tab-status.is-error { background: var(--error); } .tab-status.is-connecting {
.tab-status.is-disconnected { background: var(--text-tertiary); } 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 */ /* Also match the status-* pattern used by updateSessionState */
.tab-status.status-connected { background: var(--success); } .tab-status.status-connected {
.tab-status.status-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } background: var(--success);
.tab-status.status-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; } }
.tab-status.status-error { background: var(--error); } .tab-status.status-connecting {
.tab-status.status-disconnected { background: var(--text-tertiary); } 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) SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-sftp { .tab-sftp {
width: 16px; width: 16px;
height: 16px; height: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 10px; font-size: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0; flex-shrink: 0;
opacity: 0; opacity: 0;
transition: transition:
opacity var(--transition-fast), opacity var(--transition-fast),
background var(--transition-fast), background var(--transition-fast),
color var(--transition-fast); color var(--transition-fast);
cursor: pointer; cursor: pointer;
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
margin-left: auto; margin-left: auto;
} }
.tab:hover .tab-sftp, .tab:hover .tab-sftp,
.tab.active .tab-sftp { .tab.active .tab-sftp {
opacity: 1; opacity: 1;
} }
.tab-sftp:hover { .tab-sftp:hover {
background: var(--bg-active); background: var(--bg-active);
color: var(--accent); color: var(--accent);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Close button — revealed on hover Close button — revealed on hover
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-close { .tab-close {
width: 16px; width: 16px;
height: 16px; height: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 11px; font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0; flex-shrink: 0;
opacity: 0; opacity: 0;
transition: transition:
opacity var(--transition-fast), opacity var(--transition-fast),
background var(--transition-fast), background var(--transition-fast),
color var(--transition-fast); color var(--transition-fast);
cursor: pointer; cursor: pointer;
margin-left: 2px; margin-left: 2px;
} }
.tab:hover .tab-close, .tab:hover .tab-close,
.tab.active .tab-close { .tab.active .tab-close {
opacity: 1; opacity: 1;
} }
.tab-close:hover { .tab-close:hover {
background: var(--bg-active); background: var(--bg-active);
color: var(--text-primary); color: var(--text-primary);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Pin indicator Pin indicator
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-pin { .tab-pin {
width: 12px; width: 12px;
height: 12px; height: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 9px; font-size: 9px;
color: var(--text-disabled); color: var(--text-disabled);
flex-shrink: 0; flex-shrink: 0;
} }
.tab.is-pinned .tab-pin { .tab.is-pinned .tab-pin {
color: var(--accent); color: var(--accent);
} }
/* Pinned tabs are always narrow */ /* Pinned tabs are always narrow */
.tab.is-pinned { .tab.is-pinned {
max-width: 100px; max-width: 100px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
} }
.tab.is-pinned .tab-label { .tab.is-pinned .tab-label {
/* Hide label on pinned tabs to save space */ /* Hide label on pinned tabs to save space */
display: none; display: none;
} }
.tab.is-pinned .tab-close { .tab.is-pinned .tab-close {
/* Pinned tabs don't show close by default */ /* Pinned tabs don't show close by default */
display: none; display: none;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Drag handle area (for tab reordering) Drag handle area (for tab reordering)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-drag-handle { .tab-drag-handle {
cursor: grab; cursor: grab;
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
height: 100%; height: 100%;
} }
.tab-drag-handle:active { .tab-drag-handle:active {
cursor: grabbing; cursor: grabbing;
} }
/* Drag ghost styling */ /* Drag ghost styling */
.tab.is-dragging { .tab.is-dragging {
opacity: 0.5; opacity: 0.5;
} }
.tab.is-drag-over-left::before { .tab.is-drag-over-left::before {
content: ''; content: "";
position: absolute; position: absolute;
left: -1px; left: -1px;
top: 4px; top: 4px;
bottom: 4px; bottom: 4px;
width: 2px; width: 2px;
background: var(--accent); background: var(--accent);
border-radius: var(--radius-full); border-radius: var(--radius-full);
} }
.tab.is-drag-over-right::after { .tab.is-drag-over-right::after {
content: ''; content: "";
position: absolute; position: absolute;
right: -1px; right: -1px;
top: 4px; top: 4px;
bottom: 4px; bottom: 4px;
width: 2px; width: 2px;
background: var(--accent); background: var(--accent);
border-radius: var(--radius-full); border-radius: var(--radius-full);
/* Note: this re-uses ::after — active tab indicator is overridden while dragging */ /* Note: this re-uses ::after — active tab indicator is overridden while dragging */
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
New-tab button (+) New-tab button (+)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-new { .tab-new {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--tab-bar-height); width: var(--tab-bar-height);
height: var(--tab-bar-height); height: var(--tab-bar-height);
font-size: var(--text-lg); font-size: var(--text-lg);
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: background var(--transition-fast), color var(--transition-fast); transition:
border: none; background var(--transition-fast),
background: none; color var(--transition-fast);
border: none;
background: none;
} }
.tab-new:hover { .tab-new:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.tab-new:active { .tab-new:active {
background: var(--bg-active); background: var(--bg-active);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Broadcast toggle button Broadcast toggle button
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-broadcast { .tab-broadcast {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--tab-bar-height); width: var(--tab-bar-height);
height: var(--tab-bar-height); height: var(--tab-bar-height);
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: background var(--transition-fast), color var(--transition-fast); transition:
border: none; background var(--transition-fast),
background: none; color var(--transition-fast);
opacity: 0.5; border: none;
background: none;
opacity: 0.5;
} }
.tab-broadcast:hover { .tab-broadcast:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
opacity: 0.8; opacity: 0.8;
} }
.tab-broadcast.is-active { .tab-broadcast.is-active {
color: var(--error); color: var(--error);
opacity: 1; opacity: 1;
animation: pulse-dot 1.5s ease infinite; animation: pulse-dot 1.5s ease infinite;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
@@ -344,41 +375,41 @@
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-bar-overflow-left, .tab-bar-overflow-left,
.tab-bar-overflow-right { .tab-bar-overflow-right {
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 20px;
flex-shrink: 0; flex-shrink: 0;
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
background: linear-gradient(90deg, var(--bg-surface), transparent); background: linear-gradient(90deg, var(--bg-surface), transparent);
} }
.tab-bar-overflow-right { .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-left .tab-bar-overflow-left,
.tab-bar.has-overflow-right .tab-bar-overflow-right { .tab-bar.has-overflow-right .tab-bar-overflow-right {
display: flex; display: flex;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Tab context menu trigger (on right-click) Tab context menu trigger (on right-click)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab-context-area { .tab-context-area {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: 1;
/* Transparent hit area for right-click context menu */ /* Transparent hit area for right-click context menu */
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Modified indicator (unsaved changes) Modified indicator (unsaved changes)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.tab.is-modified .tab-close::before { .tab.is-modified .tab-close::before {
content: '●'; content: "●";
font-size: 8px; font-size: 8px;
color: var(--accent); color: var(--accent);
} }
+10
View File
@@ -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 ──────────────────────────────────────────────────── */ /* ── Toolbar ──────────────────────────────────────────────────── */
_buildToolbar(sessionId, state) { _buildToolbar(sessionId, state) {
+535
View File
@@ -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 // Force a synchronous layout reflow so the container reports its
// true dimensions, then fit the terminal to fill the space. // true dimensions, then fit the terminal to fill the space.
// eslint-disable-next-line no-unused-expressions // eslint-disable-next-line no-unused-expressions
@@ -509,6 +524,526 @@
menu.style.top = `${window.innerHeight - rect.height - 8}px`; 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 = `
<div class="modal-header">
<h3 class="modal-title">File Drop</h3>
<button class="btn btn-icon btn-xs modal-close">x</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
<strong>${title}</strong>${count === 1 && sizeLabel ? " (" + sizeLabel + ")" : ""}
</p>
<p style="font-size:var(--text-sm);color:var(--text-tertiary)">
Choose what to do with the dropped ${count === 1 ? "file" : "files"}.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm prompt-cancel">Cancel</button>
<button class="btn btn-sm btn-secondary action-paste">Paste contents</button>
<button class="btn btn-sm btn-primary action-upload">Upload to current directory</button>
</div>
`;
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 = `
<div class="modal-header">
<h3 class="modal-title">Select Upload Directory</h3>
<button class="btn btn-icon btn-xs modal-close">x</button>
</div>
<div class="modal-body sftp-picker-body">
<div class="sftp-toolbar">
<div class="sftp-breadcrumb" data-role="breadcrumb"></div>
<input class="sftp-picker-filter" type="text" placeholder="Filter folders" />
<div class="sftp-toolbar-actions">
<button class="sftp-toolbar-btn sftp-picker-up" title="Up">Up</button>
<button class="sftp-toolbar-btn sftp-picker-refresh" title="Refresh">Ref</button>
</div>
</div>
<div class="sftp-file-list sftp-picker-list"></div>
</div>
<div class="modal-footer">
<div class="modal-footer-left">
<span class="form-help sftp-picker-path"></span>
<span class="sftp-picker-status"></span>
<span class="sftp-picker-legend">
<span class="sftp-picker-badge is-readonly">READ ONLY</span>
<span class="sftp-picker-badge is-noread">NO READ</span>
</span>
</div>
<button class="btn btn-sm prompt-cancel">Cancel</button>
<button class="btn btn-sm btn-primary prompt-ok">Use this folder</button>
</div>
`;
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 = "<div class=\"sftp-state-icon\">D</div><div class=\"sftp-state-msg\">No folders found</div>";
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 = "<div class=\"sftp-spinner\"></div><div class=\"sftp-state-msg\">Loading...</div>";
listEl.appendChild(s);
};
const renderError = (msg) => {
listEl.innerHTML = "";
const s = createElement("div", "sftp-state");
s.innerHTML =
"<div class=\"sftp-state-icon\">!</div>" +
"<div class=\"sftp-state-msg\">" + escapeHtml(msg) + "</div>" +
"<button class=\"btn btn-sm btn-secondary sftp-state-retry\">Retry</button>";
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. * Builds the xterm.js colour theme.
* @returns {Object} * @returns {Object}
+43
View File
@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -1044,6 +1045,22 @@ func (s *Service) SFTPMkdir(sessionID string, remotePath string) error {
return s.sessions.SFTPMkdir(sessionID, remotePath) 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. // SFTPRemove deletes a remote file or empty directory.
func (s *Service) SFTPRemove(sessionID string, remotePath string) error { func (s *Service) SFTPRemove(sessionID string, remotePath string) error {
return s.sessions.SFTPRemove(sessionID, remotePath) 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) 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. // SFTPPwd returns the current working directory on the remote host.
func (s *Service) SFTPPwd(sessionID string) (string, error) { func (s *Service) SFTPPwd(sessionID string) (string, error) {
return s.sessions.SFTPPwd(sessionID) 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 // OpenSSH config import
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+37
View File
@@ -258,6 +258,26 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
return nil 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. // GetSession returns session info by ID.
func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) {
m.mu.RLock() 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. // SFTPPwd returns the current working directory on the remote host.
func (m *Manager) SFTPPwd(sessionID string) (string, error) { func (m *Manager) SFTPPwd(sessionID string) (string, error) {
sc, err := m.getSFTP(sessionID) sc, err := m.getSFTP(sessionID)
+20
View File
@@ -8,6 +8,7 @@
package sftpclient package sftpclient
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -247,6 +248,25 @@ func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferPr
return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress) 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. // Pwd returns the current working directory on the remote host.
func (c *Client) Pwd() (string, error) { func (c *Client) Pwd() (string, error) {
c.mu.Lock() c.mu.Lock()
+23
View File
@@ -261,6 +261,29 @@ func (c *Client) Resize(cols, rows int) error {
return session.WindowChange(rows, cols) 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 // Close cleanly shuts down the SSH session and connection, cancels the
// background context, and invokes the onClose callback. // background context, and invokes the onClose callback.
func (c *Client) Close() error { func (c *Client) Close() error {