init: first commit
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.rs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.toml]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Dragonfly
|
||||||
|
|
||||||
|
Dragonfly is a modern, high-performance SSH client and terminal emulator built with **Tauri**, **React**, and **Rust**. It combines the web's flexibility with the system's performance to provide a robust tool for managing remote connections.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **🚀 Robust SSH Client**: Secure and fast SSH connections powered by Rust's `russh`.
|
||||||
|
- **📑 Multi-Tab Interface**: Manage multiple active sessions simultaneously in a tabbed interface.
|
||||||
|
- **💾 Session Management**: Save, organize, and edit your frequently used server connections for quick access.
|
||||||
|
- **📂 Integrated File Explorer**: Browse, upload, and download files on your remote servers directly from the sidebar.
|
||||||
|
- **📜 Command History**: Automatically save and easily access your command history.
|
||||||
|
- **⚡ Quick Commands**: store and execute your most frequent commands with a single click.
|
||||||
|
- **🎨 Customizable UI**: Resizable panels for File Explorer, Saved Connections, and Command History to fit your workflow.
|
||||||
|
- **🌗 Theme Support**: Fully supported Dark and Light modes.
|
||||||
|
- **🖥️ Cross-Platform**: Optimized for Windows, macOS, and Linux.
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Vite](https://vitejs.dev/), [TailwindCSS 4](https://tailwindcss.com/)
|
||||||
|
- **Backend**: [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/)
|
||||||
|
- **Terminal**: [xterm.js](https://xtermjs.org/)
|
||||||
|
- **Icons**: [Material Icons](https://fonts.google.com/icons)
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Ensure you have the following installed on your machine:
|
||||||
|
|
||||||
|
- **Node.js**: v18 or newer recommended.
|
||||||
|
- **Rust**: The latest stable version (via [rustup](https://rustup.rs/)).
|
||||||
|
- **Tauri CLI**: (Optional) `npm install -g @tauri-apps/cli`.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/dragonfly.git
|
||||||
|
cd dragonfly
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
pnpm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💻 Development
|
||||||
|
|
||||||
|
Start the development server with hot-module replacement (HMR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri dev
|
||||||
|
# or
|
||||||
|
pnpm tauri dev
|
||||||
|
# or
|
||||||
|
yarn tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start the Vite dev server and launch the Tauri application window.
|
||||||
|
|
||||||
|
### 📦 Build
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri build
|
||||||
|
# or
|
||||||
|
pnpm tauri build
|
||||||
|
# or
|
||||||
|
yarn tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build artifacts will be located in `src-tauri/target/release/bundle`.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||||
|
"assist": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"linter": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
|
"useKeyWithClickEvents": "off",
|
||||||
|
"useButtonType": "off",
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"noUnusedImports": "warn",
|
||||||
|
"noUnusedVariables": "warn"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "warn",
|
||||||
|
"noArrayIndexKey": "warn",
|
||||||
|
"noControlCharactersInRegex": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"useNodejsImportProtocol": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="dark" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dragonfly Terminal</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "dragonfly",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"lint": "biome check src/",
|
||||||
|
"format": "biome check --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-search": "^0.16.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"material-icons": "^1.13.14",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
[package]
|
||||||
|
name = "dragonfly"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["Kang"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
# Allow common patterns that are noisy under pedantic
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
wildcard_imports = "allow"
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
struct_excessive_bools = "allow"
|
||||||
|
too_many_lines = "allow"
|
||||||
|
unnecessary_wraps = "allow"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "dragonfly_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "time", "fs"] }
|
||||||
|
russh = "0.46"
|
||||||
|
russh-keys = "0.46"
|
||||||
|
ssh-key = { version = "0.6", features = ["std"] }
|
||||||
|
portable-pty = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
thiserror = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
|
dirs = "6.0.0"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
opt-level = "s"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"dialog:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,233 @@
|
|||||||
|
//! Config persistence for sessions, UI, and quick commands.
|
||||||
|
//!
|
||||||
|
//! Stores JSON files in `~/.dragonfly/`. Credentials are AES-256-GCM encrypted in-place.
|
||||||
|
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
// ── Shared Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn get_config_dir(app: &AppHandle) -> AppResult<PathBuf> {
|
||||||
|
let home_dir = app
|
||||||
|
.path()
|
||||||
|
.home_dir()
|
||||||
|
.map_err(|e| AppError::Config(e.to_string()))?;
|
||||||
|
let config_dir = home_dir.join(".dragonfly");
|
||||||
|
fs::create_dir_all(&config_dir)?;
|
||||||
|
Ok(config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_json<T: serde::de::DeserializeOwned + Default>(path: &PathBuf) -> AppResult<T> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(T::default());
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
Ok(serde_json::from_str(&content)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_json<T: Serialize>(path: &PathBuf, data: &T) -> AppResult<()> {
|
||||||
|
let content = serde_json::to_string_pretty(data)?;
|
||||||
|
fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sessions.json ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Saved SSH connection. Credential fields store AES-256-GCM ciphertext on disk.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedConnection {
|
||||||
|
#[serde(default = "uuid_v4")]
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub username: String,
|
||||||
|
pub auth_type: String,
|
||||||
|
|
||||||
|
/// Ciphertext on disk; plaintext in memory after `load_connection_by_id`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Ciphertext on disk (PEM content); decrypted on demand via `decrypt_key_data`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub key: Option<String>,
|
||||||
|
/// Ciphertext on disk; plaintext in memory after `load_connection_by_id`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
|
||||||
|
/// File path chosen via the file picker — backend reads & encrypts the content.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub key_file_path: Option<String>,
|
||||||
|
/// True when an encrypted private key is stored in `key`.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub has_key_data: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uuid_v4() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group for organizing saved connections in the UI.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Group {
|
||||||
|
#[serde(default = "uuid_v4")]
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root config for groups and saved connections (sessions.json).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SessionsConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups: Vec<Group>,
|
||||||
|
pub connections: Vec<SavedConnection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias for the main app config (sessions + groups).
|
||||||
|
pub type AppConfig = SessionsConfig;
|
||||||
|
|
||||||
|
/// Decrypts `password` and `passphrase` in-place (ciphertext → plaintext).
|
||||||
|
///
|
||||||
|
/// Called by `load_connection_by_id` before an SSH session is established.
|
||||||
|
pub fn decrypt_credentials(conn: &mut SavedConnection) {
|
||||||
|
if let Some(ct) = conn.password.clone() {
|
||||||
|
conn.password = crypto::decrypt(&ct).ok();
|
||||||
|
}
|
||||||
|
if let Some(ct) = conn.passphrase.clone() {
|
||||||
|
conn.passphrase = crypto::decrypt(&ct).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts and returns the stored private key (PEM) for SSH authentication.
|
||||||
|
pub fn decrypt_key_data(conn: &SavedConnection) -> AppResult<Option<String>> {
|
||||||
|
crypto::decrypt_optional(&conn.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads sessions.json. Credential fields contain raw ciphertext; only `has_key_data` is derived.
|
||||||
|
pub fn load_sessions(app: &AppHandle) -> AppResult<SessionsConfig> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
let path = dir.join("sessions.json");
|
||||||
|
let mut config: SessionsConfig = load_json(&path)?;
|
||||||
|
|
||||||
|
for conn in &mut config.connections {
|
||||||
|
conn.has_key_data = conn.key.is_some();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves sessions config to disk (encrypted credentials are inline).
|
||||||
|
pub fn save_sessions(app: &AppHandle, config: &SessionsConfig) -> AppResult<()> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
save_json(&dir.join("sessions.json"), config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the main app config (sessions + groups).
|
||||||
|
pub fn load_config(app: &AppHandle) -> AppResult<AppConfig> {
|
||||||
|
load_sessions(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a single connection by ID and decrypts `password` and `passphrase` for SSH auth.
|
||||||
|
///
|
||||||
|
/// Returns `AppError::SessionNotFound` if no connection with that ID exists.
|
||||||
|
pub fn load_connection_by_id(app: &AppHandle, id: &str) -> AppResult<SavedConnection> {
|
||||||
|
let cfg = load_config(app)?;
|
||||||
|
let mut conn = cfg
|
||||||
|
.connections
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.ok_or_else(|| AppError::SessionNotFound(format!("Connection '{}' not found", id)))?;
|
||||||
|
decrypt_credentials(&mut conn);
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the main app config.
|
||||||
|
pub fn save_config(app: &AppHandle, config: &AppConfig) -> AppResult<()> {
|
||||||
|
save_sessions(app, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ui.json ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Layout and theme preferences persisted in ui.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UiConfig {
|
||||||
|
pub left_width: f64,
|
||||||
|
pub right_width: f64,
|
||||||
|
pub saved_conn_height: f64,
|
||||||
|
pub history_height: f64,
|
||||||
|
pub quick_cmd_height: f64,
|
||||||
|
pub show_file_explorer: bool,
|
||||||
|
pub show_right_sidebar: bool,
|
||||||
|
pub show_quick_commands: bool,
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_theme() -> Option<String> {
|
||||||
|
Some("github-dark".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
left_width: 256.0,
|
||||||
|
right_width: 288.0,
|
||||||
|
saved_conn_height: 240.0,
|
||||||
|
history_height: 200.0,
|
||||||
|
quick_cmd_height: 36.0,
|
||||||
|
show_file_explorer: true,
|
||||||
|
show_right_sidebar: true,
|
||||||
|
show_quick_commands: true,
|
||||||
|
theme: Some("github-dark".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads UI layout/theme config from ~/.dragonfly/ui.json.
|
||||||
|
pub fn load_ui_config(app: &AppHandle) -> AppResult<UiConfig> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
load_json(&dir.join("ui.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves UI config to disk.
|
||||||
|
pub fn save_ui_config(app: &AppHandle, config: &UiConfig) -> AppResult<()> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
save_json(&dir.join("ui.json"), config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── quick-command.json ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Single quick command (label + shell command).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuickCommand {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List of quick commands persisted in quick-command.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct QuickCommandsConfig {
|
||||||
|
pub commands: Vec<QuickCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads quick commands from ~/.dragonfly/quick-command.json.
|
||||||
|
pub fn load_quick_commands(app: &AppHandle) -> AppResult<QuickCommandsConfig> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
load_json(&dir.join("quick-command.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves quick commands to disk.
|
||||||
|
pub fn save_quick_commands(app: &AppHandle, config: &QuickCommandsConfig) -> AppResult<()> {
|
||||||
|
let dir = get_config_dir(app)?;
|
||||||
|
save_json(&dir.join("quick-command.json"), config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! AES-256-GCM encryption for SSH credentials stored in sessions.json.
|
||||||
|
//!
|
||||||
|
//! ## Key hierarchy
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! wrapping_key = SHA-256("dragonfly-key-wrap-v1:" || home_path)
|
||||||
|
//! master.key = base64( wrap_nonce[12] || AES-256-GCM(wrapping_key, master_key[32]) )
|
||||||
|
//! sessions.json = { "password": base64( nonce[12] || AES-256-GCM(master_key, plaintext) ), … }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Copying `master.key` to another machine/user account alone is not sufficient to decrypt
|
||||||
|
//! credentials — the wrapping key is derived from the local user's home directory path.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use aes_gcm::aead::{Aead, OsRng};
|
||||||
|
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn key_file_path() -> AppResult<PathBuf> {
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.ok_or_else(|| AppError::Crypto("cannot determine home directory".into()))?;
|
||||||
|
Ok(home.join(".dragonfly").join("master.key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the wrapping key from the user's home directory path.
|
||||||
|
///
|
||||||
|
/// `wrapping_key = SHA-256("dragonfly-key-wrap-v1:" || home_path)`
|
||||||
|
fn get_wrapping_key() -> AppResult<Key<Aes256Gcm>> {
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.ok_or_else(|| AppError::Crypto("cannot determine home directory".into()))?;
|
||||||
|
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(b"dragonfly-key-wrap-v1:");
|
||||||
|
h.update(home.to_string_lossy().as_bytes());
|
||||||
|
let digest = h.finalize();
|
||||||
|
|
||||||
|
Ok(*Key::<Aes256Gcm>::from_slice(&digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the master key from `~/.dragonfly/master.key`, creating it on first use.
|
||||||
|
///
|
||||||
|
/// The file stores the master key wrapped (AES-256-GCM) with the wrapping key.
|
||||||
|
fn get_master_key() -> AppResult<Key<Aes256Gcm>> {
|
||||||
|
let path = key_file_path()?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let encoded = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| AppError::Crypto(format!("read master.key: {e}")))?;
|
||||||
|
let raw = B64
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?;
|
||||||
|
|
||||||
|
if raw.len() < 13 {
|
||||||
|
return Err(AppError::Crypto("master.key file is malformed".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapping_key = get_wrapping_key()?;
|
||||||
|
let cipher = Aes256Gcm::new(&wrapping_key);
|
||||||
|
let (nonce_bytes, ciphertext) = raw.split_at(12);
|
||||||
|
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
|
||||||
|
let master_key_bytes = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| AppError::Crypto(format!("unwrap master.key: {e}")))?;
|
||||||
|
|
||||||
|
if master_key_bytes.len() != 32 {
|
||||||
|
return Err(AppError::Crypto("master key length mismatch".into()));
|
||||||
|
}
|
||||||
|
Ok(*Key::<Aes256Gcm>::from_slice(&master_key_bytes))
|
||||||
|
} else {
|
||||||
|
// First run: generate a random master key and wrap it for storage.
|
||||||
|
let master_key = Aes256Gcm::generate_key(OsRng);
|
||||||
|
|
||||||
|
let wrapping_key = get_wrapping_key()?;
|
||||||
|
let cipher = Aes256Gcm::new(&wrapping_key);
|
||||||
|
let wrap_nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
let wrapped = cipher
|
||||||
|
.encrypt(&wrap_nonce, master_key.as_slice())
|
||||||
|
.map_err(|e| AppError::Crypto(format!("wrap master.key: {e}")))?;
|
||||||
|
|
||||||
|
let mut combined = wrap_nonce.to_vec();
|
||||||
|
combined.extend_from_slice(&wrapped);
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, B64.encode(&combined))?;
|
||||||
|
|
||||||
|
Ok(master_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts `plaintext` with AES-256-GCM.
|
||||||
|
///
|
||||||
|
/// Returns `base64( nonce[12] || ciphertext+tag )`.
|
||||||
|
pub fn encrypt(plaintext: &str) -> AppResult<String> {
|
||||||
|
let key = get_master_key()?;
|
||||||
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut combined = nonce.to_vec();
|
||||||
|
combined.extend_from_slice(&ciphertext);
|
||||||
|
Ok(B64.encode(&combined))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts a `base64( nonce || ciphertext )` token produced by [`encrypt`].
|
||||||
|
pub fn decrypt(token: &str) -> AppResult<String> {
|
||||||
|
let key = get_master_key()?;
|
||||||
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
let raw = B64
|
||||||
|
.decode(token)
|
||||||
|
.map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?;
|
||||||
|
|
||||||
|
if raw.len() < 13 {
|
||||||
|
return Err(AppError::Crypto("ciphertext too short".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nonce_bytes, ciphertext) = raw.split_at(12);
|
||||||
|
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts an optional token, returning `None` when the input is `None` or empty.
|
||||||
|
pub fn decrypt_optional(token: &Option<String>) -> AppResult<Option<String>> {
|
||||||
|
match token {
|
||||||
|
Some(t) if !t.is_empty() => Ok(Some(decrypt(t)?)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Unified error type for Tauri commands.
|
||||||
|
//!
|
||||||
|
//! Provides `AppError` and `AppResult` for consistent error handling across the app.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Unified error type for all Tauri commands.
|
||||||
|
///
|
||||||
|
/// Implements `Serialize` so Tauri can pass the error message to the frontend.
|
||||||
|
/// Uses `thiserror` for ergonomic `From` conversions and `Display` formatting.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("SSH error: {0}")]
|
||||||
|
Ssh(#[from] russh::Error),
|
||||||
|
|
||||||
|
#[error("SSH key error: {0}")]
|
||||||
|
SshKey(#[from] russh_keys::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
SessionNotFound(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Channel(String),
|
||||||
|
|
||||||
|
#[error("Crypto error: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for AppError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias for `Result<T, AppError>`.
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Fuzzy search over command history using nucleo-matcher.
|
||||||
|
//!
|
||||||
|
//! Deduplicates and caps history size; persists to history.json.
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
|
||||||
|
use nucleo_matcher::{Config, Matcher, Utf32Str};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const MAX_HISTORY: usize = 5000;
|
||||||
|
|
||||||
|
/// Single fuzzy match with score and highlighted character indices.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FuzzyResult {
|
||||||
|
pub command: String,
|
||||||
|
pub score: u32,
|
||||||
|
pub indices: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory history store with dedup, persistence, and nucleo-based fuzzy search.
|
||||||
|
pub struct CommandHistoryStore {
|
||||||
|
commands: Vec<String>,
|
||||||
|
dedup: HashSet<String>,
|
||||||
|
dirty: bool,
|
||||||
|
history_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandHistoryStore {
|
||||||
|
/// Creates an empty store; call `set_history_path` before `load`.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
commands: Vec::new(),
|
||||||
|
dedup: HashSet::new(),
|
||||||
|
dirty: false,
|
||||||
|
history_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the JSON path for load/save; required before `load` or `save`.
|
||||||
|
pub fn set_history_path(&mut self, path: PathBuf) {
|
||||||
|
self.history_path = Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads history from disk; merges with existing without duplicates.
|
||||||
|
pub fn load(&mut self) -> AppResult<()> {
|
||||||
|
if let Some(path) = &self.history_path {
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
let cmds: Vec<String> = serde_json::from_str(&content)?;
|
||||||
|
for cmd in cmds {
|
||||||
|
if self.dedup.insert(cmd.clone()) {
|
||||||
|
self.commands.push(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists to disk only when dirty (after `add`).
|
||||||
|
pub fn save(&mut self) -> AppResult<()> {
|
||||||
|
if !self.dirty {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(path) = &self.history_path {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = serde_json::to_string(&self.commands)?;
|
||||||
|
fs::write(path, content)?;
|
||||||
|
self.dirty = false;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a command; moves duplicates to end and trims to MAX_HISTORY.
|
||||||
|
pub fn add(&mut self, command: String) {
|
||||||
|
let command = command.trim().to_string();
|
||||||
|
if command.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dedup.contains(&command) {
|
||||||
|
self.commands.retain(|c| c != &command);
|
||||||
|
} else {
|
||||||
|
self.dedup.insert(command.clone());
|
||||||
|
}
|
||||||
|
self.commands.push(command);
|
||||||
|
|
||||||
|
while self.commands.len() > MAX_HISTORY {
|
||||||
|
if let Some(removed) = self.commands.first().cloned() {
|
||||||
|
self.commands.remove(0);
|
||||||
|
self.dedup.remove(&removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuzzy search the history using nucleo-matcher.
|
||||||
|
/// Returns top `limit` results sorted by score (highest first).
|
||||||
|
/// At equal scores, more recent commands rank higher.
|
||||||
|
pub fn search(&self, pattern_str: &str, limit: usize) -> Vec<FuzzyResult> {
|
||||||
|
let pattern_str = pattern_str.trim();
|
||||||
|
if pattern_str.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = Pattern::new(
|
||||||
|
pattern_str,
|
||||||
|
CaseMatching::Smart,
|
||||||
|
Normalization::Smart,
|
||||||
|
AtomKind::Fuzzy,
|
||||||
|
);
|
||||||
|
|
||||||
|
if pattern.atoms.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
let mut scored: Vec<(usize, u32)> = Vec::new();
|
||||||
|
for (idx, cmd) in self.commands.iter().enumerate() {
|
||||||
|
let haystack = Utf32Str::new(cmd, &mut buf);
|
||||||
|
if let Some(score) = pattern.score(haystack, &mut matcher) {
|
||||||
|
scored.push((idx, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
|
||||||
|
scored.truncate(limit);
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(scored.len());
|
||||||
|
for (idx, score) in scored {
|
||||||
|
let cmd = &self.commands[idx];
|
||||||
|
let haystack = Utf32Str::new(cmd, &mut buf);
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
pattern.indices(haystack, &mut matcher, &mut indices);
|
||||||
|
indices.sort_unstable();
|
||||||
|
indices.dedup();
|
||||||
|
|
||||||
|
results.push(FuzzyResult {
|
||||||
|
command: cmd.clone(),
|
||||||
|
score,
|
||||||
|
indices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! Tauri command handlers and app entry point.
|
||||||
|
//!
|
||||||
|
//! Registers all invoke handlers, manages SessionManager state, and sets up tracing.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod crypto;
|
||||||
|
mod error;
|
||||||
|
mod fuzzy;
|
||||||
|
mod pty;
|
||||||
|
mod session;
|
||||||
|
mod sftp;
|
||||||
|
mod ssh;
|
||||||
|
|
||||||
|
use config::{Group, QuickCommand, QuickCommandsConfig, SavedConnection, UiConfig};
|
||||||
|
use error::{AppError, AppResult};
|
||||||
|
use fuzzy::FuzzyResult;
|
||||||
|
use session::{SessionCommand, SessionManager};
|
||||||
|
use ssh::{SshAuth, SshConfig};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::Manager;
|
||||||
|
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let log_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join("dragonfly")
|
||||||
|
.join("logs");
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::daily(&log_dir, "dragonfly.log");
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("dragonfly=debug,warn"));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_writer(file_appender)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(true),
|
||||||
|
)
|
||||||
|
.with(fmt::layer().with_writer(std::io::stderr).compact())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("Dragonfly starting");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tauri Commands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_ssh_session(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let conn = config::load_connection_by_id(&app, &connection_id)?;
|
||||||
|
|
||||||
|
let auth = match conn.auth_type.as_str() {
|
||||||
|
"password" => SshAuth::Password {
|
||||||
|
password: conn
|
||||||
|
.password
|
||||||
|
.ok_or_else(|| AppError::Auth("No password saved for this connection. Please edit and re-save it.".to_string()))?,
|
||||||
|
},
|
||||||
|
"key" => {
|
||||||
|
let key_data = config::decrypt_key_data(&conn)?
|
||||||
|
.ok_or_else(|| AppError::Auth("No private key stored for this connection.".to_string()))?;
|
||||||
|
SshAuth::Key {
|
||||||
|
key_data,
|
||||||
|
passphrase: conn.passphrase,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other => return Err(AppError::Auth(format!("Unknown auth type: {}", other))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ssh_config = SshConfig {
|
||||||
|
name: conn.name,
|
||||||
|
host: conn.host,
|
||||||
|
port: conn.port,
|
||||||
|
username: conn.username,
|
||||||
|
auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
ssh::create_ssh_session(app, state.inner().clone(), ssh_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_local_session(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
pty::create_local_session(app, state.inner().clone()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn write_to_session(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
data: String,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
state
|
||||||
|
.send_command(&session_id, SessionCommand::Write(data.into_bytes()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn resize_session(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
cols: u32,
|
||||||
|
rows: u32,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
state
|
||||||
|
.send_command(&session_id, SessionCommand::Resize { cols, rows })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn attach_session(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
state
|
||||||
|
.send_command(&session_id, SessionCommand::Attach)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn close_session(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
state
|
||||||
|
.send_command(&session_id, SessionCommand::Close)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_sessions(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
) -> AppResult<Vec<session::SessionInfo>> {
|
||||||
|
Ok(state.list_sessions().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn add_command_history(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
command: String,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
state.add_command(&session_id, command).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_command_history(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
) -> AppResult<Vec<String>> {
|
||||||
|
Ok(state.get_all_history().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fuzzy_search_history(
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
pattern: String,
|
||||||
|
limit: usize,
|
||||||
|
) -> AppResult<Vec<FuzzyResult>> {
|
||||||
|
Ok(state.fuzzy_search(&pattern, limit).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SFTP Commands ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_home_dir(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
sftp::get_home_dir(app, state.inner().clone(), &session_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_remote_dir(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, Arc<SessionManager>>,
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
) -> AppResult<Vec<sftp::FileEntry>> {
|
||||||
|
sftp::list_remote_dir(app, state.inner().clone(), &session_id, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config Commands ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_saved_connections(app: tauri::AppHandle) -> AppResult<Vec<SavedConnection>> {
|
||||||
|
let mut cfg = config::load_config(&app)?;
|
||||||
|
for conn in &mut cfg.connections {
|
||||||
|
// Clear ciphertext — frontend only needs has_key_data, not the raw bytes.
|
||||||
|
conn.password = None;
|
||||||
|
conn.passphrase = None;
|
||||||
|
conn.key = None;
|
||||||
|
}
|
||||||
|
Ok(cfg.connections)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_connection(app: tauri::AppHandle, mut connection: SavedConnection) -> AppResult<String> {
|
||||||
|
let mut cfg = config::load_config(&app)?;
|
||||||
|
|
||||||
|
if connection.id.is_empty() {
|
||||||
|
connection.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
let target_id = connection.id.clone();
|
||||||
|
let existing = cfg.connections.iter().find(|c| c.id == target_id);
|
||||||
|
|
||||||
|
// Encrypt new password, or preserve existing ciphertext if none provided.
|
||||||
|
connection.password = match connection.password.as_deref() {
|
||||||
|
Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?),
|
||||||
|
_ => existing.and_then(|e| e.password.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypt new passphrase, or preserve existing ciphertext.
|
||||||
|
connection.passphrase = match connection.passphrase.as_deref() {
|
||||||
|
Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?),
|
||||||
|
_ => existing.and_then(|e| e.passphrase.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read and encrypt key file, or preserve existing ciphertext.
|
||||||
|
connection.key = match connection.key_file_path.as_deref() {
|
||||||
|
Some(path) => {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| AppError::Config(format!("failed to read key file: {e}")))?;
|
||||||
|
Some(crypto::encrypt(&content)?)
|
||||||
|
}
|
||||||
|
None => existing.and_then(|e| e.key.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ex) = cfg.connections.iter_mut().find(|c| c.id == target_id) {
|
||||||
|
*ex = connection;
|
||||||
|
} else {
|
||||||
|
cfg.connections.push(connection);
|
||||||
|
}
|
||||||
|
config::save_config(&app, &cfg)?;
|
||||||
|
Ok(target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_connection(app: tauri::AppHandle, id: String) -> AppResult<()> {
|
||||||
|
let mut cfg = config::load_config(&app)?;
|
||||||
|
cfg.connections.retain(|c| c.id != id);
|
||||||
|
config::save_config(&app, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group Commands ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_groups(app: tauri::AppHandle) -> AppResult<Vec<Group>> {
|
||||||
|
let cfg = config::load_config(&app)?;
|
||||||
|
Ok(cfg.groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_group(app: tauri::AppHandle, mut group: Group) -> AppResult<String> {
|
||||||
|
let mut cfg = config::load_config(&app)?;
|
||||||
|
|
||||||
|
if group.id.is_empty() {
|
||||||
|
group.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
let target_id = group.id.clone();
|
||||||
|
|
||||||
|
if let Some(existing) = cfg.groups.iter_mut().find(|g| g.id == target_id) {
|
||||||
|
*existing = group;
|
||||||
|
} else {
|
||||||
|
cfg.groups.push(group);
|
||||||
|
}
|
||||||
|
config::save_config(&app, &cfg)?;
|
||||||
|
Ok(target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_group(app: tauri::AppHandle, id: String) -> AppResult<()> {
|
||||||
|
let mut cfg = config::load_config(&app)?;
|
||||||
|
let group_name = cfg.groups.iter().find(|g| g.id == id).map(|g| g.name.clone());
|
||||||
|
cfg.groups.retain(|g| g.id != id);
|
||||||
|
if let Some(name) = group_name {
|
||||||
|
for conn in &mut cfg.connections {
|
||||||
|
if conn.group.as_deref() == Some(&name) {
|
||||||
|
conn.group = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config::save_config(&app, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI Config Commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_ui_config(app: tauri::AppHandle) -> AppResult<UiConfig> {
|
||||||
|
config::load_ui_config(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_ui_config(app: tauri::AppHandle, config: UiConfig) -> AppResult<()> {
|
||||||
|
config::save_ui_config(&app, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick Command Commands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_quick_commands(app: tauri::AppHandle) -> AppResult<Vec<QuickCommand>> {
|
||||||
|
let cfg = config::load_quick_commands(&app)?;
|
||||||
|
Ok(cfg.commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_quick_commands(app: tauri::AppHandle, commands: Vec<QuickCommand>) -> AppResult<()> {
|
||||||
|
let cfg = QuickCommandsConfig { commands };
|
||||||
|
config::save_quick_commands(&app, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App Entry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Initializes tracing, builds the Tauri app, and runs the event loop.
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let session_manager = Arc::new(SessionManager::new());
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(session_manager.clone())
|
||||||
|
.setup(move |app| {
|
||||||
|
let home_dir = app
|
||||||
|
.path()
|
||||||
|
.home_dir()
|
||||||
|
.map_err(|e: tauri::Error| e.to_string())?;
|
||||||
|
let config_dir = home_dir.join(".dragonfly");
|
||||||
|
let mgr = session_manager.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
mgr.init_history_store(config_dir).await;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
create_ssh_session,
|
||||||
|
create_local_session,
|
||||||
|
write_to_session,
|
||||||
|
resize_session,
|
||||||
|
attach_session,
|
||||||
|
close_session,
|
||||||
|
list_sessions,
|
||||||
|
add_command_history,
|
||||||
|
get_command_history,
|
||||||
|
fuzzy_search_history,
|
||||||
|
get_home_dir,
|
||||||
|
list_remote_dir,
|
||||||
|
get_saved_connections,
|
||||||
|
save_connection,
|
||||||
|
delete_connection,
|
||||||
|
get_groups,
|
||||||
|
save_group,
|
||||||
|
delete_group,
|
||||||
|
get_ui_config,
|
||||||
|
save_ui_config,
|
||||||
|
get_quick_commands,
|
||||||
|
save_quick_commands,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
//! Windows subsystem configuration and entry.
|
||||||
|
//! Delegates to `dragonfly_lib::run()` for the actual app.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dragonfly_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Local PTY (pseudo-terminal) session creation and management.
|
||||||
|
//!
|
||||||
|
//! Spawns the user's shell (PowerShell on Windows, $SHELL elsewhere) and bridges I/O to Tauri.
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::session::{SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType};
|
||||||
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
struct OutputBuffer {
|
||||||
|
attached: bool,
|
||||||
|
buffer: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_default_shell() -> CommandBuilder {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
CommandBuilder::new("powershell.exe")
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||||
|
CommandBuilder::new(shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a local shell in a PTY and registers the session with the manager.
|
||||||
|
pub async fn create_local_session(
|
||||||
|
app: AppHandle,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
tracing::info!("Creating local PTY session");
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
|
||||||
|
|
||||||
|
let session_info = SessionInfo {
|
||||||
|
id: session_id.clone(),
|
||||||
|
name: "Local Terminal".to_string(),
|
||||||
|
session_type: SessionType::Local,
|
||||||
|
connected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_handle = SessionHandle {
|
||||||
|
info: session_info,
|
||||||
|
cmd_tx,
|
||||||
|
ssh_config: None,
|
||||||
|
};
|
||||||
|
manager.add_session(session_handle).await;
|
||||||
|
|
||||||
|
let sid = session_id.clone();
|
||||||
|
let mgr = manager.clone();
|
||||||
|
let rt_handle = tokio::runtime::Handle::current();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
pty_session_thread(app, sid, mgr, cmd_rx, rt_handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pty_session_thread(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||||
|
rt_handle: tokio::runtime::Handle,
|
||||||
|
) {
|
||||||
|
let pty_system = native_pty_system();
|
||||||
|
let pair = match pty_system.openpty(PtySize {
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
}) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to open PTY: {}", e);
|
||||||
|
let _ = app.emit(
|
||||||
|
&format!("session-error-{}", session_id),
|
||||||
|
format!("Failed to open PTY: {}", e),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = get_default_shell();
|
||||||
|
let mut _child = match pair.slave.spawn_command(cmd) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to spawn shell: {}", e);
|
||||||
|
let _ = app.emit(
|
||||||
|
&format!("session-error-{}", session_id),
|
||||||
|
format!("Failed to spawn shell: {}", e),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let mut writer = match pair.master.take_writer() {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to take PTY writer: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut reader = match pair.master.try_clone_reader() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to clone PTY reader: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let master = pair.master;
|
||||||
|
|
||||||
|
let output_buf = Arc::new(Mutex::new(OutputBuffer {
|
||||||
|
attached: false,
|
||||||
|
buffer: Vec::new(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let app_read = app.clone();
|
||||||
|
let sid_read = session_id.clone();
|
||||||
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
|
let buf_reader = output_buf.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
let text = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||||
|
let emit_now = {
|
||||||
|
let mut ob = buf_reader.lock().unwrap();
|
||||||
|
if ob.attached {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
ob.buffer.push(text.clone());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if emit_now {
|
||||||
|
let _ = app_read.emit(&output_event, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app_read.emit(&format!("session-closed-{}", sid_read), ());
|
||||||
|
});
|
||||||
|
|
||||||
|
let output_event_cmd = format!("terminal-output-{}", session_id);
|
||||||
|
while let Some(cmd) = cmd_rx.blocking_recv() {
|
||||||
|
match cmd {
|
||||||
|
SessionCommand::Attach => {
|
||||||
|
let buffered = {
|
||||||
|
let mut ob = output_buf.lock().unwrap();
|
||||||
|
ob.attached = true;
|
||||||
|
ob.buffer.drain(..).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for text in buffered {
|
||||||
|
let _ = app.emit(&output_event_cmd, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionCommand::Write(data) => {
|
||||||
|
let _ = writer.write_all(&data);
|
||||||
|
let _ = writer.flush();
|
||||||
|
}
|
||||||
|
SessionCommand::Resize { cols, rows } => {
|
||||||
|
let _ = master.resize(PtySize {
|
||||||
|
rows: rows as u16,
|
||||||
|
cols: cols as u16,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SessionCommand::Close => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rt_handle.block_on(async {
|
||||||
|
manager.remove_session(&session_id).await;
|
||||||
|
});
|
||||||
|
let _ = app.emit(&format!("session-closed-{}", session_id), ());
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Session manager holding active sessions and command history.
|
||||||
|
//!
|
||||||
|
//! Tracks SSH/local sessions, routes commands, and persists history for fuzzy search.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::fuzzy::{CommandHistoryStore, FuzzyResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
/// Distinguishes SSH vs local PTY sessions for UI and routing.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SessionType {
|
||||||
|
SSH,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for a session exposed to the frontend.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub session_type: SessionType,
|
||||||
|
pub connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands sent from the frontend to a session's I/O loop.
|
||||||
|
pub enum SessionCommand {
|
||||||
|
/// Frontend listener is ready — flush buffered output and start emitting.
|
||||||
|
Attach,
|
||||||
|
/// User input to send to the terminal.
|
||||||
|
Write(Vec<u8>),
|
||||||
|
/// Terminal size change (cols × rows).
|
||||||
|
Resize { cols: u32, rows: u32 },
|
||||||
|
/// Close the session and clean up.
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to an active session; used to send commands and access SSH config for SFTP.
|
||||||
|
pub struct SessionHandle {
|
||||||
|
pub info: SessionInfo,
|
||||||
|
pub cmd_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
|
/// SSH-specific: stores config for opening new connections (SFTP, exec)
|
||||||
|
pub ssh_config: Option<Arc<dyn Any + Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central registry of sessions, history, and fuzzy search store.
|
||||||
|
pub struct SessionManager {
|
||||||
|
pub sessions: Arc<Mutex<HashMap<String, SessionHandle>>>,
|
||||||
|
pub command_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||||
|
pub history_store: Arc<Mutex<CommandHistoryStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
/// Creates an empty manager; history store is initialized in setup.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
command_history: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
history_store: Arc::new(Mutex::new(CommandHistoryStore::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads history from config_dir/history.json for fuzzy search.
|
||||||
|
pub async fn init_history_store(&self, config_dir: PathBuf) {
|
||||||
|
let mut store = self.history_store.lock().await;
|
||||||
|
store.set_history_path(config_dir.join("history.json"));
|
||||||
|
if let Err(e) = store.load() {
|
||||||
|
tracing::warn!("Failed to load command history: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a new session and allocates empty command history.
|
||||||
|
pub async fn add_session(&self, handle: SessionHandle) {
|
||||||
|
let id = handle.info.id.clone();
|
||||||
|
self.sessions.lock().await.insert(id.clone(), handle);
|
||||||
|
self.command_history.lock().await.insert(id, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes session and its history; returns true if the session existed.
|
||||||
|
pub async fn remove_session(&self, id: &str) -> bool {
|
||||||
|
self.command_history.lock().await.remove(id);
|
||||||
|
self.sessions.lock().await.remove(id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a command to a session's I/O loop; errors if session not found.
|
||||||
|
pub async fn send_command(&self, id: &str, cmd: SessionCommand) -> AppResult<()> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
if let Some(handle) = sessions.get(id) {
|
||||||
|
handle
|
||||||
|
.cmd_tx
|
||||||
|
.send(cmd)
|
||||||
|
.map_err(|e| AppError::Channel(e.to_string()))
|
||||||
|
} else {
|
||||||
|
Err(AppError::SessionNotFound(format!(
|
||||||
|
"Session '{}' not found",
|
||||||
|
id
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns metadata for all active sessions.
|
||||||
|
pub async fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
sessions.values().map(|h| h.info.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a command to per-session history and persists to the fuzzy store.
|
||||||
|
pub async fn add_command(&self, session_id: &str, command: String) {
|
||||||
|
{
|
||||||
|
let mut history = self.command_history.lock().await;
|
||||||
|
if let Some(cmds) = history.get_mut(session_id) {
|
||||||
|
cmds.push(command.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut store = self.history_store.lock().await;
|
||||||
|
store.add(command);
|
||||||
|
if let Err(e) = store.save() {
|
||||||
|
tracing::warn!("Failed to save command history: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all commands from all sessions (for history UI).
|
||||||
|
pub async fn get_all_history(&self) -> Vec<String> {
|
||||||
|
let history = self.command_history.lock().await;
|
||||||
|
let mut all: Vec<String> = Vec::new();
|
||||||
|
for cmds in history.values() {
|
||||||
|
all.extend(cmds.iter().cloned());
|
||||||
|
}
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuzzy searches command history; returns top `limit` matches by score.
|
||||||
|
pub async fn fuzzy_search(&self, pattern: &str, limit: usize) -> Vec<FuzzyResult> {
|
||||||
|
let store = self.history_store.lock().await;
|
||||||
|
store.search(pattern, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! Remote file listing via SSH exec (ls -la) commands.
|
||||||
|
//!
|
||||||
|
//! Opens temporary connections using existing session credentials; no SFTP subsystem.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::session::SessionManager;
|
||||||
|
use crate::ssh::{SshAuth, SshConfig, SshHandler};
|
||||||
|
use russh::client;
|
||||||
|
use russh::ChannelMsg;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Parsed entry from `ls -la` output for the file explorer.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub size: u64,
|
||||||
|
pub permissions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_tmp_connection(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
manager: &SessionManager,
|
||||||
|
session_id: &str,
|
||||||
|
) -> AppResult<client::Handle<SshHandler>> {
|
||||||
|
let config = {
|
||||||
|
let sessions = manager.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| AppError::SessionNotFound(format!("Session '{}' not found", session_id)))?;
|
||||||
|
|
||||||
|
session
|
||||||
|
.ssh_config
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::Config("Not an SSH session".to_string()))?
|
||||||
|
.clone()
|
||||||
|
.downcast::<SshConfig>()
|
||||||
|
.map_err(|_| AppError::Config("Failed to get SSH config".to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let ssh_config = Arc::new(client::Config::default());
|
||||||
|
let mut handle = client::connect(
|
||||||
|
ssh_config,
|
||||||
|
(config.host.as_str(), config.port),
|
||||||
|
SshHandler::new(app.clone(), config.host.clone(), config.port),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("SSH connection failed: {}", e)))?;
|
||||||
|
|
||||||
|
match &config.auth {
|
||||||
|
SshAuth::Password { password } => {
|
||||||
|
let ok = handle
|
||||||
|
.authenticate_password(&config.username, password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("Auth failed: {}", e)))?;
|
||||||
|
if !ok {
|
||||||
|
return Err(AppError::Auth(
|
||||||
|
"Auth failed: invalid credentials".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SshAuth::Key {
|
||||||
|
key_data,
|
||||||
|
passphrase,
|
||||||
|
} => {
|
||||||
|
let key = russh_keys::decode_secret_key(key_data, passphrase.as_deref())?;
|
||||||
|
let ok = handle
|
||||||
|
.authenticate_publickey(&config.username, Arc::new(key))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("Key auth failed: {}", e)))?;
|
||||||
|
if !ok {
|
||||||
|
return Err(AppError::Auth("Auth failed: key rejected".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_remote_command(
|
||||||
|
handle: &client::Handle<SshHandler>,
|
||||||
|
cmd: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let channel = handle
|
||||||
|
.channel_open_session()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Channel(format!("Failed to open channel: {}", e)))?;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.exec(true, cmd.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Channel(format!("Exec failed: {}", e)))?;
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut channel = channel;
|
||||||
|
while let Some(msg) = channel.wait().await {
|
||||||
|
match msg {
|
||||||
|
ChannelMsg::Data { ref data } => {
|
||||||
|
output.push_str(&String::from_utf8_lossy(data));
|
||||||
|
}
|
||||||
|
ChannelMsg::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves $HOME on the remote host for default file explorer path.
|
||||||
|
pub async fn get_home_dir(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
session_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let handle = open_tmp_connection(&app, &manager, session_id).await?;
|
||||||
|
let output = exec_remote_command(&handle, "echo $HOME").await?;
|
||||||
|
let _ = handle
|
||||||
|
.disconnect(russh::Disconnect::ByApplication, "", "English")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let home = output.trim().to_string();
|
||||||
|
if home.is_empty() {
|
||||||
|
Err(AppError::Config(
|
||||||
|
"Failed to determine home directory".to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists a remote directory via `ls -la`; parses output into `FileEntry`s.
|
||||||
|
pub async fn list_remote_dir(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
session_id: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> AppResult<Vec<FileEntry>> {
|
||||||
|
let handle = open_tmp_connection(&app, &manager, session_id).await?;
|
||||||
|
|
||||||
|
let escaped = build_path_for_shell(path);
|
||||||
|
let cmd = format!(
|
||||||
|
"ls -la --color=never {} 2>/dev/null || ls -la {} 2>/dev/null",
|
||||||
|
escaped, escaped
|
||||||
|
);
|
||||||
|
let output = exec_remote_command(&handle, &cmd).await?;
|
||||||
|
let _ = handle
|
||||||
|
.disconnect(russh::Disconnect::ByApplication, "", "English")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let entries = parse_ls_output(&output);
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a shell-safe path string.
|
||||||
|
/// `~` and `~/...` are left unquoted so the remote shell expands them;
|
||||||
|
/// everything else is single-quote escaped.
|
||||||
|
fn build_path_for_shell(path: &str) -> String {
|
||||||
|
if path == "~" {
|
||||||
|
"~".to_string()
|
||||||
|
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
format!("~/{}", shell_escape(rest))
|
||||||
|
} else {
|
||||||
|
shell_escape(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ls_output(output: &str) -> Vec<FileEntry> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
if line.starts_with("total ") || line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 9 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let permissions = parts[0].to_string();
|
||||||
|
let is_dir = permissions.starts_with('d');
|
||||||
|
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||||
|
let name = parts[8..].join(" ");
|
||||||
|
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = name.trim_end_matches(|c| c == '@' || c == '/' || c == '*');
|
||||||
|
|
||||||
|
entries.push(FileEntry {
|
||||||
|
name: name.to_string(),
|
||||||
|
is_dir,
|
||||||
|
size,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
//! SSH session creation, TOFU known_hosts verification, and I/O loop.
|
||||||
|
//!
|
||||||
|
//! Uses russh for connection/auth and emits terminal output via Tauri events.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::session::{SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use russh::client;
|
||||||
|
use russh::ChannelMsg;
|
||||||
|
use russh_keys::PublicKeyBase64;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Connection parameters for SSH (host, port, user, auth method).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SshConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub username: String,
|
||||||
|
pub auth: SshAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication method: password or key (with optional passphrase).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum SshAuth {
|
||||||
|
#[serde(rename = "password")]
|
||||||
|
Password { password: String },
|
||||||
|
#[serde(rename = "key")]
|
||||||
|
Key {
|
||||||
|
key_data: String,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// russh client handler; performs TOFU known_hosts verification.
|
||||||
|
pub(crate) struct SshHandler {
|
||||||
|
app: AppHandle,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshHandler {
|
||||||
|
pub fn new(app: AppHandle, host: String, port: u16) -> Self {
|
||||||
|
Self { app, host, port }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_known_hosts_path(&self) -> Option<std::path::PathBuf> {
|
||||||
|
self.app
|
||||||
|
.path()
|
||||||
|
.home_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|h: std::path::PathBuf| h.join(".dragonfly").join("known_hosts"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl client::Handler for SshHandler {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn check_server_key(
|
||||||
|
&mut self,
|
||||||
|
server_public_key: &russh_keys::key::PublicKey,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
let path = match self.get_known_hosts_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_type = server_public_key.name();
|
||||||
|
let key_base64 = server_public_key.public_key_base64();
|
||||||
|
let fingerprint = server_public_key.fingerprint();
|
||||||
|
|
||||||
|
let host_identifier = if self.port != 22 {
|
||||||
|
format!("[{}]:{}", self.host, self.port)
|
||||||
|
} else {
|
||||||
|
self.host.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let host_entry = format!("{} {} {}", host_identifier, key_type, key_base64);
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 3 && parts[0] == host_identifier {
|
||||||
|
if parts[1] == key_type && parts[2] == key_base64 {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
// Host key mismatch — potential MITM
|
||||||
|
let _ = self.app.emit(
|
||||||
|
"ssh-error",
|
||||||
|
format!(
|
||||||
|
"SECURITY ALERT: Host key for {}:{} has changed! New fingerprint: {}",
|
||||||
|
self.host, self.port, fingerprint
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOFU: trust on first use — add new host key
|
||||||
|
use std::io::Write;
|
||||||
|
if let Ok(mut file) =
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
if let Err(e) = writeln!(file, "{}", host_entry) {
|
||||||
|
let _ = self.app.emit(
|
||||||
|
"ssh-error",
|
||||||
|
format!("Failed to save known_hosts: {}", e),
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects via SSH, opens a PTY shell, and spawns the I/O loop.
|
||||||
|
pub async fn create_ssh_session(
|
||||||
|
app: AppHandle,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
config: SshConfig,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
tracing::info!(host = %config.host, port = config.port, user = %config.username, "Creating SSH session");
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
|
||||||
|
|
||||||
|
let ssh_config = Arc::new(client::Config::default());
|
||||||
|
let handler = SshHandler::new(app.clone(), config.host.clone(), config.port);
|
||||||
|
|
||||||
|
let mut handle = client::connect(ssh_config, (config.host.as_str(), config.port), handler)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("SSH connection failed: {}", e)))?;
|
||||||
|
|
||||||
|
match &config.auth {
|
||||||
|
SshAuth::Password { password } => {
|
||||||
|
let authenticated = handle
|
||||||
|
.authenticate_password(&config.username, password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("Authentication failed: {}", e)))?;
|
||||||
|
if !authenticated {
|
||||||
|
return Err(AppError::Auth(
|
||||||
|
"Authentication failed: invalid credentials".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SshAuth::Key {
|
||||||
|
key_data,
|
||||||
|
passphrase,
|
||||||
|
} => {
|
||||||
|
let key = russh_keys::decode_secret_key(key_data, passphrase.as_deref())?;
|
||||||
|
let authenticated = handle
|
||||||
|
.authenticate_publickey(&config.username, Arc::new(key))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Auth(format!("Key authentication failed: {}", e)))?;
|
||||||
|
if !authenticated {
|
||||||
|
return Err(AppError::Auth(
|
||||||
|
"Authentication failed: key rejected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = handle
|
||||||
|
.channel_open_session()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Channel(format!("Failed to open channel: {}", e)))?;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.request_pty(false, "xterm-256color", 80, 24, 0, 0, &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Channel(format!("PTY request failed: {}", e)))?;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.request_shell(false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Channel(format!("Shell request failed: {}", e)))?;
|
||||||
|
|
||||||
|
let session_info = SessionInfo {
|
||||||
|
id: session_id.clone(),
|
||||||
|
name: config.name.clone(),
|
||||||
|
session_type: SessionType::SSH,
|
||||||
|
connected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ssh_config_arc: Arc<dyn std::any::Any + Send + Sync> = Arc::new(config.clone());
|
||||||
|
|
||||||
|
let session_handle = SessionHandle {
|
||||||
|
info: session_info,
|
||||||
|
cmd_tx,
|
||||||
|
ssh_config: Some(ssh_config_arc),
|
||||||
|
};
|
||||||
|
manager.add_session(session_handle).await;
|
||||||
|
|
||||||
|
let sid = session_id.clone();
|
||||||
|
let mgr = manager.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
ssh_io_loop(app, sid, mgr, channel, handle, cmd_rx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(session_id = %session_id, "SSH session created");
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ssh_io_loop(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
manager: Arc<SessionManager>,
|
||||||
|
mut channel: russh::Channel<client::Msg>,
|
||||||
|
_handle: client::Handle<SshHandler>,
|
||||||
|
mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||||
|
) {
|
||||||
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
|
let closed_event = format!("session-closed-{}", session_id);
|
||||||
|
|
||||||
|
let mut attached = false;
|
||||||
|
let mut buffer: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
match cmd {
|
||||||
|
Some(SessionCommand::Attach) => {
|
||||||
|
attached = true;
|
||||||
|
for text in buffer.drain(..) {
|
||||||
|
let _ = app.emit(&output_event, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(SessionCommand::Write(data)) => {
|
||||||
|
let _ = channel.data(&data[..]).await;
|
||||||
|
}
|
||||||
|
Some(SessionCommand::Resize { cols, rows }) => {
|
||||||
|
let _ = channel.window_change(cols, rows, 0, 0).await;
|
||||||
|
}
|
||||||
|
Some(SessionCommand::Close) | None => {
|
||||||
|
let _ = channel.close().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = channel.wait() => {
|
||||||
|
match msg {
|
||||||
|
Some(ChannelMsg::Data { ref data }) => {
|
||||||
|
let text = String::from_utf8_lossy(data).to_string();
|
||||||
|
if attached {
|
||||||
|
let _ = app.emit(&output_event, &text);
|
||||||
|
} else {
|
||||||
|
buffer.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
||||||
|
let text = String::from_utf8_lossy(data).to_string();
|
||||||
|
if attached {
|
||||||
|
let _ = app.emit(&output_event, &text);
|
||||||
|
} else {
|
||||||
|
buffer.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::Eof) | None => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.remove_session(&session_id).await;
|
||||||
|
tracing::info!(session_id = %session_id, "SSH session closed");
|
||||||
|
let _ = app.emit(&closed_event, ());
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "dragonfly",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.kang.dragonfly",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Dragonfly Terminal",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import NewSessionDialog from "./components/dialog/NewSessionDialog";
|
||||||
|
import Header from "./components/layout/Header";
|
||||||
|
import ResizeHandle from "./components/layout/ResizeHandle";
|
||||||
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
|
import ActiveSessions from "./components/panel/ActiveSessions";
|
||||||
|
import CommandHistory from "./components/panel/CommandHistory";
|
||||||
|
import SavedConnections from "./components/panel/SavedConnections";
|
||||||
|
import FileExplorer from "./components/sidebar/FileExplorer";
|
||||||
|
import QuickCommands from "./components/terminal/QuickCommands";
|
||||||
|
import TabBar from "./components/terminal/TabBar";
|
||||||
|
import XTerminal from "./components/terminal/XTerminal";
|
||||||
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
|
import { ToastProvider } from "./components/toast/ToastContext";
|
||||||
|
import { useApp } from "./context/AppContext";
|
||||||
|
import type { SavedConnection } from "./types";
|
||||||
|
|
||||||
|
/** Root layout: header, sidebars, terminal area, dialogs. Wraps content in ToastProvider. */
|
||||||
|
function App() {
|
||||||
|
const {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
setActiveTabId,
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
|
uiConfig,
|
||||||
|
updateUiConfig,
|
||||||
|
// savedConnections, // consumed by ActiveSessions/SavedConnections if refactored
|
||||||
|
showNewSession,
|
||||||
|
setShowNewSession,
|
||||||
|
editingConnection,
|
||||||
|
setEditingConnection,
|
||||||
|
refreshConnections,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// Mobile state
|
||||||
|
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||||
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||||
|
|
||||||
|
const activeTab = tabs.find((t) => t.id === activeTabId) ?? null;
|
||||||
|
|
||||||
|
const handleNewSession = () => {
|
||||||
|
setEditingConnection(undefined);
|
||||||
|
setShowNewSession(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditConnection = useCallback(
|
||||||
|
(conn: SavedConnection) => {
|
||||||
|
setEditingConnection(conn);
|
||||||
|
setShowNewSession(true);
|
||||||
|
},
|
||||||
|
[setEditingConnection, setShowNewSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle connection success from Dialog or SavedConnections
|
||||||
|
const handleSessionConnected = useCallback(
|
||||||
|
(sessionId: string, name: string, type_: "SSH" | "Local") => {
|
||||||
|
addTab(sessionId, name, type_);
|
||||||
|
setShowNewSession(false);
|
||||||
|
setEditingConnection(undefined);
|
||||||
|
},
|
||||||
|
[addTab, setShowNewSession, setEditingConnection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSessionClick = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
const tab = tabs.find((t) => t.sessionId === sessionId);
|
||||||
|
if (tab) {
|
||||||
|
setActiveTabId(tab.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tabs, setActiveTabId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHistoryCommand = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (activeTab) {
|
||||||
|
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||||
|
invoke("write_to_session", {
|
||||||
|
sessionId: activeTab.sessionId,
|
||||||
|
data: `${command}\r`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resize handlers
|
||||||
|
const handleLeftResize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
updateUiConfig({ left_width: Math.max(160, Math.min(480, uiConfig.left_width + delta)) });
|
||||||
|
},
|
||||||
|
[updateUiConfig, uiConfig.left_width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRightResize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
updateUiConfig({ right_width: Math.max(200, Math.min(480, uiConfig.right_width - delta)) });
|
||||||
|
},
|
||||||
|
[updateUiConfig, uiConfig.right_width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSavedConnResize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
updateUiConfig({
|
||||||
|
saved_conn_height: Math.max(80, Math.min(500, uiConfig.saved_conn_height + delta)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateUiConfig, uiConfig.saved_conn_height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHistoryResize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
// delta > 0 means mouse moves down → shrink history (it's at the bottom in flex col, but here it's actually growing/shrinking height)
|
||||||
|
// Checking previous logic: setHistoryHeight((h) => Math.max(80, Math.min(500, h - delta)));
|
||||||
|
updateUiConfig({
|
||||||
|
history_height: Math.max(80, Math.min(500, uiConfig.history_height - delta)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateUiConfig, uiConfig.history_height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQuickCmdResize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
updateUiConfig({
|
||||||
|
quick_cmd_height: Math.max(36, Math.min(300, uiConfig.quick_cmd_height - delta)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateUiConfig, uiConfig.quick_cmd_height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<div
|
||||||
|
className="font-display h-screen flex flex-col overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Header
|
||||||
|
onNewSession={handleNewSession}
|
||||||
|
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
|
||||||
|
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex overflow-hidden relative">
|
||||||
|
{/* Backdrop for mobile */}
|
||||||
|
{(mobileLeftOpen || mobileRightOpen) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 z-40 md:hidden lg:hidden"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileLeftOpen(false);
|
||||||
|
setMobileRightOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Sidebar - File Explorer */}
|
||||||
|
{uiConfig.show_file_explorer && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ width: uiConfig.left_width, backgroundColor: "var(--df-bg)" }}
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-40 shadow-xl transition-transform duration-200
|
||||||
|
lg:relative lg:translate-x-0 lg:z-0 lg:shadow-none
|
||||||
|
${mobileLeftOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="h-full flex flex-col pt-10 lg:pt-0">
|
||||||
|
<FileExplorer activeSessionId={activeTab?.sessionId ?? null} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Only show resize handle on desktop */}
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
onResize={handleLeftResize}
|
||||||
|
className="hidden lg:block"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Center - Terminal Area */}
|
||||||
|
<section
|
||||||
|
className="flex-1 flex flex-col relative min-w-0"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-terminal)" }}
|
||||||
|
>
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<TabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
onTabChange={setActiveTabId}
|
||||||
|
onTabClose={closeTab}
|
||||||
|
onAddTab={handleNewSession}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Terminal Instances */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{tabs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-slate-500">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<span className="material-icons text-4xl">terminal</span>
|
||||||
|
<p className="text-sm">No active sessions</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs bg-primary hover:bg-primary/80 text-white rounded transition-colors"
|
||||||
|
onClick={handleNewSession}
|
||||||
|
>
|
||||||
|
New Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tabs.map((tab) => (
|
||||||
|
<XTerminal
|
||||||
|
key={tab.sessionId}
|
||||||
|
sessionId={tab.sessionId}
|
||||||
|
active={activeTabId === tab.id}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Commands Bar */}
|
||||||
|
{uiConfig.show_quick_commands && (
|
||||||
|
<>
|
||||||
|
<ResizeHandle direction="vertical" onResize={handleQuickCmdResize} />
|
||||||
|
<div
|
||||||
|
style={{ height: uiConfig.quick_cmd_height }}
|
||||||
|
className="shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<QuickCommands onSend={handleHistoryCommand} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Right Sidebar: three independent panels */}
|
||||||
|
{uiConfig.show_right_sidebar && (
|
||||||
|
<>
|
||||||
|
{/* Only show resize handle on desktop */}
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
onResize={handleRightResize}
|
||||||
|
className="hidden md:block"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: uiConfig.right_width,
|
||||||
|
backgroundColor: "var(--df-bg-panel)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 right-0 z-50 flex flex-col shadow-xl transition-transform duration-200 border-l
|
||||||
|
md:relative md:translate-x-0 md:z-0 md:shadow-none
|
||||||
|
${mobileRightOpen ? "translate-x-0" : "translate-x-full"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Mobile placeholder for header height if needed, or close button */}
|
||||||
|
<div
|
||||||
|
className="md:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
|
||||||
|
style={{ borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileRightOpen(false)}
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Connections - fixed pixel height at top */}
|
||||||
|
<div
|
||||||
|
style={{ height: uiConfig.saved_conn_height }}
|
||||||
|
className="shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<SavedConnections
|
||||||
|
onEditConnection={handleEditConnection}
|
||||||
|
onSessionCreated={handleSessionConnected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResizeHandle direction="vertical" onResize={handleSavedConnResize} />
|
||||||
|
|
||||||
|
{/* Active Sessions - flexible middle */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<ActiveSessions onSessionClick={handleSessionClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResizeHandle direction="vertical" onResize={handleHistoryResize} />
|
||||||
|
|
||||||
|
{/* Command History - fixed pixel height at bottom */}
|
||||||
|
<div
|
||||||
|
style={{ height: uiConfig.history_height }}
|
||||||
|
className="shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<CommandHistory onCommandSend={handleHistoryCommand} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
<StatusBar />
|
||||||
|
|
||||||
|
{/* New Session Dialog */}
|
||||||
|
<NewSessionDialog
|
||||||
|
open={showNewSession}
|
||||||
|
onClose={() => {
|
||||||
|
setShowNewSession(false);
|
||||||
|
setEditingConnection(undefined);
|
||||||
|
}}
|
||||||
|
onConnect={handleSessionConnected}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowNewSession(false);
|
||||||
|
setEditingConnection(undefined);
|
||||||
|
refreshConnections();
|
||||||
|
}}
|
||||||
|
initialData={editingConnection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { logger } from "../lib/logger";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catches React errors, logs them, and renders fallback UI with reload button. */
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
logger.error("Uncaught React error", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: "#0d1117",
|
||||||
|
color: "#c9d1d9",
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
padding: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 480 }}>
|
||||||
|
<h1 style={{ fontSize: "1.25rem", marginBottom: "1rem", color: "#ff7b72" }}>
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
padding: "1rem",
|
||||||
|
backgroundColor: "#161b22",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 200,
|
||||||
|
textAlign: "left",
|
||||||
|
color: "#8b949e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error?.message}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
padding: "0.5rem 1.5rem",
|
||||||
|
backgroundColor: "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload Application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Group, SavedConnection } from "../../types";
|
||||||
|
|
||||||
|
interface NewSessionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConnect: (sessionId: string, name: string, type: "SSH" | "Local") => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
initialData?: SavedConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared input style using CSS variables */
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--df-bg-input)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
color: "var(--df-text)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modal for new/edit SSH connection or local terminal. Save, connect, or cancel. */
|
||||||
|
export default function NewSessionDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConnect,
|
||||||
|
onSaved,
|
||||||
|
initialData,
|
||||||
|
}: NewSessionDialogProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [group, setGroup] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [port, setPort] = useState(22);
|
||||||
|
const [username, setUsername] = useState("root");
|
||||||
|
const [authType, setAuthType] = useState<"password" | "key">("password");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [keyFilePath, setKeyFilePath] = useState("");
|
||||||
|
const [keyFileName, setKeyFileName] = useState("");
|
||||||
|
const [hasKeyData, setHasKeyData] = useState(false);
|
||||||
|
const [passphrase, setPassphrase] = useState("");
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [showGroupDropdown, setShowGroupDropdown] = useState(false);
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const groupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (groupRef.current && !groupRef.current.contains(e.target as Node)) {
|
||||||
|
setShowGroupDropdown(false);
|
||||||
|
setNewGroupName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load groups when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
invoke<Group[]>("get_groups")
|
||||||
|
.then(setGroups)
|
||||||
|
.catch(() => { });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName("");
|
||||||
|
setGroup("");
|
||||||
|
setDescription("");
|
||||||
|
setHost("");
|
||||||
|
setPort(22);
|
||||||
|
setUsername("root");
|
||||||
|
setAuthType("password");
|
||||||
|
setPassword("");
|
||||||
|
setKeyFilePath("");
|
||||||
|
setKeyFileName("");
|
||||||
|
setHasKeyData(false);
|
||||||
|
setPassphrase("");
|
||||||
|
setError("");
|
||||||
|
setConnecting(false);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (initialData) {
|
||||||
|
setName(initialData.name);
|
||||||
|
setGroup(initialData.group || "");
|
||||||
|
setDescription(initialData.description || "");
|
||||||
|
setHost(initialData.host);
|
||||||
|
setPort(initialData.port);
|
||||||
|
setUsername(initialData.username);
|
||||||
|
setAuthType(initialData.auth_type as "password" | "key");
|
||||||
|
setPassword(initialData.password || "");
|
||||||
|
setKeyFilePath("");
|
||||||
|
setKeyFileName("");
|
||||||
|
setHasKeyData(initialData.has_key_data || false);
|
||||||
|
setPassphrase(initialData.passphrase || "");
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, initialData, resetForm]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!host) {
|
||||||
|
setError("Host is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
setSaveSuccess(false);
|
||||||
|
setConnecting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (group && !groups.find((g) => g.name === group)) {
|
||||||
|
await invoke("save_group", {
|
||||||
|
group: { id: "", name: group, sort_order: groups.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: SavedConnection = {
|
||||||
|
id: initialData?.id || "",
|
||||||
|
name: name || `${host}:${port}`,
|
||||||
|
group: group || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
auth_type: authType,
|
||||||
|
password: authType === "password" ? password : undefined,
|
||||||
|
key_file_path: authType === "key" && keyFilePath ? keyFilePath : undefined,
|
||||||
|
passphrase: authType === "key" ? passphrase || undefined : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await invoke("save_connection", { connection });
|
||||||
|
resetForm();
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectLocal = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = await invoke<string>("create_local_session");
|
||||||
|
resetForm();
|
||||||
|
onConnect(sessionId, "Local Terminal", "Local");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const inputClass = "w-full rounded px-3 py-2 text-xs focus:outline-none border transition-colors";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className="rounded-lg w-[480px] shadow-2xl border"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold" style={{ color: "var(--df-text)" }}>
|
||||||
|
{initialData ? "Edit Connection" : "New Connection"}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="material-icons text-base cursor-pointer transition-opacity hover:opacity-70"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{!initialData && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-3 p-3 border rounded transition-colors text-left hover:opacity-90"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)", borderColor: "var(--df-border)" }}
|
||||||
|
onClick={handleConnectLocal}
|
||||||
|
disabled={connecting}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-xl text-cyan-400">terminal</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium" style={{ color: "var(--df-text)" }}>
|
||||||
|
Local Terminal
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Open a local shell session
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!initialData && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 text-[10px] uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div>
|
||||||
|
<span>SSH Connection</span>
|
||||||
|
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name + Group */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Connection Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="My Server"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-52 relative" ref={groupRef}>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Group
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between rounded px-3 py-2 text-xs text-left border transition-colors"
|
||||||
|
style={inputStyle}
|
||||||
|
onClick={() => setShowGroupDropdown(!showGroupDropdown)}
|
||||||
|
>
|
||||||
|
<span style={{ color: group ? "var(--df-text)" : "var(--df-text-dimmed)" }}>
|
||||||
|
{group || "None"}
|
||||||
|
</span>
|
||||||
|
<span className="material-icons text-xs" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showGroupDropdown && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 border rounded shadow-xl z-10 overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: !group
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 15%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
color: !group ? "var(--df-primary)" : "var(--df-text-muted)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (group) e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (group) e.currentTarget.style.backgroundColor = "";
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setGroup("");
|
||||||
|
setShowGroupDropdown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
group === g.name
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 15%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
color: group === g.name ? "var(--df-primary)" : "var(--df-text)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (group !== g.name)
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (group !== g.name) e.currentTarget.style.backgroundColor = "";
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setGroup(g.name);
|
||||||
|
setShowGroupDropdown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="p-1.5 border-t"
|
||||||
|
style={{ borderColor: "color-mix(in srgb, var(--df-border) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New group..."
|
||||||
|
className="flex-1 min-w-0 rounded px-2 py-1 text-xs border focus:outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && newGroupName.trim()) {
|
||||||
|
setGroup(newGroupName.trim());
|
||||||
|
setNewGroupName("");
|
||||||
|
setShowGroupDropdown(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="flex-shrink-0 p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
disabled={!newGroupName.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (newGroupName.trim()) {
|
||||||
|
setGroup(newGroupName.trim());
|
||||||
|
setNewGroupName("");
|
||||||
|
setShowGroupDropdown(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host + Port */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Host
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20">
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Authentication
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="flex-1 py-1.5 text-xs rounded border transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
authType === "password"
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 20%, transparent)"
|
||||||
|
: "var(--df-bg-input)",
|
||||||
|
borderColor: authType === "password" ? "var(--df-primary)" : "var(--df-border)",
|
||||||
|
color: authType === "password" ? "var(--df-primary)" : "var(--df-text-muted)",
|
||||||
|
}}
|
||||||
|
onClick={() => setAuthType("password")}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 py-1.5 text-xs rounded border transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
authType === "key"
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 20%, transparent)"
|
||||||
|
: "var(--df-bg-input)",
|
||||||
|
borderColor: authType === "key" ? "var(--df-primary)" : "var(--df-border)",
|
||||||
|
color: authType === "key" ? "var(--df-primary)" : "var(--df-text-muted)",
|
||||||
|
}}
|
||||||
|
onClick={() => setAuthType("key")}
|
||||||
|
>
|
||||||
|
Private Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password or Key File */}
|
||||||
|
{authType === "password" ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Private Key
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex items-center w-full rounded border overflow-hidden transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--df-bg-input)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1 px-3 py-2 text-xs truncate"
|
||||||
|
style={{
|
||||||
|
color: keyFileName || hasKeyData ? "var(--df-text)" : "var(--df-text-muted)",
|
||||||
|
opacity: keyFileName || hasKeyData ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{keyFileName || (hasKeyData ? "Key file loaded" : "Select private key file...")}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-2 hover:bg-black/10 dark:hover:bg-white/10 transition-colors flex items-center justify-center border-l"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
color: "var(--df-text-muted)",
|
||||||
|
}}
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await openFileDialog({
|
||||||
|
multiple: false,
|
||||||
|
title: "Select Private Key File",
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
setKeyFilePath(selected);
|
||||||
|
const parts = selected.replace(/\\/g, "/").split("/");
|
||||||
|
setKeyFileName(parts[parts.length - 1]);
|
||||||
|
setHasKeyData(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">folder_open</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Passphrase (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Web server for project X"
|
||||||
|
className="w-full rounded px-3 py-2 text-xs border focus:outline-none resize-none transition-colors"
|
||||||
|
style={inputStyle}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveSuccess && (
|
||||||
|
<div className="p-2 bg-green-500/10 border border-green-500/30 rounded text-xs text-green-400">
|
||||||
|
Connection saved successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className="flex justify-end gap-2 px-5 py-3 border-t"
|
||||||
|
style={{ borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-1.5 text-xs transition-colors hover:opacity-70"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-1.5 text-xs text-white rounded transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--df-primary)" }}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={connecting || !host}
|
||||||
|
>
|
||||||
|
{connecting ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onNewSession: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
action?: () => void;
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top bar with File/Edit/View/Terminal/Help menus, theme picker, and mobile toggles. */
|
||||||
|
export default function Header({
|
||||||
|
onNewSession,
|
||||||
|
onToggleLeft,
|
||||||
|
onToggleRight,
|
||||||
|
}: HeaderProps & { onToggleLeft?: () => void; onToggleRight?: () => void }) {
|
||||||
|
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||||
|
const [showThemePicker, setShowThemePicker] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const themeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { themeName, setTheme, themeNames } = useTheme();
|
||||||
|
|
||||||
|
const menus: Record<string, MenuItem[]> = {
|
||||||
|
File: [
|
||||||
|
{ label: "New SSH Connection", action: onNewSession },
|
||||||
|
{ label: "separator", separator: true },
|
||||||
|
{ label: "Exit" },
|
||||||
|
],
|
||||||
|
Edit: [{ label: "Copy" }, { label: "Paste" }, { label: "Select All" }],
|
||||||
|
View: [{ label: "Toggle Sidebar" }, { label: "Toggle Panel" }],
|
||||||
|
Terminal: [
|
||||||
|
{ label: "New SSH Connection", action: onNewSession },
|
||||||
|
{ label: "New Local Terminal", action: onNewSession },
|
||||||
|
],
|
||||||
|
Help: [{ label: "About" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setActiveMenu(null);
|
||||||
|
}
|
||||||
|
if (themeRef.current && !themeRef.current.contains(e.target as Node)) {
|
||||||
|
setShowThemePicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (activeMenu || showThemePicker) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [activeMenu, showThemePicker]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="h-10 border-b flex items-center justify-between px-3 select-none shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4" ref={menuRef}>
|
||||||
|
{/* Mobile Left Toggle */}
|
||||||
|
<button
|
||||||
|
className="lg:hidden"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={onToggleLeft}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-xl">menu</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-0 text-xs font-medium relative">
|
||||||
|
{Object.keys(menus).map((item) => (
|
||||||
|
<div key={item} className="relative">
|
||||||
|
<span
|
||||||
|
className="cursor-pointer px-2 py-1 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
color: activeMenu === item ? "var(--df-primary)" : "var(--df-text-muted)",
|
||||||
|
backgroundColor:
|
||||||
|
activeMenu === item
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 10%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveMenu(activeMenu === item ? null : item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
{activeMenu === item && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full left-0 mt-1 rounded shadow-xl py-1 min-w-[180px] z-50 border"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
{menus[item].map((menuItem) =>
|
||||||
|
menuItem.separator ? (
|
||||||
|
<div
|
||||||
|
key={`sep-${menuItem.label}`}
|
||||||
|
className="my-1 border-t"
|
||||||
|
style={{ borderColor: "var(--df-border)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={menuItem.label}
|
||||||
|
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"color-mix(in srgb, var(--df-primary) 20%, transparent)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "";
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
menuItem.action?.();
|
||||||
|
setActiveMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItem.label}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3" style={{ color: "var(--df-text-muted)" }}>
|
||||||
|
{/* Mobile Right Toggle */}
|
||||||
|
<button
|
||||||
|
className="md:hidden"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={onToggleRight}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-xl">view_sidebar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block">
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Theme Picker */}
|
||||||
|
<div className="relative inline-flex items-center" ref={themeRef}>
|
||||||
|
<span
|
||||||
|
className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block"
|
||||||
|
onClick={() => setShowThemePicker(!showThemePicker)}
|
||||||
|
title="Switch Theme"
|
||||||
|
>
|
||||||
|
palette
|
||||||
|
</span>
|
||||||
|
{showThemePicker && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full right-0 mt-2 rounded-lg shadow-2xl py-1.5 min-w-[200px] z-50 border"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-3 py-1.5 text-[10px] uppercase tracking-wider font-bold"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
Theme
|
||||||
|
</div>
|
||||||
|
{themeNames.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
themeName === t.id
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 12%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (themeName !== t.id)
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (themeName !== t.id) e.currentTarget.style.backgroundColor = "";
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(t.id);
|
||||||
|
setShowThemePicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border shrink-0"
|
||||||
|
style={{ backgroundColor: t.swatch, borderColor: "var(--df-border)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium flex-1"
|
||||||
|
style={{ color: themeName === t.id ? "var(--df-primary)" : "var(--df-text)" }}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
{themeName === t.id && (
|
||||||
|
<span className="material-icons text-sm" style={{ color: "var(--df-primary)" }}>
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block">
|
||||||
|
settings
|
||||||
|
</span>
|
||||||
|
<span className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block">
|
||||||
|
fullscreen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface ResizeHandleProps {
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
onResize: (delta: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draggable handle for horizontal or vertical resize. Calls onResize(delta) on drag. */
|
||||||
|
export default function ResizeHandle({ direction, onResize, className = "" }: ResizeHandleProps) {
|
||||||
|
const startPos = useRef(0);
|
||||||
|
const onResizeRef = useRef(onResize);
|
||||||
|
|
||||||
|
// Keep the ref up to date
|
||||||
|
useEffect(() => {
|
||||||
|
onResizeRef.current = onResize;
|
||||||
|
}, [onResize]);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startPos.current = direction === "horizontal" ? e.clientX : e.clientY;
|
||||||
|
|
||||||
|
const handleMouseMove = (ev: MouseEvent) => {
|
||||||
|
const current = direction === "horizontal" ? ev.clientX : ev.clientY;
|
||||||
|
const delta = current - startPos.current;
|
||||||
|
startPos.current = current;
|
||||||
|
onResizeRef.current(delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[direction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${
|
||||||
|
isHorizontal
|
||||||
|
? "w-[3px] shrink-0 cursor-col-resize hover:opacity-70 active:opacity-90 transition-opacity"
|
||||||
|
: "h-[3px] shrink-0 cursor-row-resize hover:opacity-70 active:opacity-90 transition-opacity"
|
||||||
|
} ${className}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: "var(--df-border)" }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Footer bar showing current time. */
|
||||||
|
export default function StatusBar() {
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formattedTime = time.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="h-7 text-white flex items-center justify-between px-3 text-[11px] select-none shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--df-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 h-full"></div>
|
||||||
|
<div className="flex items-center gap-4 h-full">
|
||||||
|
<div className="flex items-center gap-1 bg-black/20 px-3 h-full">
|
||||||
|
<span className="font-bold">{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import type { SessionInfo } from "../../types";
|
||||||
|
|
||||||
|
interface ActiveSessionsProps {
|
||||||
|
onSessionClick: (sessionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List of active sessions (polled). Click switches to that session's tab. */
|
||||||
|
function ActiveSessions({ onSessionClick }: ActiveSessionsProps) {
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
try {
|
||||||
|
const sess = await invoke<SessionInfo[]>("list_sessions");
|
||||||
|
setSessions(sess);
|
||||||
|
} catch {
|
||||||
|
// Backend might not be ready yet
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSessions();
|
||||||
|
const interval = setInterval(fetchSessions, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-2 text-[10px] uppercase tracking-wider font-bold border-b flex justify-between items-center"
|
||||||
|
style={{
|
||||||
|
color: "var(--df-text-muted)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
backgroundColor: "var(--df-bg-section-header)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Active Sessions</span>
|
||||||
|
<span className="text-[10px] font-normal" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
{sessions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 text-xs space-y-0.5 terminal-scroll">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-[11px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors df-hover ${!session.connected ? "opacity-50" : ""}`}
|
||||||
|
onClick={() => onSessionClick(session.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: session.connected ? "#22c55e" : "var(--df-text-dimmed)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate" style={{ color: "var(--df-text)" }}>
|
||||||
|
{session.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
{session.session_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ActiveSessions);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface CommandHistoryProps {
|
||||||
|
onCommandSend: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Command history list (polled). Double-click sends command to active tab. */
|
||||||
|
function CommandHistory({ onCommandSend }: CommandHistoryProps) {
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
const cmds = await invoke<string[]>("get_command_history");
|
||||||
|
setHistory(cmds);
|
||||||
|
} catch {
|
||||||
|
// Backend might not be ready
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchHistory();
|
||||||
|
const interval = setInterval(fetchHistory, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
onCommandSend(command);
|
||||||
|
},
|
||||||
|
[onCommandSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-2 text-[10px] uppercase tracking-wider font-bold border-b flex justify-between items-center"
|
||||||
|
style={{
|
||||||
|
color: "var(--df-text-muted)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
backgroundColor: "var(--df-bg-section-header)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Command History</span>
|
||||||
|
<span
|
||||||
|
className="material-icons text-sm cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
history
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 text-xs font-mono space-y-0.5 terminal-scroll">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-4 font-display text-[11px]"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
No commands yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
history.map((cmd, index) => (
|
||||||
|
<div
|
||||||
|
key={`${cmd}-${index}`}
|
||||||
|
className="px-2 py-1.5 rounded cursor-pointer transition-colors truncate flex items-center gap-1.5 group df-hover"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
title={cmd}
|
||||||
|
onDoubleClick={() => handleDoubleClick(cmd)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-icons text-[10px] transition-colors"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{cmd}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CommandHistory);
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useApp } from "../../context/AppContext";
|
||||||
|
import { invoke } from "../../lib/invoke";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import type { Group, SavedConnection, SessionType } from "../../types";
|
||||||
|
import { useToast } from "../toast/ToastContext";
|
||||||
|
|
||||||
|
interface SavedConnectionsProps {
|
||||||
|
onEditConnection: (connection: SavedConnection) => void;
|
||||||
|
onSessionCreated: (sessionId: string, name: string, type: SessionType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoverInfo {
|
||||||
|
conn: SavedConnection;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grouped saved SSH connections. Connect, edit, delete. Hover shows detail panel. */
|
||||||
|
export default function SavedConnections({
|
||||||
|
onEditConnection,
|
||||||
|
onSessionCreated,
|
||||||
|
}: SavedConnectionsProps) {
|
||||||
|
const { savedConnections, savedGroups, refreshConnections } = useApp();
|
||||||
|
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Build grouped view: use savedGroups order, include empty groups
|
||||||
|
const { groups, ungrouped } = useMemo(() => {
|
||||||
|
const connByGroup: Record<string, SavedConnection[]> = {};
|
||||||
|
const noGroup: SavedConnection[] = [];
|
||||||
|
|
||||||
|
savedConnections.forEach((conn) => {
|
||||||
|
if (conn.group) {
|
||||||
|
if (!connByGroup[conn.group]) connByGroup[conn.group] = [];
|
||||||
|
connByGroup[conn.group].push(conn);
|
||||||
|
} else {
|
||||||
|
noGroup.push(conn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedGroups = [...savedGroups].sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
const result: [Group, SavedConnection[]][] = sortedGroups.map((g) => [
|
||||||
|
g,
|
||||||
|
connByGroup[g.name] || [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { groups: result, ungrouped: noGroup };
|
||||||
|
}, [savedConnections, savedGroups]);
|
||||||
|
|
||||||
|
const toggleGroup = (groupId: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupId)) {
|
||||||
|
next.delete(groupId);
|
||||||
|
} else {
|
||||||
|
next.add(groupId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = async (conn: SavedConnection) => {
|
||||||
|
if (connectingId) return;
|
||||||
|
setConnectingId(conn.id);
|
||||||
|
try {
|
||||||
|
const sessionId = await invoke<string>("create_ssh_session", { connectionId: conn.id });
|
||||||
|
onSessionCreated(sessionId, conn.name, "SSH");
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`SSH connection failed for "${conn.name}"`, e);
|
||||||
|
toast.error(`Connection failed: ${e}`);
|
||||||
|
onEditConnection(conn);
|
||||||
|
} finally {
|
||||||
|
setConnectingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await invoke("delete_connection", { id });
|
||||||
|
refreshConnections();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Failed to delete connection: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (e: React.MouseEvent, conn: SavedConnection) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEditConnection(conn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent, conn: SavedConnection) => {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setHoverInfo({
|
||||||
|
conn,
|
||||||
|
top: rect.top,
|
||||||
|
right: rect.left,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setHoverInfo(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderConnectionItem = (conn: SavedConnection, indented: boolean) => (
|
||||||
|
<div
|
||||||
|
key={conn.id}
|
||||||
|
className={`group/item relative flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer transition-colors df-hover ${indented ? "ml-4" : ""}`}
|
||||||
|
onDoubleClick={() => handleConnect(conn)}
|
||||||
|
onMouseEnter={(e) => handleMouseEnter(e, conn)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-emerald-500/70 text-sm shrink-0">lan</span>
|
||||||
|
<span
|
||||||
|
className="flex-1 min-w-0 truncate text-[11px] font-medium pr-16"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
>
|
||||||
|
{conn.name}
|
||||||
|
</span>
|
||||||
|
{connectingId === conn.id && (
|
||||||
|
<span
|
||||||
|
className="animate-spin material-icons text-xs shrink-0"
|
||||||
|
style={{ color: "var(--df-primary)" }}
|
||||||
|
>
|
||||||
|
refresh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 hidden group-hover/item:flex items-center gap-0.5 shrink-0 backdrop-blur-sm rounded px-1"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="p-0.5 transition-colors hover:opacity-80"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
title="Connect"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleConnect(conn);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">play_arrow</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-0.5 transition-colors hover:opacity-80"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
title="Edit"
|
||||||
|
onClick={(e) => handleEdit(e, conn)}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">edit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-0.5 hover:text-red-400 transition-colors"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
title="Delete"
|
||||||
|
onClick={(e) => handleDelete(e, conn.id)}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-2 text-[10px] uppercase tracking-wider font-bold border-b flex justify-between items-center"
|
||||||
|
style={{
|
||||||
|
color: "var(--df-text-muted)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
backgroundColor: "var(--df-bg-section-header)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Saved Connections</span>
|
||||||
|
<span className="text-[10px] font-normal" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
{savedConnections.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-1.5 text-xs space-y-0.5 terminal-scroll">
|
||||||
|
{savedConnections.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-[11px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
No saved connections
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.map(([grp, conns]) => {
|
||||||
|
const collapsed = !expandedGroups.has(grp.id);
|
||||||
|
return (
|
||||||
|
<div key={grp.id}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 py-1.5 px-2 rounded cursor-pointer transition-colors select-none df-hover"
|
||||||
|
onClick={() => toggleGroup(grp.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-icons text-xs transition-transform"
|
||||||
|
style={{
|
||||||
|
color: "var(--df-text-dimmed)",
|
||||||
|
transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
<span className="material-icons text-sm text-amber-500/70">
|
||||||
|
{collapsed ? "folder" : "folder_open"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-medium flex-1 truncate"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
{grp.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[9px] tabular-nums"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
{conns.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="mb-1">
|
||||||
|
{conns.map((conn) => renderConnectionItem(conn, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{ungrouped.length > 0 && groups.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-1 pt-1 border-t"
|
||||||
|
style={{ borderColor: "color-mix(in srgb, var(--df-border) 50%, transparent)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ungrouped.map((conn) => renderConnectionItem(conn, false))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover detail panel */}
|
||||||
|
{hoverInfo && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 pointer-events-none"
|
||||||
|
style={{ top: hoverInfo.top, left: hoverInfo.right }}
|
||||||
|
>
|
||||||
|
<div className="relative -translate-x-full -translate-y-1">
|
||||||
|
{/* Arrow */}
|
||||||
|
<div
|
||||||
|
className="absolute top-3 -right-1.5 w-3 h-3 border-r border-t rotate-45"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
/>
|
||||||
|
{/* Panel body */}
|
||||||
|
<div
|
||||||
|
className="mr-1 border rounded-lg shadow-xl p-3 min-w-[200px] max-w-[260px]"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-xs font-semibold mb-2 truncate"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
>
|
||||||
|
{hoverInfo.conn.name}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
<span className="truncate" style={{ color: "var(--df-text)" }}>
|
||||||
|
{hoverInfo.conn.host}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Port
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--df-text)" }}>{hoverInfo.conn.port}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
User
|
||||||
|
</span>
|
||||||
|
<span className="truncate" style={{ color: "var(--df-text)" }}>
|
||||||
|
{hoverInfo.conn.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Auth
|
||||||
|
</span>
|
||||||
|
<span className="capitalize" style={{ color: "var(--df-text)" }}>
|
||||||
|
{hoverInfo.conn.auth_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hoverInfo.conn.group && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Group
|
||||||
|
</span>
|
||||||
|
<span className="truncate" style={{ color: "var(--df-text)" }}>
|
||||||
|
{hoverInfo.conn.group}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hoverInfo.conn.description && (
|
||||||
|
<div
|
||||||
|
className="pt-1.5 mt-1.5 border-t"
|
||||||
|
style={{ borderColor: "color-mix(in srgb, var(--df-border) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--df-text-muted)" }} className="leading-relaxed">
|
||||||
|
{hoverInfo.conn.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
permissions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileExplorerProps {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(entry: FileEntry): { icon: string; color: string } {
|
||||||
|
if (entry.is_dir) return { icon: "folder", color: "#eab308" }; // yellow-500
|
||||||
|
const ext = entry.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
switch (ext) {
|
||||||
|
case "js":
|
||||||
|
case "ts":
|
||||||
|
case "jsx":
|
||||||
|
case "tsx":
|
||||||
|
return { icon: "javascript", color: "#60a5fa" };
|
||||||
|
case "py":
|
||||||
|
return { icon: "code", color: "#4ade80" };
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
return { icon: "terminal", color: "#22d3ee" };
|
||||||
|
case "json":
|
||||||
|
case "yaml":
|
||||||
|
case "yml":
|
||||||
|
case "toml":
|
||||||
|
return { icon: "description", color: "#fb923c" };
|
||||||
|
case "md":
|
||||||
|
case "txt":
|
||||||
|
return { icon: "article", color: "var(--df-text-muted)" };
|
||||||
|
default:
|
||||||
|
return { icon: "insert_drive_file", color: "var(--df-text-muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return "-";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote file browser for active SSH session. Lists dirs/files, supports navigation. */
|
||||||
|
export default function FileExplorer({ activeSessionId }: FileExplorerProps) {
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
|
const [homeDir, setHomeDir] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadDirectory = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await invoke<FileEntry[]>("list_remote_dir", {
|
||||||
|
sessionId: activeSessionId,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
setFiles(entries);
|
||||||
|
setCurrentPath(path);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (activeSessionId) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const home = await invoke<string>("get_home_dir", { sessionId: activeSessionId });
|
||||||
|
if (cancelled) return;
|
||||||
|
setHomeDir(home);
|
||||||
|
loadDirectory(home);
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
loadDirectory("~");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
setFiles([]);
|
||||||
|
setCurrentPath("");
|
||||||
|
setHomeDir("");
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeSessionId, loadDirectory]);
|
||||||
|
|
||||||
|
const handleItemClick = (entry: FileEntry) => {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
loadDirectory(`${currentPath}/${entry.name}`);
|
||||||
|
} else {
|
||||||
|
setSelectedFile(entry.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoUp = () => {
|
||||||
|
if (!currentPath || currentPath === "/") return;
|
||||||
|
const parts = currentPath.split("/");
|
||||||
|
parts.pop();
|
||||||
|
loadDirectory(parts.join("/") || "/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayPath = (() => {
|
||||||
|
if (!homeDir || !currentPath) return currentPath || "~";
|
||||||
|
if (currentPath === homeDir) return "~";
|
||||||
|
if (currentPath.startsWith(`${homeDir}/`)) return `~${currentPath.slice(homeDir.length)}`;
|
||||||
|
return currentPath;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="h-full flex flex-col overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-2 text-[10px] uppercase tracking-wider font-bold border-b flex justify-between items-center"
|
||||||
|
style={{ color: "var(--df-text-muted)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<span>File Explorer</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{activeSessionId && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="material-icons text-xs cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={handleGoUp}
|
||||||
|
title="Go Up"
|
||||||
|
>
|
||||||
|
arrow_upward
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="material-icons text-xs cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={() => loadDirectory(currentPath)}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
refresh
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSessionId && (
|
||||||
|
<div
|
||||||
|
className="px-2 py-1 text-[10px] border-b font-mono truncate"
|
||||||
|
style={{ color: "var(--df-text-dimmed)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
{displayPath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 text-sm terminal-scroll">
|
||||||
|
{!activeSessionId ? (
|
||||||
|
<div className="text-center py-8 text-xs" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
<div className="material-icons text-xl block mb-2">folder_off</div>
|
||||||
|
<div className="text-sm block mb-2">Connect to a session to browse files</div>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="text-center py-4 text-xs" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center text-red-400 py-4 text-xs">{error}</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-xs" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
Empty directory
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{files.map((entry) => {
|
||||||
|
const { icon, color } = getFileIcon(entry);
|
||||||
|
const isSelected = selectedFile === entry.name;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.name}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 10%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
color: isSelected ? "var(--df-primary)" : "var(--df-text)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) e.currentTarget.style.backgroundColor = "";
|
||||||
|
}}
|
||||||
|
onClick={() => handleItemClick(entry)}
|
||||||
|
title={`${entry.permissions} ${formatSize(entry.size)}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-icons text-base"
|
||||||
|
style={{ color: isSelected ? "var(--df-primary)" : color }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-xs">{entry.name}</span>
|
||||||
|
{!entry.is_dir && (
|
||||||
|
<span className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
{formatSize(entry.size)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { memo, useEffect, useRef } from "react";
|
||||||
|
import type { FuzzyResult } from "../../types";
|
||||||
|
|
||||||
|
interface CommandSuggestionsProps {
|
||||||
|
suggestions: FuzzyResult[];
|
||||||
|
visible: boolean;
|
||||||
|
selectedIndex: number;
|
||||||
|
cursorPosition: { top: number; left: number };
|
||||||
|
onSelect: (command: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a single suggestion with matched characters highlighted. */
|
||||||
|
function HighlightedCommand({ command, indices }: { command: string; indices: number[] }) {
|
||||||
|
const indexSet = new Set(indices);
|
||||||
|
const parts: { text: string; highlighted: boolean }[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < command.length) {
|
||||||
|
const isHighlighted = indexSet.has(i);
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < command.length && indexSet.has(j) === isHighlighted) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
parts.push({ text: command.slice(i, j), highlighted: isHighlighted });
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[12px]">
|
||||||
|
{parts.map((part, idx) =>
|
||||||
|
part.highlighted ? (
|
||||||
|
<span key={idx} className="font-semibold" style={{ color: "var(--df-accent)" }}>
|
||||||
|
{part.text}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={idx} style={{ color: "var(--df-text)" }}>
|
||||||
|
{part.text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup list of fuzzy-matched history suggestions. Arrow keys, Enter, Tab, Esc. */
|
||||||
|
function CommandSuggestions({
|
||||||
|
suggestions,
|
||||||
|
visible,
|
||||||
|
selectedIndex,
|
||||||
|
cursorPosition,
|
||||||
|
onSelect,
|
||||||
|
onDismiss: _onDismiss,
|
||||||
|
}: CommandSuggestionsProps) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Scroll the selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRef.current) {
|
||||||
|
selectedRef.current.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible || suggestions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp left so the popup doesn't overflow the right edge of the viewport
|
||||||
|
const popupWidth = 380;
|
||||||
|
const clampedLeft = Math.max(
|
||||||
|
4,
|
||||||
|
Math.min(cursorPosition.left, window.innerWidth - popupWidth - 8),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] w-[380px] max-h-[240px] overflow-y-auto rounded-lg border backdrop-blur-sm shadow-2xl terminal-scroll"
|
||||||
|
ref={listRef}
|
||||||
|
style={{
|
||||||
|
top: cursorPosition.top,
|
||||||
|
left: clampedLeft,
|
||||||
|
backgroundColor: "color-mix(in srgb, var(--df-bg-panel) 95%, transparent)",
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent the terminal from losing focus
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5 text-[10px] uppercase tracking-wider border-b flex items-center gap-1.5"
|
||||||
|
style={{ color: "var(--df-text-dimmed)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-[12px]">history</span>
|
||||||
|
<span>History</span>
|
||||||
|
<span className="ml-auto" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
{suggestions.length} match{suggestions.length !== 1 ? "es" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.map((result, index) => (
|
||||||
|
<div
|
||||||
|
key={`${result.command}-${index}`}
|
||||||
|
ref={index === selectedIndex ? selectedRef : null}
|
||||||
|
className={`px-3 py-1.5 cursor-pointer flex items-center gap-2 transition-colors border-l-2 ${
|
||||||
|
index === selectedIndex ? "" : "border-transparent"
|
||||||
|
} ${index !== selectedIndex ? "df-hover" : ""}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
index === selectedIndex
|
||||||
|
? "color-mix(in srgb, var(--df-primary) 20%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
borderLeftColor: index === selectedIndex ? "var(--df-primary)" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(result.command)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-icons text-[12px] shrink-0"
|
||||||
|
style={{
|
||||||
|
color: index === selectedIndex ? "var(--df-accent)" : "var(--df-text-dimmed)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
<HighlightedCommand command={result.command} indices={result.indices} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="px-2 py-1 border-t flex items-center gap-3 text-[10px]"
|
||||||
|
style={{ borderColor: "var(--df-border)", color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<kbd
|
||||||
|
className="px-1 py-0.5 rounded text-[9px]"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)", color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
↑↓
|
||||||
|
</kbd>{" "}
|
||||||
|
select
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd
|
||||||
|
className="px-1 py-0.5 rounded text-[9px]"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)", color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
execute
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd
|
||||||
|
className="px-1 py-0.5 rounded text-[9px]"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)", color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
Tab
|
||||||
|
</kbd>{" "}
|
||||||
|
fill
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd
|
||||||
|
className="px-1 py-0.5 rounded text-[9px]"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)", color: "var(--df-text-muted)" }}
|
||||||
|
>
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
dismiss
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CommandSuggestions);
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { QuickCommand } from "../../types";
|
||||||
|
|
||||||
|
interface QuickCommandsProps {
|
||||||
|
onSend: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable quick-command buttons. Loads/saves from backend; onSend runs command in active tab. */
|
||||||
|
function QuickCommands({ onSend }: QuickCommandsProps) {
|
||||||
|
const [commands, setCommands] = useState<QuickCommand[]>([]);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [newLabel, setNewLabel] = useState("");
|
||||||
|
const [newCommand, setNewCommand] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
const [editCommand, setEditCommand] = useState("");
|
||||||
|
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editLabelRef = useRef<HTMLInputElement>(null);
|
||||||
|
const loaded = useRef(false);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Load from backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<QuickCommand[]>("get_quick_commands")
|
||||||
|
.then((cmds) => {
|
||||||
|
setCommands(cmds);
|
||||||
|
loaded.current = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loaded.current = true;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced save to backend on change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded.current) return;
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
invoke("save_quick_commands", { commands }).catch(() => {});
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [commands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adding) labelInputRef.current?.focus();
|
||||||
|
}, [adding]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingId) editLabelRef.current?.focus();
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
|
const handleAdd = useCallback(() => {
|
||||||
|
const label = newLabel.trim();
|
||||||
|
const cmd = newCommand.trim();
|
||||||
|
if (!label || !cmd) return;
|
||||||
|
setCommands((prev) => [...prev, { id: `qc-${Date.now()}`, label, command: cmd }]);
|
||||||
|
setNewLabel("");
|
||||||
|
setNewCommand("");
|
||||||
|
setAdding(false);
|
||||||
|
}, [newLabel, newCommand]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((id: string) => {
|
||||||
|
setCommands((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startEdit = useCallback((cmd: QuickCommand) => {
|
||||||
|
setEditingId(cmd.id);
|
||||||
|
setEditLabel(cmd.label);
|
||||||
|
setEditCommand(cmd.command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditSave = useCallback(() => {
|
||||||
|
const label = editLabel.trim();
|
||||||
|
const cmd = editCommand.trim();
|
||||||
|
if (!label || !cmd || !editingId) {
|
||||||
|
setEditingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCommands((prev) =>
|
||||||
|
prev.map((c) => (c.id === editingId ? { ...c, label, command: cmd } : c)),
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
}, [editingId, editLabel, editCommand]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full" style={{ backgroundColor: "var(--df-bg-panel)" }}>
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 h-full overflow-x-auto overflow-y-auto terminal-scroll flex-wrap content-start">
|
||||||
|
<span
|
||||||
|
className="material-icons text-sm shrink-0 mr-0.5"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
>
|
||||||
|
bolt
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{commands.map((cmd) =>
|
||||||
|
editingId === cmd.id ? (
|
||||||
|
<div
|
||||||
|
key={cmd.id}
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={editLabelRef}
|
||||||
|
className="bg-transparent text-[11px] outline-none w-14"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
value={editLabel}
|
||||||
|
placeholder="Label"
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleEditSave();
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="bg-transparent text-[11px] font-mono outline-none w-24"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
value={editCommand}
|
||||||
|
placeholder="Command"
|
||||||
|
onChange={(e) => setEditCommand(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleEditSave();
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="material-icons text-sm text-green-500 hover:text-green-400"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
>
|
||||||
|
check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="material-icons text-sm"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={cmd.id}
|
||||||
|
className="group flex items-center gap-1 rounded px-2 py-1 cursor-pointer transition-colors shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)" }}
|
||||||
|
title={cmd.command}
|
||||||
|
onClick={() => onSend(cmd.command)}
|
||||||
|
>
|
||||||
|
<span className="text-[11px]" style={{ color: "var(--df-accent)" }}>
|
||||||
|
{cmd.label}
|
||||||
|
</span>
|
||||||
|
<div className="hidden group-hover:flex items-center gap-0.5 ml-0.5">
|
||||||
|
<button
|
||||||
|
className="material-icons text-xs transition-colors"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
title="Edit"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startEdit(cmd);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="material-icons text-xs hover:text-red-400 transition-colors"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
title="Delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(cmd.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-hover)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={labelInputRef}
|
||||||
|
className="bg-transparent text-[11px] outline-none w-14"
|
||||||
|
style={{ color: "var(--df-text)" }}
|
||||||
|
value={newLabel}
|
||||||
|
placeholder="Label"
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAdd();
|
||||||
|
if (e.key === "Escape") setAdding(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="bg-transparent text-[11px] font-mono outline-none w-24"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
value={newCommand}
|
||||||
|
placeholder="Command"
|
||||||
|
onChange={(e) => setNewCommand(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAdd();
|
||||||
|
if (e.key === "Escape") setAdding(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="material-icons text-sm text-green-500 hover:text-green-400"
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="material-icons text-sm"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
onClick={() => setAdding(false)}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-0.5 text-[11px] transition-colors shrink-0 px-1.5 py-1 rounded hover:opacity-80"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">add</span>
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(QuickCommands);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { memo } from "react";
|
||||||
|
import type { Tab } from "../../types";
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
onTabChange: (tabId: string) => void;
|
||||||
|
onTabClose: (tabId: string) => void;
|
||||||
|
onAddTab: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab strip for terminal sessions. Closes backend session on tab close. */
|
||||||
|
function TabBar({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
onTabChange,
|
||||||
|
onTabClose,
|
||||||
|
onAddTab,
|
||||||
|
}: TabBarProps) {
|
||||||
|
const handleClose = (e: React.MouseEvent, tab: Tab) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Close the backend session
|
||||||
|
invoke("close_session", { sessionId: tab.sessionId }).catch(() => {});
|
||||||
|
onTabClose(tab.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-9 overflow-x-auto terminal-scroll shrink-0 border-b"
|
||||||
|
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={`group flex items-center px-4 gap-2 border-r text-xs font-medium cursor-pointer transition-colors ${
|
||||||
|
activeTabId === tab.id ? "active-tab" : ""
|
||||||
|
} ${activeTabId !== tab.id ? "df-hover" : ""}`}
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--df-border)",
|
||||||
|
color: activeTabId === tab.id ? "var(--df-text)" : "var(--df-text-muted)",
|
||||||
|
}}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-sm">{tab.type === "SSH" ? "dns" : "terminal"}</span>
|
||||||
|
<span className="whitespace-nowrap max-w-[160px] truncate">{tab.name}</span>
|
||||||
|
<span
|
||||||
|
className="material-icons text-[10px] hover:text-red-500 transition-colors"
|
||||||
|
style={{ color: "var(--df-text-dimmed)" }}
|
||||||
|
onClick={(e) => handleClose(e, tab)}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="px-3 transition-colors df-hover"
|
||||||
|
style={{ color: "var(--df-text-muted)" }}
|
||||||
|
onClick={onAddTab}
|
||||||
|
title="New Connection"
|
||||||
|
>
|
||||||
|
<span className="material-icons text-base">add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TabBar);
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
|
import { type IMarker, Terminal } from "@xterm/xterm";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import type { FuzzyResult } from "../../types";
|
||||||
|
import CommandSuggestions from "./CommandSuggestions";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
/** Shell integration state tracked via OSC 133 sequences. */
|
||||||
|
interface ShellIntegrationState {
|
||||||
|
/** Whether the shell emits OSC 133 sequences. Set to true on first receipt. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Marker at the beginning of the prompt (OSC 133;A). */
|
||||||
|
promptStartMarker: IMarker | null;
|
||||||
|
/** Marker at the end of the prompt / start of user command input (OSC 133;B). */
|
||||||
|
commandStartMarker: IMarker | null;
|
||||||
|
/** CursorX at the moment OSC 133;B was received. */
|
||||||
|
commandStartX: number;
|
||||||
|
/** Fallback: promptEndX detected heuristically (for shells without OSC 133). */
|
||||||
|
fallbackPromptEndX: number;
|
||||||
|
/** Fallback: whether we still need to detect the prompt position. */
|
||||||
|
fallbackNeedsDetection: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XTerminalProps {
|
||||||
|
sessionId: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xterm.js terminal for a session. Handles OSC 133 shell integration (or fallback prompt
|
||||||
|
* detection), fuzzy command history suggestions, and resize/fit. Key props: sessionId, active.
|
||||||
|
*/
|
||||||
|
export default function XTerminal({ sessionId, active }: XTerminalProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Fuzzy search: refs for the onData callback, state for rendering
|
||||||
|
const currentLineRef = useRef("");
|
||||||
|
const suggestionsRef = useRef<FuzzyResult[]>([]);
|
||||||
|
const selectedIndexRef = useRef(-1);
|
||||||
|
const showSuggestionsRef = useRef(false);
|
||||||
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Shell integration: OSC 133 based prompt/command tracking with fallback
|
||||||
|
const shellIntegrationRef = useRef<ShellIntegrationState>({
|
||||||
|
enabled: false,
|
||||||
|
promptStartMarker: null,
|
||||||
|
commandStartMarker: null,
|
||||||
|
commandStartX: 0,
|
||||||
|
fallbackPromptEndX: 0,
|
||||||
|
fallbackNeedsDetection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = useState<FuzzyResult[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [cursorPosition, setCursorPosition] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read command text from the terminal buffer.
|
||||||
|
* Uses marker-based reading when shell integration (OSC 133) is active,
|
||||||
|
* falls back to heuristic prompt-end position otherwise.
|
||||||
|
*/
|
||||||
|
const readCommandFromBuffer = (): string => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return currentLineRef.current;
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = terminal.buffer.active;
|
||||||
|
|
||||||
|
if (si.enabled && si.commandStartMarker) {
|
||||||
|
// OSC 133 path: read between the B-marker and the current cursor
|
||||||
|
return readBetweenMarkerAndCursor(terminal, si.commandStartMarker, si.commandStartX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback path: single-line read from heuristic prompt end
|
||||||
|
const row = buf.baseY + buf.cursorY;
|
||||||
|
const line = buf.getLine(row);
|
||||||
|
if (!line) return currentLineRef.current;
|
||||||
|
return line.translateToString(true, si.fallbackPromptEndX);
|
||||||
|
} catch {
|
||||||
|
return currentLineRef.current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read text from the buffer between a marker position and the current cursor.
|
||||||
|
* Supports multi-line commands (wrapped or explicit newlines).
|
||||||
|
*/
|
||||||
|
const readBetweenMarkerAndCursor = (
|
||||||
|
terminal: Terminal,
|
||||||
|
startMarker: IMarker,
|
||||||
|
startX: number,
|
||||||
|
): string => {
|
||||||
|
try {
|
||||||
|
const buf = terminal.buffer.active;
|
||||||
|
const startRow = startMarker.line;
|
||||||
|
const endRow = buf.baseY + buf.cursorY;
|
||||||
|
|
||||||
|
if (startRow === endRow) {
|
||||||
|
const line = buf.getLine(startRow);
|
||||||
|
return line?.translateToString(true, startX) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-line command: concatenate rows
|
||||||
|
let result = "";
|
||||||
|
for (let row = startRow; row <= endRow; row++) {
|
||||||
|
const line = buf.getLine(row);
|
||||||
|
if (!line) continue;
|
||||||
|
if (row === startRow) {
|
||||||
|
result += line.translateToString(true, startX);
|
||||||
|
} else if (line.isWrapped) {
|
||||||
|
// Soft-wrapped continuation — no separator
|
||||||
|
result += line.translateToString(true);
|
||||||
|
} else {
|
||||||
|
result += `\n${line.translateToString(true)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept a suggestion from the overlay (click) — erase + write + execute
|
||||||
|
const handleSelectSuggestion = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
const actualCmd = readCommandFromBuffer();
|
||||||
|
const eraseChars = "\x7f".repeat(actualCmd.length);
|
||||||
|
invoke("write_to_session", {
|
||||||
|
sessionId,
|
||||||
|
data: `${eraseChars + command}\r`,
|
||||||
|
}).catch(() => {});
|
||||||
|
invoke("add_command_history", { sessionId, command }).catch(() => {});
|
||||||
|
currentLineRef.current = "";
|
||||||
|
shellIntegrationRef.current.fallbackNeedsDetection = true;
|
||||||
|
|
||||||
|
showSuggestionsRef.current = false;
|
||||||
|
suggestionsRef.current = [];
|
||||||
|
selectedIndexRef.current = -1;
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
},
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: readCommandFromBuffer reads refs directly, not state; adding it would recreate the callback on every render
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dismiss suggestions from the overlay
|
||||||
|
const handleDismissSuggestions = useCallback(() => {
|
||||||
|
showSuggestionsRef.current = false;
|
||||||
|
suggestionsRef.current = [];
|
||||||
|
selectedIndexRef.current = -1;
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create and setup terminal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
theme: { ...theme.colors.terminal },
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
terminal.open(containerRef.current);
|
||||||
|
|
||||||
|
// Initial fit
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
});
|
||||||
|
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// ── Fuzzy search helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Get the cursor position in viewport (fixed) coordinates. */
|
||||||
|
const getCursorViewportPosition = (): { top: number; left: number } => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const core = (terminal as any)._core;
|
||||||
|
const dims = core._renderService.dimensions;
|
||||||
|
const cellHeight: number = dims.css.cell.height;
|
||||||
|
const cellWidth: number = dims.css.cell.width;
|
||||||
|
|
||||||
|
const cursorY = terminal.buffer.active.cursorY;
|
||||||
|
const cursorX = terminal.buffer.active.cursorX;
|
||||||
|
|
||||||
|
// Get the xterm-screen element's viewport rect for accurate offset
|
||||||
|
const screenEl = terminal.element?.querySelector(".xterm-screen");
|
||||||
|
if (!screenEl) return { top: 0, left: 0 };
|
||||||
|
|
||||||
|
const screenRect = screenEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: screenRect.top + (cursorY + 1) * cellHeight,
|
||||||
|
left: screenRect.left + cursorX * cellWidth,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { top: 0, left: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSuggestions = () => {
|
||||||
|
showSuggestionsRef.current = false;
|
||||||
|
suggestionsRef.current = [];
|
||||||
|
selectedIndexRef.current = -1;
|
||||||
|
setSuggestions([]);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read the command from the terminal buffer (effect-local version). */
|
||||||
|
const readBufferCommand = (): string => {
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
try {
|
||||||
|
if (si.enabled && si.commandStartMarker) {
|
||||||
|
return readBetweenMarkerAndCursor(terminal, si.commandStartMarker, si.commandStartX);
|
||||||
|
}
|
||||||
|
// Fallback: single-line read from heuristic prompt end
|
||||||
|
const buf = terminal.buffer.active;
|
||||||
|
const row = buf.baseY + buf.cursorY;
|
||||||
|
const line = buf.getLine(row);
|
||||||
|
if (!line) return currentLineRef.current;
|
||||||
|
return line.translateToString(true, si.fallbackPromptEndX);
|
||||||
|
} catch {
|
||||||
|
return currentLineRef.current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSearch = () => {
|
||||||
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||||
|
|
||||||
|
// Guard: only search if the user has typed something since the last
|
||||||
|
// reset. Use length (not trim) so that even a single space after an
|
||||||
|
// arrow-key recall still triggers a buffer read.
|
||||||
|
if (currentLineRef.current.length === 0) {
|
||||||
|
dismissSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimerRef.current = setTimeout(async () => {
|
||||||
|
// Read the real command from the terminal buffer — by now (80 ms
|
||||||
|
// later) the echo has arrived, so the buffer includes shell
|
||||||
|
// completions, IME commits, etc.
|
||||||
|
const pattern = readBufferCommand();
|
||||||
|
if (!pattern.trim()) {
|
||||||
|
dismissSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await invoke<FuzzyResult[]>("fuzzy_search_history", {
|
||||||
|
pattern,
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
suggestionsRef.current = results;
|
||||||
|
selectedIndexRef.current = -1;
|
||||||
|
showSuggestionsRef.current = results.length > 0;
|
||||||
|
setSuggestions(results);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
setShowSuggestions(results.length > 0);
|
||||||
|
|
||||||
|
// Capture cursor position after echo has arrived
|
||||||
|
if (results.length > 0) {
|
||||||
|
setCursorPosition(getCursorViewportPosition());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── OSC 133 Shell Integration handlers ─────────────────────────────
|
||||||
|
//
|
||||||
|
// These hook into the FinalTerm / VS Code shell integration protocol.
|
||||||
|
// The shell emits OSC 133 sequences to mark prompt boundaries and
|
||||||
|
// command execution, giving us precise command capture without
|
||||||
|
// keystroke-tracking heuristics.
|
||||||
|
|
||||||
|
const oscDisposable = terminal.parser.registerOscHandler(133, (data) => {
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
|
||||||
|
if (data.startsWith("A")) {
|
||||||
|
// ── Prompt Start ──────────────────────────────────────────────
|
||||||
|
si.enabled = true;
|
||||||
|
si.promptStartMarker?.dispose();
|
||||||
|
si.promptStartMarker = terminal.registerMarker(0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startsWith("B")) {
|
||||||
|
// ── Prompt End / Command Input Start ──────────────────────────
|
||||||
|
si.enabled = true;
|
||||||
|
si.commandStartMarker?.dispose();
|
||||||
|
si.commandStartMarker = terminal.registerMarker(0);
|
||||||
|
si.commandStartX = terminal.buffer.active.cursorX;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startsWith("C")) {
|
||||||
|
// ── Command Executed (user pressed Enter) ─────────────────────
|
||||||
|
si.enabled = true;
|
||||||
|
if (si.commandStartMarker) {
|
||||||
|
const command = readBetweenMarkerAndCursor(
|
||||||
|
terminal,
|
||||||
|
si.commandStartMarker,
|
||||||
|
si.commandStartX,
|
||||||
|
).trim();
|
||||||
|
if (command) {
|
||||||
|
invoke("add_command_history", { sessionId, command }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentLineRef.current = "";
|
||||||
|
dismissSuggestions();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startsWith("D")) {
|
||||||
|
// ── Command Finished ──────────────────────────────────────────
|
||||||
|
// Optionally: const exitCode = data.split(";")[1];
|
||||||
|
si.enabled = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fallback prompt detection via onWriteParsed ──────────────────
|
||||||
|
//
|
||||||
|
// For shells that do NOT emit OSC 133, we use onWriteParsed (fires
|
||||||
|
// after terminal.write data is fully parsed, at most once per frame)
|
||||||
|
// to continuously track the cursor position while detection is
|
||||||
|
// pending. The value is only finalized (fallbackNeedsDetection set
|
||||||
|
// to false) by the keystroke handler in onData — this avoids a race
|
||||||
|
// where the debounce timer fires between the last \n and the prompt
|
||||||
|
// text, locking in cursorX=0.
|
||||||
|
|
||||||
|
const writeParsedDisposable = terminal.onWriteParsed(() => {
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
if (si.enabled) return; // OSC 133 is handling everything
|
||||||
|
|
||||||
|
// Keep updating the prompt-end position as output settles.
|
||||||
|
// Do NOT set fallbackNeedsDetection=false here — the keystroke
|
||||||
|
// handler in onData finalizes it when the user actually starts
|
||||||
|
// typing, which guarantees the cursor is past the full prompt.
|
||||||
|
if (si.fallbackNeedsDetection) {
|
||||||
|
si.fallbackPromptEndX = terminal.buffer.active.cursorX;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Backend output & session listeners ───────────────────────────────
|
||||||
|
|
||||||
|
let outputUnlisten: UnlistenFn | null = null;
|
||||||
|
let closedUnlisten: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
const setupListeners = async () => {
|
||||||
|
outputUnlisten = await listen<string>(`terminal-output-${sessionId}`, (event) => {
|
||||||
|
terminal.write(event.payload);
|
||||||
|
|
||||||
|
// When backend output contains a newline, reset the keystroke
|
||||||
|
// buffer so stale characters don't leak into fuzzy search.
|
||||||
|
// Prompt detection is now handled by onWriteParsed (fallback)
|
||||||
|
// or OSC 133;B (shell integration).
|
||||||
|
if (event.payload.includes("\n")) {
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
currentLineRef.current = "";
|
||||||
|
if (!si.enabled) {
|
||||||
|
si.fallbackNeedsDetection = true;
|
||||||
|
}
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
closedUnlisten = await listen<void>(`session-closed-${sessionId}`, () => {
|
||||||
|
terminal.write("\r\n\x1b[31m[Session disconnected]\x1b[0m\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener is ready — tell the backend to flush buffered output
|
||||||
|
await invoke("attach_session", { sessionId });
|
||||||
|
};
|
||||||
|
setupListeners();
|
||||||
|
|
||||||
|
// ── Handle user input ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const dataDisposable = terminal.onData((data) => {
|
||||||
|
// When suggestions are visible, intercept navigation keys
|
||||||
|
if (showSuggestionsRef.current && suggestionsRef.current.length > 0) {
|
||||||
|
// Tab → accept selected suggestion (fill in without executing)
|
||||||
|
// Only when an item is actually selected
|
||||||
|
if (data === "\t" && selectedIndexRef.current >= 0) {
|
||||||
|
const selected = suggestionsRef.current[selectedIndexRef.current];
|
||||||
|
if (selected) {
|
||||||
|
const actualCmd = readBufferCommand();
|
||||||
|
const eraseChars = "\x7f".repeat(actualCmd.length);
|
||||||
|
invoke("write_to_session", {
|
||||||
|
sessionId,
|
||||||
|
data: eraseChars + selected.command,
|
||||||
|
}).catch(() => {});
|
||||||
|
currentLineRef.current = selected.command;
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up arrow → navigate up; from -1 (no selection) goes to last item
|
||||||
|
if (data === "\x1b[A") {
|
||||||
|
const cur = selectedIndexRef.current;
|
||||||
|
const newIdx = cur === -1 ? suggestionsRef.current.length - 1 : cur === 0 ? -1 : cur - 1;
|
||||||
|
selectedIndexRef.current = newIdx;
|
||||||
|
setSelectedIndex(newIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down arrow → navigate down; from -1 (no selection) goes to first item
|
||||||
|
if (data === "\x1b[B") {
|
||||||
|
const cur = selectedIndexRef.current;
|
||||||
|
const newIdx = cur === -1 ? 0 : cur === suggestionsRef.current.length - 1 ? -1 : cur + 1;
|
||||||
|
selectedIndexRef.current = newIdx;
|
||||||
|
setSelectedIndex(newIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape → dismiss suggestions
|
||||||
|
if (data === "\x1b") {
|
||||||
|
dismissSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter → if an item is selected, accept + execute it
|
||||||
|
if (data === "\r" && selectedIndexRef.current >= 0) {
|
||||||
|
const selected = suggestionsRef.current[selectedIndexRef.current];
|
||||||
|
if (selected) {
|
||||||
|
const actualCmd = readBufferCommand();
|
||||||
|
const eraseChars = "\x7f".repeat(actualCmd.length);
|
||||||
|
invoke("write_to_session", {
|
||||||
|
sessionId,
|
||||||
|
data: `${eraseChars + selected.command}\r`,
|
||||||
|
}).catch(() => {});
|
||||||
|
// When shell integration is active, OSC 133;C will capture
|
||||||
|
// the command for history. In fallback mode, record it here.
|
||||||
|
if (!shellIntegrationRef.current.enabled) {
|
||||||
|
invoke("add_command_history", {
|
||||||
|
sessionId,
|
||||||
|
command: selected.command,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
currentLineRef.current = "";
|
||||||
|
shellIntegrationRef.current.fallbackNeedsDetection = true;
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input buffering & history ───────────────────────────────────
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
|
||||||
|
if (data === "\r") {
|
||||||
|
// On Enter: in fallback mode, read command from buffer for history.
|
||||||
|
// When OSC 133 is active, the C-handler captures the command instead.
|
||||||
|
if (!si.enabled) {
|
||||||
|
const bufCmd = readBufferCommand().trim();
|
||||||
|
const cmd = bufCmd || currentLineRef.current.trim();
|
||||||
|
if (cmd) {
|
||||||
|
invoke("add_command_history", { sessionId, command: cmd });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentLineRef.current = "";
|
||||||
|
si.fallbackNeedsDetection = true;
|
||||||
|
dismissSuggestions();
|
||||||
|
} else if (data === "\u007f" || data === "\b") {
|
||||||
|
// Backspace — update keystroke buffer for fuzzy search
|
||||||
|
currentLineRef.current = currentLineRef.current.slice(0, -1);
|
||||||
|
triggerSearch();
|
||||||
|
} else if (data === "\t") {
|
||||||
|
// Tab — pass through to shell for its own completion.
|
||||||
|
triggerSearch();
|
||||||
|
} else if (!/[\x00-\x1f\x7f]/.test(data)) {
|
||||||
|
// Printable characters
|
||||||
|
// Fallback prompt detection: on first keystroke, snapshot cursorX
|
||||||
|
if (!si.enabled && si.fallbackNeedsDetection) {
|
||||||
|
si.fallbackPromptEndX = terminal.buffer.active.cursorX;
|
||||||
|
si.fallbackNeedsDetection = false;
|
||||||
|
}
|
||||||
|
currentLineRef.current += data;
|
||||||
|
triggerSearch();
|
||||||
|
} else if (data.startsWith("\x1b")) {
|
||||||
|
// Escape sequences (arrow keys, etc.) — invalidate keystroke buffer
|
||||||
|
if (!si.enabled && si.fallbackNeedsDetection) {
|
||||||
|
si.fallbackPromptEndX = terminal.buffer.active.cursorX;
|
||||||
|
si.fallbackNeedsDetection = false;
|
||||||
|
}
|
||||||
|
currentLineRef.current = "";
|
||||||
|
dismissSuggestions();
|
||||||
|
} else {
|
||||||
|
// Other control characters (Ctrl+C, Ctrl+U, etc.)
|
||||||
|
currentLineRef.current = "";
|
||||||
|
si.fallbackNeedsDetection = true;
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke("write_to_session", { sessionId, data }).catch(() => {
|
||||||
|
// Session might be closed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send resize events to backend
|
||||||
|
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||||
|
invoke("resize_session", { sessionId, cols, rows }).catch(() => {
|
||||||
|
// Session might be closed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe container size changes
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||||
|
|
||||||
|
// Dispose shell integration markers
|
||||||
|
const si = shellIntegrationRef.current;
|
||||||
|
si.promptStartMarker?.dispose();
|
||||||
|
si.commandStartMarker?.dispose();
|
||||||
|
si.promptStartMarker = null;
|
||||||
|
si.commandStartMarker = null;
|
||||||
|
|
||||||
|
// Dispose xterm event / parser handlers
|
||||||
|
oscDisposable.dispose();
|
||||||
|
writeParsedDisposable.dispose();
|
||||||
|
dataDisposable.dispose();
|
||||||
|
resizeDisposable.dispose();
|
||||||
|
|
||||||
|
observer.disconnect();
|
||||||
|
if (outputUnlisten) outputUnlisten();
|
||||||
|
if (closedUnlisten) closedUnlisten();
|
||||||
|
terminal.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Re-fit and focus when tab becomes active
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && fitAddonRef.current && terminalRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
// React to theme changes: update terminal colors dynamically
|
||||||
|
useEffect(() => {
|
||||||
|
if (terminalRef.current) {
|
||||||
|
terminalRef.current.options.theme = { ...theme.colors.terminal };
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full relative" style={{ display: active ? "block" : "none" }}>
|
||||||
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
|
<CommandSuggestions
|
||||||
|
suggestions={suggestions}
|
||||||
|
visible={showSuggestions}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
cursorPosition={cursorPosition}
|
||||||
|
onSelect={handleSelectSuggestion}
|
||||||
|
onDismiss={handleDismissSuggestions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ToastType } from "./ToastContext";
|
||||||
|
import { useToast } from "./ToastContext";
|
||||||
|
|
||||||
|
const colorMap: Record<ToastType, { bg: string; border: string }> = {
|
||||||
|
error: { bg: "rgba(220,38,38,0.9)", border: "rgba(220,38,38,0.6)" },
|
||||||
|
warn: { bg: "rgba(217,119,6,0.9)", border: "rgba(217,119,6,0.6)" },
|
||||||
|
info: { bg: "rgba(37,99,235,0.9)", border: "rgba(37,99,235,0.6)" },
|
||||||
|
success: { bg: "rgba(5,150,105,0.9)", border: "rgba(5,150,105,0.6)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMap: Record<ToastType, string> = {
|
||||||
|
error: "error",
|
||||||
|
warn: "warning",
|
||||||
|
info: "info",
|
||||||
|
success: "check_circle",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders toasts from ToastContext as fixed overlay. Type-based colors and icons. */
|
||||||
|
export default function ToastContainer() {
|
||||||
|
const { toasts, dismiss } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-[380px]">
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const colors = colorMap[toast.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className="animate-slide-in-right rounded-lg shadow-xl px-4 py-3 flex items-start gap-3 border backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons text-base text-white/90 mt-0.5">
|
||||||
|
{iconMap[toast.type]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white flex-1 leading-relaxed">{toast.message}</span>
|
||||||
|
<span
|
||||||
|
className="material-icons text-sm text-white/60 cursor-pointer hover:text-white mt-0.5 transition-colors"
|
||||||
|
onClick={() => dismiss(toast.id)}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type ToastType = "error" | "warn" | "info" | "success";
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: ToastItem[];
|
||||||
|
error: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
info: (msg: string) => void;
|
||||||
|
success: (msg: string) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast state: array of ToastItem. Add via error/warn/info/success(msg); auto-dismiss
|
||||||
|
* after 5s. dismiss(id) removes manually.
|
||||||
|
*/
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
const TOAST_DURATION = 5000;
|
||||||
|
|
||||||
|
/** Provides toasts array and error/warn/info/success/dismiss helpers. */
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const counterRef = useRef(0);
|
||||||
|
|
||||||
|
const addToast = useCallback((type: ToastType, message: string) => {
|
||||||
|
const id = `toast-${++counterRef.current}`;
|
||||||
|
setToasts((prev) => [...prev, { id, type, message }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, TOAST_DURATION);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const error = useCallback((msg: string) => addToast("error", msg), [addToast]);
|
||||||
|
const warn = useCallback((msg: string) => addToast("warn", msg), [addToast]);
|
||||||
|
const info = useCallback((msg: string) => addToast("info", msg), [addToast]);
|
||||||
|
const success = useCallback((msg: string) => addToast("success", msg), [addToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, error, warn, info, success, dismiss }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to access ToastContext. Throws if used outside ToastProvider. */
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { invoke } from "../lib/invoke";
|
||||||
|
import { logger } from "../lib/logger";
|
||||||
|
import type { Group, SavedConnection, SessionType, Tab, UiConfig } from "../types";
|
||||||
|
|
||||||
|
interface AppContextType {
|
||||||
|
// Tabs
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
setActiveTabId: (id: string | null) => void;
|
||||||
|
addTab: (sessionId: string, name: string, type: SessionType) => void;
|
||||||
|
closeTab: (tabId: string) => void;
|
||||||
|
|
||||||
|
// UI Config
|
||||||
|
uiConfig: UiConfig;
|
||||||
|
updateUiConfig: (updates: Partial<UiConfig>) => void;
|
||||||
|
|
||||||
|
// Data
|
||||||
|
savedConnections: SavedConnection[];
|
||||||
|
savedGroups: Group[];
|
||||||
|
refreshConnections: () => Promise<void>;
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
showNewSession: boolean;
|
||||||
|
setShowNewSession: (show: boolean) => void;
|
||||||
|
editingConnection: SavedConnection | undefined;
|
||||||
|
setEditingConnection: (conn: SavedConnection | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide state: tabs, UI config (debounced save), saved connections (polled),
|
||||||
|
* and dialog visibility. Updates via setState/useCallback; config persisted to backend.
|
||||||
|
*/
|
||||||
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
|
|
||||||
|
const DEFAULT_UI_CONFIG: UiConfig = {
|
||||||
|
left_width: 256,
|
||||||
|
right_width: 288,
|
||||||
|
saved_conn_height: 240,
|
||||||
|
history_height: 200,
|
||||||
|
quick_cmd_height: 36,
|
||||||
|
show_file_explorer: true,
|
||||||
|
show_right_sidebar: true,
|
||||||
|
show_quick_commands: true,
|
||||||
|
theme: "github-dark",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides tabs, uiConfig, savedConnections, and dialog state to the app. */
|
||||||
|
export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
// Tabs State
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// UI Config State
|
||||||
|
const [uiConfig, setUiConfig] = useState<UiConfig>(DEFAULT_UI_CONFIG);
|
||||||
|
const uiConfigLoaded = useRef(false);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Data State
|
||||||
|
const [savedConnections, setSavedConnections] = useState<SavedConnection[]>([]);
|
||||||
|
const [savedGroups, setSavedGroups] = useState<Group[]>([]);
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [showNewSession, setShowNewSession] = useState(false);
|
||||||
|
const [editingConnection, setEditingConnection] = useState<SavedConnection | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Load UI Config
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<UiConfig>("get_ui_config")
|
||||||
|
.then((cfg) => {
|
||||||
|
setUiConfig(cfg);
|
||||||
|
uiConfigLoaded.current = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
uiConfigLoaded.current = true;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2. Save UI Config Debounced
|
||||||
|
const updateUiConfig = useCallback((updates: Partial<UiConfig>) => {
|
||||||
|
setUiConfig((prev) => {
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
// Debounce save
|
||||||
|
if (uiConfigLoaded.current) {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
invoke("save_ui_config", { config: next }).catch((e) =>
|
||||||
|
logger.error("Failed to save UI config", e),
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3. Load Connections
|
||||||
|
const refreshConnections = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [saved, groups] = await Promise.all([
|
||||||
|
invoke<SavedConnection[]>("get_saved_connections"),
|
||||||
|
invoke<Group[]>("get_groups"),
|
||||||
|
]);
|
||||||
|
setSavedConnections(saved);
|
||||||
|
setSavedGroups(groups);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to fetch connections", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshConnections();
|
||||||
|
const interval = setInterval(refreshConnections, 5000); // Poll every 5s
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [refreshConnections]);
|
||||||
|
|
||||||
|
// 4. Tab Logic
|
||||||
|
const addTab = useCallback((sessionId: string, name: string, type: SessionType) => {
|
||||||
|
const tabId = `tab-${Date.now()}`;
|
||||||
|
const newTab: Tab = { id: tabId, sessionId, name, type };
|
||||||
|
setTabs((prev) => [...prev, newTab]);
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
|
||||||
|
// Close dialogs when session starts
|
||||||
|
setShowNewSession(false);
|
||||||
|
setEditingConnection(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeTab = useCallback(
|
||||||
|
(tabId: string) => {
|
||||||
|
setTabs((prev) => {
|
||||||
|
const newTabs = prev.filter((t) => t.id !== tabId);
|
||||||
|
if (activeTabId === tabId) {
|
||||||
|
if (newTabs.length > 0) {
|
||||||
|
setActiveTabId(newTabs[newTabs.length - 1].id);
|
||||||
|
} else {
|
||||||
|
setActiveTabId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newTabs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeTabId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
setActiveTabId,
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
|
uiConfig,
|
||||||
|
updateUiConfig,
|
||||||
|
savedConnections,
|
||||||
|
savedGroups,
|
||||||
|
refreshConnections,
|
||||||
|
showNewSession,
|
||||||
|
setShowNewSession,
|
||||||
|
editingConnection,
|
||||||
|
setEditingConnection,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to access AppContext. Throws if used outside AppProvider. */
|
||||||
|
export function useApp() {
|
||||||
|
const context = useContext(AppContext);
|
||||||
|
if (!context) throw new Error("useApp must be used within AppProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { DEFAULT_THEME_ID, type Theme, type ThemeColors, themeList, themes } from "../themes";
|
||||||
|
import { useApp } from "./AppContext";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
themeName: string;
|
||||||
|
setTheme: (id: string) => void;
|
||||||
|
themeNames: typeof themeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme state: current theme object and name. setTheme updates local state and
|
||||||
|
* persists via updateUiConfig. CSS vars applied to :root on theme change.
|
||||||
|
*/
|
||||||
|
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||||
|
|
||||||
|
/** Inject all theme colors as CSS custom properties on :root */
|
||||||
|
function applyThemeToDOM(colors: ThemeColors) {
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
root.setProperty("--df-bg", colors.bg);
|
||||||
|
root.setProperty("--df-bg-panel", colors.bgPanel);
|
||||||
|
root.setProperty("--df-bg-terminal", colors.bgTerminal);
|
||||||
|
root.setProperty("--df-bg-hover", colors.bgHover);
|
||||||
|
root.setProperty("--df-bg-input", colors.bgInput);
|
||||||
|
root.setProperty("--df-bg-section-header", colors.bgSectionHeader);
|
||||||
|
root.setProperty("--df-border", colors.border);
|
||||||
|
root.setProperty("--df-text", colors.text);
|
||||||
|
root.setProperty("--df-text-muted", colors.textMuted);
|
||||||
|
root.setProperty("--df-text-dimmed", colors.textDimmed);
|
||||||
|
root.setProperty("--df-primary", colors.primary);
|
||||||
|
root.setProperty("--df-primary-hover", colors.primaryHover);
|
||||||
|
root.setProperty("--df-scroll-thumb", colors.scrollThumb);
|
||||||
|
root.setProperty("--df-accent", colors.accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides theme, themeName, setTheme. Syncs with uiConfig.theme from backend. */
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { uiConfig, updateUiConfig } = useApp();
|
||||||
|
const initialId = uiConfig.theme || DEFAULT_THEME_ID;
|
||||||
|
const [themeName, setThemeName] = useState(initialId);
|
||||||
|
|
||||||
|
const current = themes[themeName] || themes[DEFAULT_THEME_ID];
|
||||||
|
|
||||||
|
// Apply CSS vars whenever theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeToDOM(current.colors);
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
// Sync from backend when uiConfig.theme changes (e.g. on load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (uiConfig.theme && uiConfig.theme !== themeName && themes[uiConfig.theme]) {
|
||||||
|
setThemeName(uiConfig.theme);
|
||||||
|
}
|
||||||
|
}, [uiConfig.theme, themeName]);
|
||||||
|
|
||||||
|
const setTheme = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (themes[id]) {
|
||||||
|
setThemeName(id);
|
||||||
|
updateUiConfig({ theme: id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateUiConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme: current, themeName, setTheme, themeNames: themeList }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to access ThemeContext. Throws if used outside ThemeProvider. */
|
||||||
|
export function useTheme(): ThemeContextType {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-display: "JetBrains Mono", "Inter", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
|
|
||||||
|
--color-primary: var(--df-primary);
|
||||||
|
--color-primary-hover: var(--df-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme-aware scrollbar ──────────────────────────────────────────────── */
|
||||||
|
.terminal-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--df-scroll-thumb, #30363d);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Active tab indicator ───────────────────────────────────────────────── */
|
||||||
|
.active-tab {
|
||||||
|
border-bottom: 2px solid var(--df-primary, #3b82f6);
|
||||||
|
background-color: color-mix(in srgb, var(--df-primary, #3b82f6) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Material Icons default size ────────────────────────────────────────── */
|
||||||
|
.material-icons {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global resets ──────────────────────────────────────────────────────── */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow text selection inside terminal */
|
||||||
|
.xterm {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ─────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-blink {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme-aware hover (replaces inline onMouseEnter/onMouseLeave) ──────── */
|
||||||
|
.df-hover:hover {
|
||||||
|
background-color: var(--df-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.df-hover-primary:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--df-primary) 20%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed wrapper around Tauri's `invoke()` with built-in error logging.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const result = await invoke<string>("create_ssh_session", { config });
|
||||||
|
* const sessions = await invoke<SessionInfo[]>("list_sessions");
|
||||||
|
*/
|
||||||
|
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await tauriInvoke<T>(cmd, args);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Command "${cmd}" failed`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||||
|
debug: 0,
|
||||||
|
info: 1,
|
||||||
|
warn: 2,
|
||||||
|
error: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_LEVEL: LogLevel = import.meta.env.DEV ? "debug" : "warn";
|
||||||
|
|
||||||
|
function shouldLog(level: LogLevel): boolean {
|
||||||
|
return LOG_LEVELS[level] >= LOG_LEVELS[MIN_LEVEL];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(level: LogLevel, message: string): string {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level-filtered logger: debug/info/warn/error. Respects DEV vs prod min level. */
|
||||||
|
export const logger = {
|
||||||
|
debug(message: string, ...args: unknown[]) {
|
||||||
|
if (shouldLog("debug")) {
|
||||||
|
console.debug(formatMessage("debug", message), ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
info(message: string, ...args: unknown[]) {
|
||||||
|
if (shouldLog("info")) {
|
||||||
|
console.info(formatMessage("info", message), ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
warn(message: string, ...args: unknown[]) {
|
||||||
|
if (shouldLog("warn")) {
|
||||||
|
console.warn(formatMessage("warn", message), ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
error(message: string, ...args: unknown[]) {
|
||||||
|
if (shouldLog("error")) {
|
||||||
|
console.error(formatMessage("error", message), ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import "@fontsource/jetbrains-mono/400.css";
|
||||||
|
import "@fontsource/jetbrains-mono/500.css";
|
||||||
|
import "@fontsource/jetbrains-mono/700.css";
|
||||||
|
import "@fontsource/inter/400.css";
|
||||||
|
import "@fontsource/inter/500.css";
|
||||||
|
import "@fontsource/inter/600.css";
|
||||||
|
import "material-icons/iconfont/material-icons.css";
|
||||||
|
import App from "./App";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import "./index.css";
|
||||||
|
import { AppProvider } from "./context/AppContext";
|
||||||
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AppProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</AppProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// ── Theme Definitions ──────────────────────────────────────────────────────
|
||||||
|
// Each theme defines both UI surface colors and a full xterm 16-color palette.
|
||||||
|
|
||||||
|
export interface TerminalColors {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
cursor: string;
|
||||||
|
selectionBackground: string;
|
||||||
|
black: string;
|
||||||
|
red: string;
|
||||||
|
green: string;
|
||||||
|
yellow: string;
|
||||||
|
blue: string;
|
||||||
|
magenta: string;
|
||||||
|
cyan: string;
|
||||||
|
white: string;
|
||||||
|
brightBlack: string;
|
||||||
|
brightRed: string;
|
||||||
|
brightGreen: string;
|
||||||
|
brightYellow: string;
|
||||||
|
brightBlue: string;
|
||||||
|
brightMagenta: string;
|
||||||
|
brightCyan: string;
|
||||||
|
brightWhite: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeColors {
|
||||||
|
// UI surface colors
|
||||||
|
bg: string;
|
||||||
|
bgPanel: string;
|
||||||
|
bgTerminal: string;
|
||||||
|
bgHover: string;
|
||||||
|
bgInput: string;
|
||||||
|
bgSectionHeader: string;
|
||||||
|
border: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
textDimmed: string;
|
||||||
|
primary: string;
|
||||||
|
primaryHover: string;
|
||||||
|
scrollThumb: string;
|
||||||
|
accent: string;
|
||||||
|
// Terminal 16-color ANSI palette
|
||||||
|
terminal: TerminalColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Short label for the switcher preview */
|
||||||
|
label: string;
|
||||||
|
/** Representative color swatch for the picker */
|
||||||
|
swatch: string;
|
||||||
|
colors: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GitHub Dark ────────────────────────────────────────────────────────────
|
||||||
|
const githubDark: Theme = {
|
||||||
|
id: "github-dark",
|
||||||
|
name: "GitHub Dark",
|
||||||
|
label: "GitHub",
|
||||||
|
swatch: "#0d1117",
|
||||||
|
colors: {
|
||||||
|
bg: "#0d1117",
|
||||||
|
bgPanel: "#161b22",
|
||||||
|
bgTerminal: "#0d1117",
|
||||||
|
bgHover: "#1c2128",
|
||||||
|
bgInput: "#0d1117",
|
||||||
|
bgSectionHeader: "rgba(30,38,50,0.3)",
|
||||||
|
border: "#30363d",
|
||||||
|
text: "#c9d1d9",
|
||||||
|
textMuted: "#8b949e",
|
||||||
|
textDimmed: "#6e7681",
|
||||||
|
primary: "#3b82f6",
|
||||||
|
primaryHover: "#2563eb",
|
||||||
|
scrollThumb: "#30363d",
|
||||||
|
accent: "#58a6ff",
|
||||||
|
terminal: {
|
||||||
|
background: "#0d1117",
|
||||||
|
foreground: "#c9d1d9",
|
||||||
|
cursor: "#c9d1d9",
|
||||||
|
selectionBackground: "#3b82f6",
|
||||||
|
black: "#484f58",
|
||||||
|
red: "#ff7b72",
|
||||||
|
green: "#3fb950",
|
||||||
|
yellow: "#d29922",
|
||||||
|
blue: "#58a6ff",
|
||||||
|
magenta: "#bc8cff",
|
||||||
|
cyan: "#39d353",
|
||||||
|
white: "#b1bac4",
|
||||||
|
brightBlack: "#6e7681",
|
||||||
|
brightRed: "#ffa198",
|
||||||
|
brightGreen: "#56d364",
|
||||||
|
brightYellow: "#e3b341",
|
||||||
|
brightBlue: "#79c0ff",
|
||||||
|
brightMagenta: "#d2a8ff",
|
||||||
|
brightCyan: "#56d364",
|
||||||
|
brightWhite: "#f0f6fc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dracula ────────────────────────────────────────────────────────────────
|
||||||
|
const dracula: Theme = {
|
||||||
|
id: "dracula",
|
||||||
|
name: "Dracula",
|
||||||
|
label: "Dracula",
|
||||||
|
swatch: "#282a36",
|
||||||
|
colors: {
|
||||||
|
bg: "#282a36",
|
||||||
|
bgPanel: "#21222c",
|
||||||
|
bgTerminal: "#282a36",
|
||||||
|
bgHover: "#343746",
|
||||||
|
bgInput: "#21222c",
|
||||||
|
bgSectionHeader: "rgba(68,71,90,0.3)",
|
||||||
|
border: "#44475a",
|
||||||
|
text: "#f8f8f2",
|
||||||
|
textMuted: "#bd93f9",
|
||||||
|
textDimmed: "#6272a4",
|
||||||
|
primary: "#bd93f9",
|
||||||
|
primaryHover: "#a77bfa",
|
||||||
|
scrollThumb: "#44475a",
|
||||||
|
accent: "#ff79c6",
|
||||||
|
terminal: {
|
||||||
|
background: "#282a36",
|
||||||
|
foreground: "#f8f8f2",
|
||||||
|
cursor: "#f8f8f2",
|
||||||
|
selectionBackground: "#44475a",
|
||||||
|
black: "#21222c",
|
||||||
|
red: "#ff5555",
|
||||||
|
green: "#50fa7b",
|
||||||
|
yellow: "#f1fa8c",
|
||||||
|
blue: "#bd93f9",
|
||||||
|
magenta: "#ff79c6",
|
||||||
|
cyan: "#8be9fd",
|
||||||
|
white: "#f8f8f2",
|
||||||
|
brightBlack: "#6272a4",
|
||||||
|
brightRed: "#ff6e6e",
|
||||||
|
brightGreen: "#69ff94",
|
||||||
|
brightYellow: "#ffffa5",
|
||||||
|
brightBlue: "#d6acff",
|
||||||
|
brightMagenta: "#ff92df",
|
||||||
|
brightCyan: "#a4ffff",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Nord ───────────────────────────────────────────────────────────────────
|
||||||
|
const nord: Theme = {
|
||||||
|
id: "nord",
|
||||||
|
name: "Nord",
|
||||||
|
label: "Nord",
|
||||||
|
swatch: "#2e3440",
|
||||||
|
colors: {
|
||||||
|
bg: "#2e3440",
|
||||||
|
bgPanel: "#3b4252",
|
||||||
|
bgTerminal: "#2e3440",
|
||||||
|
bgHover: "#434c5e",
|
||||||
|
bgInput: "#2e3440",
|
||||||
|
bgSectionHeader: "rgba(59,66,82,0.3)",
|
||||||
|
border: "#4c566a",
|
||||||
|
text: "#d8dee9",
|
||||||
|
textMuted: "#81a1c1",
|
||||||
|
textDimmed: "#616e88",
|
||||||
|
primary: "#88c0d0",
|
||||||
|
primaryHover: "#7bb8c9",
|
||||||
|
scrollThumb: "#4c566a",
|
||||||
|
accent: "#5e81ac",
|
||||||
|
terminal: {
|
||||||
|
background: "#2e3440",
|
||||||
|
foreground: "#d8dee9",
|
||||||
|
cursor: "#d8dee9",
|
||||||
|
selectionBackground: "#434c5e",
|
||||||
|
black: "#3b4252",
|
||||||
|
red: "#bf616a",
|
||||||
|
green: "#a3be8c",
|
||||||
|
yellow: "#ebcb8b",
|
||||||
|
blue: "#81a1c1",
|
||||||
|
magenta: "#b48ead",
|
||||||
|
cyan: "#88c0d0",
|
||||||
|
white: "#e5e9f0",
|
||||||
|
brightBlack: "#4c566a",
|
||||||
|
brightRed: "#bf616a",
|
||||||
|
brightGreen: "#a3be8c",
|
||||||
|
brightYellow: "#ebcb8b",
|
||||||
|
brightBlue: "#81a1c1",
|
||||||
|
brightMagenta: "#b48ead",
|
||||||
|
brightCyan: "#8fbcbb",
|
||||||
|
brightWhite: "#eceff4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Monokai Pro ────────────────────────────────────────────────────────────
|
||||||
|
const monokaiPro: Theme = {
|
||||||
|
id: "monokai-pro",
|
||||||
|
name: "Monokai Pro",
|
||||||
|
label: "Monokai",
|
||||||
|
swatch: "#2d2a2e",
|
||||||
|
colors: {
|
||||||
|
bg: "#2d2a2e",
|
||||||
|
bgPanel: "#221f22",
|
||||||
|
bgTerminal: "#2d2a2e",
|
||||||
|
bgHover: "#3a373b",
|
||||||
|
bgInput: "#221f22",
|
||||||
|
bgSectionHeader: "rgba(56,53,56,0.3)",
|
||||||
|
border: "#49474c",
|
||||||
|
text: "#fcfcfa",
|
||||||
|
textMuted: "#c1c0c0",
|
||||||
|
textDimmed: "#727072",
|
||||||
|
primary: "#ffd866",
|
||||||
|
primaryHover: "#e6c25e",
|
||||||
|
scrollThumb: "#49474c",
|
||||||
|
accent: "#78dce8",
|
||||||
|
terminal: {
|
||||||
|
background: "#2d2a2e",
|
||||||
|
foreground: "#fcfcfa",
|
||||||
|
cursor: "#fcfcfa",
|
||||||
|
selectionBackground: "#49474c",
|
||||||
|
black: "#403e41",
|
||||||
|
red: "#ff6188",
|
||||||
|
green: "#a9dc76",
|
||||||
|
yellow: "#ffd866",
|
||||||
|
blue: "#fc9867",
|
||||||
|
magenta: "#ab9df2",
|
||||||
|
cyan: "#78dce8",
|
||||||
|
white: "#fcfcfa",
|
||||||
|
brightBlack: "#727072",
|
||||||
|
brightRed: "#ff6188",
|
||||||
|
brightGreen: "#a9dc76",
|
||||||
|
brightYellow: "#ffd866",
|
||||||
|
brightBlue: "#fc9867",
|
||||||
|
brightMagenta: "#ab9df2",
|
||||||
|
brightCyan: "#78dce8",
|
||||||
|
brightWhite: "#fcfcfa",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Solarized Light ────────────────────────────────────────────────────────
|
||||||
|
const solarizedLight: Theme = {
|
||||||
|
id: "solarized-light",
|
||||||
|
name: "Solarized Light",
|
||||||
|
label: "Solarized",
|
||||||
|
swatch: "#fdf6e3",
|
||||||
|
colors: {
|
||||||
|
bg: "#fdf6e3",
|
||||||
|
bgPanel: "#eee8d5",
|
||||||
|
bgTerminal: "#fdf6e3",
|
||||||
|
bgHover: "#e8e1cb",
|
||||||
|
bgInput: "#fdf6e3",
|
||||||
|
bgSectionHeader: "rgba(238,232,213,0.5)",
|
||||||
|
border: "#d3cbb7",
|
||||||
|
text: "#586e75",
|
||||||
|
textMuted: "#93a1a1",
|
||||||
|
textDimmed: "#b0b8b8",
|
||||||
|
primary: "#268bd2",
|
||||||
|
primaryHover: "#1e7abc",
|
||||||
|
scrollThumb: "#d3cbb7",
|
||||||
|
accent: "#2aa198",
|
||||||
|
terminal: {
|
||||||
|
background: "#fdf6e3",
|
||||||
|
foreground: "#586e75",
|
||||||
|
cursor: "#586e75",
|
||||||
|
selectionBackground: "#eee8d5",
|
||||||
|
black: "#073642",
|
||||||
|
red: "#dc322f",
|
||||||
|
green: "#859900",
|
||||||
|
yellow: "#b58900",
|
||||||
|
blue: "#268bd2",
|
||||||
|
magenta: "#d33682",
|
||||||
|
cyan: "#2aa198",
|
||||||
|
white: "#eee8d5",
|
||||||
|
brightBlack: "#002b36",
|
||||||
|
brightRed: "#cb4b16",
|
||||||
|
brightGreen: "#586e75",
|
||||||
|
brightYellow: "#657b83",
|
||||||
|
brightBlue: "#839496",
|
||||||
|
brightMagenta: "#6c71c4",
|
||||||
|
brightCyan: "#93a1a1",
|
||||||
|
brightWhite: "#fdf6e3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Exports ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const themes: Record<string, Theme> = {
|
||||||
|
"github-dark": githubDark,
|
||||||
|
dracula,
|
||||||
|
nord,
|
||||||
|
"monokai-pro": monokaiPro,
|
||||||
|
"solarized-light": solarizedLight,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const themeList: Theme[] = [githubDark, dracula, nord, monokaiPro, solarizedLight];
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_ID = "github-dark";
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** Type of terminal session: SSH remote or local shell. */
|
||||||
|
export type SessionType = "SSH" | "Local";
|
||||||
|
|
||||||
|
/** Metadata for a connected or disconnected session. */
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
session_type: SessionType;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI tab representing a terminal session. */
|
||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
type: SessionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSH connection config for creating a session. */
|
||||||
|
export interface SshConfig {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
auth: SshAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSH authentication: password or private key (PEM content). */
|
||||||
|
export type SshAuth =
|
||||||
|
| { type: "password"; password: string }
|
||||||
|
| { type: "key"; key_data: string; passphrase?: string };
|
||||||
|
|
||||||
|
/** Group for organizing saved connections. */
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored SSH connection with host, auth, and optional group. */
|
||||||
|
export interface SavedConnection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
group?: string;
|
||||||
|
description?: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
auth_type: string;
|
||||||
|
password?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
/** File path selected via the file picker — backend reads and encrypts. */
|
||||||
|
key_file_path?: string;
|
||||||
|
/** True when an encrypted private key is already stored on disk. */
|
||||||
|
has_key_data?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout preferences: panel widths, visibility flags, theme. */
|
||||||
|
export interface UiConfig {
|
||||||
|
left_width: number;
|
||||||
|
right_width: number;
|
||||||
|
saved_conn_height: number;
|
||||||
|
history_height: number;
|
||||||
|
quick_cmd_height: number;
|
||||||
|
show_file_explorer: boolean;
|
||||||
|
show_right_sidebar: boolean;
|
||||||
|
show_quick_commands: boolean;
|
||||||
|
theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Labeled command shortcut for quick execution. */
|
||||||
|
export interface QuickCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuzzy search result with matched command and highlight indices. */
|
||||||
|
export interface FuzzyResult {
|
||||||
|
command: string;
|
||||||
|
score: number;
|
||||||
|
indices: number[];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// @ts-expect-error process is a nodejs global
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
react: ["react", "react-dom"],
|
||||||
|
xterm: [
|
||||||
|
"@xterm/xterm",
|
||||||
|
"@xterm/addon-fit",
|
||||||
|
"@xterm/addon-web-links",
|
||||||
|
"@xterm/addon-webgl",
|
||||||
|
"@xterm/addon-search",
|
||||||
|
],
|
||||||
|
tauri: ["@tauri-apps/api"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||