Fix render
This commit is contained in:
parent
f9b6b4e6fe
commit
ff9a0aae8d
19 changed files with 3979 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
target
|
||||||
|
Cargo.lock
|
||||||
29
Cargo.toml
Normal file
29
Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "komiterm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
# >=0.50: keys-модуль слит в сам russh (russh::keys вместо отдельного
|
||||||
|
# russh-keys), authenticate_publickey принимает PrivateKeyWithHashAlg,
|
||||||
|
# и есть russh::keys::{check_known_hosts, learn_known_hosts} — на них
|
||||||
|
# завязана проверка known_hosts в transport/ssh.rs. Один из крипто-бэкендов
|
||||||
|
# (ring/aws-lc-rs) обязателен, иначе не соберётся.
|
||||||
|
russh = { version = ">=0.50", features = ["ring"] }
|
||||||
|
russh-sftp = "2"
|
||||||
|
alacritty_terminal = "0.24"
|
||||||
|
ratatui = "0.28"
|
||||||
|
crossterm = "0.28"
|
||||||
|
anyhow = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
directories = "5"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rpassword = "7"
|
||||||
|
tokio-serial = "5"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "komiterm"
|
||||||
|
path = "src/main.rs"
|
||||||
486
README.md
Normal file
486
README.md
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
# KomiTerm — SSH/Telnet клиент на Rust
|
||||||
|
|
||||||
|
Минималистичная альтернатива MobaXterm под Linux: session manager + SSH/Telnet
|
||||||
|
в терминале, без X11-сервера и RDP/VNC (сознательно вырезано ради MVP).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
main.rs — CLI (clap): запуск сессии + subcommand `profile add/list/remove`
|
||||||
|
session.rs — конфиг сессий (~/.config/komiterm/sessions.toml), AuthMethod/PassphraseSource
|
||||||
|
profiles.rs — именованные credential-профили (~/.config/komiterm/credentials.toml)
|
||||||
|
hotkeys.rs — конфигурируемые хоткеи табов (~/.config/komiterm/hotkeys.toml)
|
||||||
|
sftp.rs — обёртка над russh_sftp::client::SftpSession (листинг/cd/download/upload)
|
||||||
|
localfs.rs — локальный файловый браузер для выбора файла при закачке (u в SFTP-режиме)
|
||||||
|
hoststats.rs — парсинг CPU/памяти/load average из /proc для строки состояния
|
||||||
|
sshconfig.rs — парсер ~/.ssh/config, включая Include (см. ниже)
|
||||||
|
resolve.rs — слияние приоритетов: session > credential-профиль > ssh_config > дефолты
|
||||||
|
transport/
|
||||||
|
mod.rs — трейт Transport (write/resize/close/open_sftp/run_command) — общий интерфейс
|
||||||
|
ssh.rs — реализация через russh, поддерживает Password/Key/Agent + open_sftp/run_command
|
||||||
|
telnet.rs — реализация telnet с ответом на DO/WILL (не просто дроп)
|
||||||
|
serial.rs — последовательный порт (COM/tty) через tokio-serial
|
||||||
|
term.rs — обёртка над alacritty_terminal (парсинг escape-последовательностей)
|
||||||
|
ui.rs — рендер сетки терминала + боковая панель (сессии / SFTP-браузер)
|
||||||
|
app.rs — главный цикл: incoming -> term_state -> render, ввод -> transport
|
||||||
|
```
|
||||||
|
|
||||||
|
## Аутентификация и профили
|
||||||
|
|
||||||
|
Способ входа можно задать двумя способами:
|
||||||
|
|
||||||
|
1. **Прямо в сессии** — поле `auth` в `sessions.toml`.
|
||||||
|
2. **Через именованный профиль** — заведи один раз в `credentials.toml`
|
||||||
|
(командой `komiterm profile add`), затем ссылайся на него по имени полем
|
||||||
|
`credential` из любой сессии. Удобно, когда один логин/ключ используется
|
||||||
|
для нескольких хостов.
|
||||||
|
|
||||||
|
Приоритет при разрешении (см. `resolve.rs`): explicit `auth` в сессии >
|
||||||
|
`credential`-профиль > подсказка из `~/.ssh/config` (IdentityFile) > запрос
|
||||||
|
пароля как последний вариант.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
komiterm profile add # интерактивно: имя, логин, пароль/ключ/agent*
|
||||||
|
komiterm profile list
|
||||||
|
komiterm profile remove myprofile
|
||||||
|
```
|
||||||
|
|
||||||
|
\* ssh-agent как способ входа временно не работает (см. TODO ниже) —
|
||||||
|
профиль с ним создать можно, но подключение с таким профилем откажет.
|
||||||
|
|
||||||
|
Пароли и passphrase **никогда не сохраняются на диск** — только логин,
|
||||||
|
путь к ключу и признак "спросить passphrase при подключении". Файл
|
||||||
|
`credentials.toml` на Unix создаётся с правами 600.
|
||||||
|
|
||||||
|
## known_hosts / защита от MITM
|
||||||
|
|
||||||
|
`check_server_key` в `transport/ssh.rs` больше не доверяет серверу
|
||||||
|
безусловно — сверяется с `~/.ssh/known_hosts` через `russh::keys::check_known_hosts`:
|
||||||
|
|
||||||
|
- **Ключ совпадает с записанным** — подключаемся молча, как обычно.
|
||||||
|
- **Записи для хоста нет вообще** (первое подключение) — печатается
|
||||||
|
отпечаток ключа, и спрашивается подтверждение (Trust On First Use, как
|
||||||
|
делает обычный `ssh` при "authenticity of host can't be established").
|
||||||
|
При согласии ключ дописывается в known_hosts — своей маленькой функцией
|
||||||
|
(`transport/ssh.rs::learn_known_hosts`), а не `russh::keys::learn_known_hosts`:
|
||||||
|
в используемой версии russh (0.62.2) это имя не реэкспортируется на
|
||||||
|
верхнем уровне `russh::keys` (только `check_known_hosts`), так что строку
|
||||||
|
формируем сами через `PublicKey::to_openssh()` — формат known_hosts
|
||||||
|
простой и стабилен десятилетиями, велосипед тут минимальный.
|
||||||
|
- **Запись есть, но ключ другой** — это подозрение на MITM или
|
||||||
|
переустановленный сервер. Здесь **нет** интерактивного "продолжить всё
|
||||||
|
равно": подключение отклоняется безусловно, с инструкцией вручную
|
||||||
|
почистить строку в `~/.ssh/known_hosts`, если пользователь уверен, что
|
||||||
|
смена ключа легитимна. Слабое место так просто не обойти повторным нажатием Enter.
|
||||||
|
|
||||||
|
Из-за этого пришлось поднять версию `russh` с `0.44` до текущей (`>=0.50`,
|
||||||
|
реально резолвится в `0.62.x`) — модуль работы с ключами в старой версии
|
||||||
|
жил отдельным крейтом `russh-keys` и не имел этих функций в удобном виде;
|
||||||
|
в текущей версии `russh::keys` включает и агента, и известные хосты, и
|
||||||
|
работу с ключами в одном месте. Заодно поменялся API аутентификации по
|
||||||
|
ключу (`authenticate_publickey` теперь принимает `PrivateKeyWithHashAlg`
|
||||||
|
вместо голого `Arc<PrivateKey>`) — учтено в текущем коде.
|
||||||
|
|
||||||
|
## ~/.ssh/config
|
||||||
|
|
||||||
|
Если у сессии `use_ssh_config = true` (по умолчанию), поле `host` сначала
|
||||||
|
ищется как алиас в `~/.ssh/config` — так же, как если бы ты написал
|
||||||
|
`ssh myalias`. Оттуда подтягиваются `HostName`, `User`, `Port`,
|
||||||
|
`IdentityFile`, если сессия их не переопределяет явно.
|
||||||
|
|
||||||
|
Путь один и тот же на всех трёх платформах (`<домашняя_директория>/.ssh/config`),
|
||||||
|
поэтому специального кода под Windows не потребовалось — OpenSSH для Windows
|
||||||
|
использует ту же схему.
|
||||||
|
|
||||||
|
Поддержана директива `Include` (в т.ч. с `*`-wildcard в имени файла и путями
|
||||||
|
относительно директории текущего конфига — `Include config.d/*` работает).
|
||||||
|
Совпадения `*`-паттерна обходятся в отсортированном порядке, как это делает
|
||||||
|
сам OpenSSH, иначе порядок Include стал бы недетерминированным. Есть защита
|
||||||
|
от циклов (`a.conf` включает `b.conf` включает `a.conf`) через отслеживание
|
||||||
|
уже разобранных файлов и предел глубины. Из не поддержанного: `Match`-блоки
|
||||||
|
и полноценные glob-паттерны (`?`, `[abc]`, отрицания через `!`) — см. TODO
|
||||||
|
в конце `sshconfig.rs`.
|
||||||
|
|
||||||
|
## Табы (мультиплексирование сессий)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
komiterm myserver staging-box old-switch # три таба сразу при запуске
|
||||||
|
komiterm # один таб, сессия через менеджер
|
||||||
|
```
|
||||||
|
|
||||||
|
Хоткеи читаются из `~/.config/komiterm/hotkeys.toml` (создаётся при первом
|
||||||
|
запуске с показанными ниже значениями по умолчанию; `Alt+1..Alt+9` в конфиг
|
||||||
|
не вынесены — см. комментарий в `hotkeys.rs`, почему):
|
||||||
|
|
||||||
|
| Комбинация | Действие |
|
||||||
|
|------------------------|----------------------------------------|
|
||||||
|
| `Ctrl+PageUp/PageDown` | предыдущий / следующий таб |
|
||||||
|
| `Alt+1`..`Alt+9` | прыжок на таб N |
|
||||||
|
| `F1` | фокус на боковую панель / обратно на терминал |
|
||||||
|
| `F2` | новый таб (выбор сессии из списка) |
|
||||||
|
| `Ctrl+F2` | закрыть активный таб |
|
||||||
|
| `F3` | свернуть/развернуть боковую панель |
|
||||||
|
| `F4` | SFTP-браузер для активного таба (только SSH) |
|
||||||
|
| `F10` | выйти из KomiTerm целиком |
|
||||||
|
|
||||||
|
Все табы держат живое соединение и вычитывают входящие данные каждый кадр
|
||||||
|
(фон не "замирает", даже если ты сейчас смотришь в другой таб) — рисуется
|
||||||
|
только активный. Открытие нового таба (`F2`) и начальный выбор сессии (если
|
||||||
|
она не передана аргументом) показывают список сессий прямо поверх текущего
|
||||||
|
экрана через `ratatui::widgets::List` — без выхода из raw-режима и без
|
||||||
|
"грязного" экрана, как было в первой версии.
|
||||||
|
|
||||||
|
Если сохранённых сессий вообще нет (первый запуск), список не ошибка, а
|
||||||
|
пустой экран с подсказкой — нажми `n`, чтобы создать сессию прямо тут же,
|
||||||
|
не выходя из TUI и не редактируя `sessions.toml` руками. `n` работает и
|
||||||
|
при непустом списке — добавить ещё одну сессию можно в любой момент, когда
|
||||||
|
показан этот список (при `F2` или при начальном выборе). Мастер — короткий
|
||||||
|
пошаговый опрос (протокол → имя → поля по протоколу, включая способ входа
|
||||||
|
для SSH), каждое поле — Enter продолжить, Esc отменяет всё создание сразу.
|
||||||
|
Готовая сессия дописывается в `sessions.toml` и сразу открывается табом.
|
||||||
|
|
||||||
|
**Как закрыть/выйти из одной сессии** (не из всего komiterm): `Ctrl+F2`
|
||||||
|
закрывает активный таб явно. Выйти изнутри сессии обычным способом
|
||||||
|
(`exit` или `Ctrl+D` в шелле) тоже работает — таб закрывается сам, как
|
||||||
|
только замечает, что канал от удалённой стороны закрылся (проверяется
|
||||||
|
каждый кадр). Если все табы закрылись — komiterm завершается сам, выходить
|
||||||
|
из него отдельно уже не нужно.
|
||||||
|
|
||||||
|
## Боковая панель сессий
|
||||||
|
|
||||||
|
Слева — как в MobaXterm — постоянная панель со списком всех сохранённых
|
||||||
|
сессий (`ui::render_sidebar`), фиксированной ширины (`SIDEBAR_WIDTH = 24`
|
||||||
|
колонки). Открытые сейчас как табы сессии помечены точкой, активная —
|
||||||
|
подсвечена инверсией. Сворачивается/разворачивается по `F3`, освобождая
|
||||||
|
эти 24 колонки под содержимое терминала.
|
||||||
|
|
||||||
|
Важный нюанс: при сворачивании/разворачивании панели меняется реальная
|
||||||
|
ширина видимой области терминала, поэтому `F3` не просто перерисовывает
|
||||||
|
UI — он ресайзит PTY на удалённой стороне (`transport.resize`) для всех
|
||||||
|
табов, как при обычном ресайзе окна. Без этого full-screen приложения на
|
||||||
|
той стороне (vim, htop, less) не узнали бы о появившемся/пропавшем месте.
|
||||||
|
|
||||||
|
**Панель интерактивна**, но не одновременно с терминалом — есть явный
|
||||||
|
фокус, переключаемый `F1` (если панель была скрыта, `F1` заодно её
|
||||||
|
показывает). В фокусе рамка панели становится жёлтой — это единственный
|
||||||
|
визуальный признак того, куда сейчас уйдёт следующая клавиша. Пока панель
|
||||||
|
в фокусе, **любая** клавиша принадлежит ей, а не терминалу (даже
|
||||||
|
нераспознанная — просто игнорируется), чтобы не было пограничных случаев
|
||||||
|
вида "стрелка ушла и туда, и туда":
|
||||||
|
|
||||||
|
- `↑`/`↓` (или `j`/`k`) — выбор сессии в списке
|
||||||
|
- `Enter` — переключиться на уже открытый таб этой сессии, либо открыть
|
||||||
|
новый (без модального picker'а — сессия уже выбрана курсором)
|
||||||
|
- `Esc` — вернуть фокус терминалу (панель остаётся видна)
|
||||||
|
|
||||||
|
`F4` (SFTP) при открытии сразу переводит фокус на панель — незачем
|
||||||
|
открывать браузер файлов и не иметь возможности сразу же по нему
|
||||||
|
перемещаться. `Esc` внутри SFTP-режима возвращает и режим панели к списку
|
||||||
|
сессий, и фокус терминалу — одним действием.
|
||||||
|
|
||||||
|
## SFTP-браузер
|
||||||
|
|
||||||
|
`F4` открывает в той же боковой панели файловый браузер для **активного**
|
||||||
|
таба (переиспользует уже открытое SSH-соединение — отдельный channel с
|
||||||
|
subsystem `sftp`, без нового пароля/ключа). Работает только для SSH,
|
||||||
|
для Telnet-сессий покажет заглушку "SFTP недоступен".
|
||||||
|
|
||||||
|
Пока панель в режиме SFTP, стрелки/`j`/`k`/Enter/Esc уходят в неё, а не в
|
||||||
|
шелл — так и должно быть, иначе непонятно, куда идёт навигация:
|
||||||
|
|
||||||
|
- `↑`/`↓` (или `j`/`k`) — выбор записи в списке
|
||||||
|
- `Enter` на папке — зайти внутрь; `..` наверху списка — выйти на уровень выше
|
||||||
|
- `Enter` на файле — скачать в `~/Downloads/komiterm/<имя_сессии>/`
|
||||||
|
- `u` — открыть локальный файловый браузер и закачать выбранный файл в
|
||||||
|
текущую директорию (см. ниже)
|
||||||
|
- `Esc` — вернуть панель к списку сессий (сама SFTP-сессия при этом не
|
||||||
|
закрывается — переключение обратно на `F4` покажет тот же листинг без
|
||||||
|
повторного похода на сервер)
|
||||||
|
- Повторный `F4` — то же самое, что `Esc`
|
||||||
|
|
||||||
|
### Закачка файлов (`u`)
|
||||||
|
|
||||||
|
`u` внутри SFTP-режима открывает `localfs::LocalBrowser` — тот же список
|
||||||
|
файлов, но по локальному диску, начиная с домашней директории (обычный
|
||||||
|
`std::fs`, никакого SSH). Навигация та же (`↑`/`↓`/`j`/`k`, `..` наверху),
|
||||||
|
`Enter` на файле закачивает его в текущую директорию SFTP-браузера
|
||||||
|
активного таба под тем же именем и возвращает в SFTP-режим с уже
|
||||||
|
обновлённым листингом (закачанный файл сразу виден в списке). `Esc` —
|
||||||
|
отмена, назад в SFTP-режим без закачки.
|
||||||
|
|
||||||
|
`sftp.rs` — тонкая обёртка над `russh_sftp::client::SftpSession`: листинг
|
||||||
|
директории (папки впереди файлов, дальше по алфавиту), `cd` через
|
||||||
|
`canonicalize` (чтобы `..` и симлинки резолвил сам сервер, а не наша
|
||||||
|
склейка строк), download/upload. Download/upload грузят файл целиком в
|
||||||
|
память, для реально больших файлов нужно будет стримить чанками — см.
|
||||||
|
TODO в конце `sftp.rs`.
|
||||||
|
|
||||||
|
API `russh_sftp` сверен с реальными примерами из репозитория
|
||||||
|
(`AspectUnk/russh-sftp/examples/client.rs`, тот же паттерн — в
|
||||||
|
`russh/examples/sftp_client.rs`), а не написан по памяти.
|
||||||
|
|
||||||
|
## Строка состояния хоста
|
||||||
|
|
||||||
|
Внизу экрана (под содержимым терминала, отдельной строкой) — CPU/память/
|
||||||
|
load average активного таба, обновляется раз в `STATS_INTERVAL` (5 секунд
|
||||||
|
по умолчанию, константа в `app.rs`).
|
||||||
|
|
||||||
|
Данные берутся не парсингом вывода интерактивного шелла (это было бы
|
||||||
|
ненадёжно — зависит от промпта, алиасов, запущенной программы), а отдельным
|
||||||
|
**exec-каналом** той же SSH-сессии — `Transport::run_command()`, новый
|
||||||
|
метод трейта, третий по счёту наряду с `open_sftp` (тоже дефолт `Ok(None)`
|
||||||
|
для Telnet). Команда гоняет `cat /proc/loadavg`, первые строки
|
||||||
|
`/proc/meminfo` и первую строку `/proc/stat`, разделяя секции меткой `@@@`
|
||||||
|
для надёжного парсинга (`hoststats::parse`). Соответственно работает
|
||||||
|
только на Linux-хостах и только по SSH — для Telnet и не-Linux строка
|
||||||
|
состояния просто показывает имя сессии без метрик, без ошибок в интерфейсе.
|
||||||
|
|
||||||
|
CPU% — не то же самое, что load average: это доля "не-idle" времени между
|
||||||
|
двумя последовательными снятиями `/proc/stat` (как считает `top`/`htop`),
|
||||||
|
поэтому появляется только со второго опроса — на первом кадре после
|
||||||
|
подключения будет прочерк, пока не накопится дельта.
|
||||||
|
|
||||||
|
Честный компромисс: опрос выполняется прямо в основном цикле и блокирует
|
||||||
|
отрисовку/ввод на время round-trip команды (обычно доли секунды, но на
|
||||||
|
дальних/медленных соединениях может быть заметно) — раз в 5 секунд это
|
||||||
|
редкость, но не бесплатно. Опрашивается только активный таб — у фоновых
|
||||||
|
вкладок статистика не обновляется, пока на них не переключишься.
|
||||||
|
|
||||||
|
## Serial / COM-подключения
|
||||||
|
|
||||||
|
Для консольных портов сетевого/embedded-оборудования — как в MobaXterm, но
|
||||||
|
без модема и прочего "телефонного" наследия PuTTY. Транспорт (`serial.rs`)
|
||||||
|
использует `tokio-serial` — тот же паттерн read/write-задач, что и у telnet,
|
||||||
|
просто без протокола поверх (сырые байты в обе стороны, никакого IAC).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[session]]
|
||||||
|
name = "switch-console"
|
||||||
|
protocol = "serial"
|
||||||
|
port = "/dev/ttyUSB0" # Linux/macOS; на Windows — "COM3", "COM15" и т.п.
|
||||||
|
baud_rate = 115200 # выбор скорости — обязательное поле, дефолта нет
|
||||||
|
# Остальное — опционально, дефолт 8N1 без flow control (подходит для
|
||||||
|
# подавляющего большинства консольных портов):
|
||||||
|
# data_bits = "eight" # five | six | seven | eight
|
||||||
|
# parity = "none" # none | odd | even
|
||||||
|
# stop_bits = "one" # one | two
|
||||||
|
# flow_control = "none" # none | software | hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
При подключении к serial-сессии в терминал печатается небольшая справка
|
||||||
|
(один раз, при открытии таба) — те самые нюансы, которые не видны нигде
|
||||||
|
в интерфейсе:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- Serial-подключение: /dev/ttyUSB0 @ 115200 бод ---
|
||||||
|
- Порт занят другим приложением? Один порт — одно соединение одновременно.
|
||||||
|
- Linux: Permission denied при открытии? Добавь себя в группу dialout
|
||||||
|
(или uucp) и перелогинься: sudo usermod -aG dialout $USER
|
||||||
|
- data bits/parity/stop bits/flow control должны совпадать с устройством
|
||||||
|
на другом конце — по умолчанию тут 8N1 без flow control.
|
||||||
|
```
|
||||||
|
|
||||||
|
Ограничения, которые стоит держать в голове:
|
||||||
|
- **Занятость порта.** ОС не даёт двум процессам открыть один и тот же
|
||||||
|
serial-порт одновременно — если он уже занят (в другом терминале,
|
||||||
|
в MobaXterm, в `screen`/`minicom`), подключение вернёт ошибку открытия,
|
||||||
|
а не зависнет.
|
||||||
|
- **Права доступа только на Linux/macOS.** На Windows обычно ничего
|
||||||
|
дополнительно настраивать не нужно; на Linux без группы `dialout`
|
||||||
|
(`uucp` на некоторых дистрибутивах) — `Permission denied`.
|
||||||
|
- **Настройки порта должны совпадать с устройством.** Неверный baud
|
||||||
|
rate/parity — не ошибка подключения, а нечитаемая ерунда вместо текста
|
||||||
|
("mojibake"), потому что порт открывается успешно независимо от того,
|
||||||
|
правильно ли задан протокол данных поверх него.
|
||||||
|
- **Нет ни SFTP, ни строки состояния хоста** — оба используют SSH-специфику
|
||||||
|
(`open_sftp`/`run_command`), а serial-транспорт не переопределяет эти
|
||||||
|
методы трейта, так что дефолтные `Ok(None)` отключают их автоматически,
|
||||||
|
без специального кода в UI. Строка состояния просто покажет имя сессии,
|
||||||
|
как для Telnet.
|
||||||
|
- **`resize()` — no-op.** У физического провода нет понятия "размер
|
||||||
|
терминала" — устройство на другом конце просто пишет вслепую в свой
|
||||||
|
обычный vt100/ANSI, ресайз окна KomiTerm ни на что не влияет.
|
||||||
|
|
||||||
|
## Рендер терминала
|
||||||
|
|
||||||
|
`ui::render_terminal` проходит по сетке `alacritty_terminal` напрямую через
|
||||||
|
`grid[Line(row)][Column(col)]`, а не через `display_iter()` — так проще
|
||||||
|
гарантированно сохранить построчную раскладку. Поддержано: цвет текста/фона
|
||||||
|
(Named/Indexed/Spec — включая true color), bold/dim/italic/underline/
|
||||||
|
strikeout/inverse, курсор (инверсией текущей ячейки). Соседние ячейки с
|
||||||
|
одинаковым стилем схлопываются в один `Span`, чтобы не плодить по Span на
|
||||||
|
символ на длинных однотонных строках.
|
||||||
|
|
||||||
|
## Что реально работает
|
||||||
|
|
||||||
|
Ниже — история того, что уже прогонялось через настоящий `cargo build` на
|
||||||
|
реальной машине (не только в песочнице), а не только через сверку с
|
||||||
|
документацией. Первый прогон нашёл 7 реальных ошибок компиляции, второй —
|
||||||
|
ещё одну (сигнатура `Channel::data`). После этого **`cargo build --release`
|
||||||
|
прошёл полностью** — остались только предупреждения о неиспользуемом коде
|
||||||
|
(почищены). Живое подключение к серверу/устройству пока не проверялось.
|
||||||
|
|
||||||
|
**Что было сломано и как исправлено (см. git-историю/этот README для
|
||||||
|
контекста, если он у тебя есть):**
|
||||||
|
- `async-trait` не был объявлен в `Cargo.toml`, хотя использовался everywhere
|
||||||
|
для `Transport` — добавлено.
|
||||||
|
- `russh::keys::learn_known_hosts` не реэкспортируется на верхнем уровне в
|
||||||
|
версии 0.62.2 (там есть только `check_known_hosts`) — записываем строку в
|
||||||
|
`~/.ssh/known_hosts` сами, через `PublicKey::to_openssh()` + дозапись в
|
||||||
|
файл, без обращения к приватным/непереэкспортированным функциям russh.
|
||||||
|
- `authenticate_publickey`/`authenticate_password` возвращают `AuthResult`
|
||||||
|
(с методом `.success() -> bool`), а не голый `bool`, как предполагалось
|
||||||
|
изначально — поправлено.
|
||||||
|
- `Handle::window_change` в этой версии не существует — только
|
||||||
|
`Channel::window_change`. Раз уж пришлось трогать эту часть, канал
|
||||||
|
(shell/PTY) целиком переехал в отдельную задачу, которая мультиплексирует
|
||||||
|
входящие сообщения от сервера и команды (write/resize/close) от
|
||||||
|
`Transport` через `tokio::select!`, вместо того чтобы держать `ChannelId`
|
||||||
|
и слать всё через `Handle`.
|
||||||
|
- `alacritty_terminal::event::WindowSize` не реализует нужный трейт
|
||||||
|
`Dimensions` для `Term::new`/`Term::resize` (это разные вещи: `WindowSize`
|
||||||
|
— размер в пикселях для PTY, `Dimensions` — размер в колонках/строках
|
||||||
|
символьной сетки) — завели свою маленькую структуру `GridSize` в `term.rs`.
|
||||||
|
- `Handle::data(...)` возвращает `Result<(), Bytes>` (не пропущенные данные
|
||||||
|
возвращаются как есть, а не завёрнуты в нормальный `Error`) — обёрнуто
|
||||||
|
через `map_err`, чтобы `?` не спотыкался о недостающий `impl StdError`.
|
||||||
|
- (Второй раунд, после того как первый прогон дошёл до `Channel::data`)
|
||||||
|
— `Channel::data<R: AsyncRead + Unpin>(&self, data: R)` принимает
|
||||||
|
источник для чтения, а не готовый буфер/что-то конвертируемое через
|
||||||
|
`Into` (в отличие от `Handle::data`, у которого сигнатура другая) — вместо
|
||||||
|
`data.into()` теперь `channel.data(&data[..])`, `&[u8]` сам реализует
|
||||||
|
`AsyncRead`.
|
||||||
|
- (Третий раунд — уже не ошибка компиляции, а баг в поведении: экран при
|
||||||
|
запуске не очищался.) `enable_raw_mode()` включает построчный ввод, но
|
||||||
|
сам по себе не переключает терминал на отдельный "alternate screen
|
||||||
|
buffer" — тот самый механизм, из-за которого `vim`/`htop`/`less` рисуют
|
||||||
|
на чистом экране, а при выходе возвращают терминал ровно в то состояние,
|
||||||
|
в котором он был (включая историю команд шелла). Без него komiterm рисовал
|
||||||
|
свой UI поверх текущего скроллбэка — добавлены `enter_tui()`/`leave_tui()`
|
||||||
|
в `app.rs`, вызывающие `EnterAlternateScreen`/`LeaveAlternateScreen` из
|
||||||
|
`crossterm` парно с `enable_raw_mode()`/`disable_raw_mode()` на каждом
|
||||||
|
пути выхода из `run()` (их несколько — отмена выбора сессии, ошибка
|
||||||
|
подключения, обычное завершение).
|
||||||
|
- (Четвёртый раунд — **первое подключение к реальному SSH-серверу прошло
|
||||||
|
успешно**, но нашлось два визуальных бага.) Промпт passphrase от
|
||||||
|
`rpassword` (и TOFU-промпт known_hosts) пишет прямо в tty в обход
|
||||||
|
`ratatui` — `Terminal` про эту запись не знает и её не перерисовывает,
|
||||||
|
поэтому огрызок текста промпта оставался на экране поверх строки
|
||||||
|
состояния. Добавлен `terminal.clear()?` сразу после каждого
|
||||||
|
`transport::connect()`, случающегося уже внутри TUI (стартовый picker,
|
||||||
|
`F2`, `Enter` по сессии в фокусированной панели) — форсирует полную
|
||||||
|
перерисовку экрана с нуля вместо диффинга, который не видел "чужой" записи.
|
||||||
|
- (Пятый раунд, тот же скриншот.) Название сессии в боковой панели не
|
||||||
|
помещалось / было не видно целиком. Вероятная причина — "тяжёлые"
|
||||||
|
юникод-символы (● ➤ 📁 и т.п.), чья реальная ширина в конкретном шрифте
|
||||||
|
терминала может не совпадать с тем, что предполагает расчёт колонок в
|
||||||
|
`ratatui` (типичная проблема для символов вне базовой латиницы/кириллицы —
|
||||||
|
не все шрифты меряют их как честную одну ячейку). Заменил все декоративные
|
||||||
|
символы на простой ASCII (`*`, `>`, `[D]`, `_`, `-`) и увеличил
|
||||||
|
`SIDEBAR_WIDTH` с 24 до 30 колонок с запасом. Если после этого текст
|
||||||
|
всё ещё обрезается — это, вероятно, уже про шрифт терминала (кириллица
|
||||||
|
не всегда идеально моноширинная в некоторых шрифтах), а не про код.
|
||||||
|
- (Шестой раунд — не баг, а конфликт хоткея, плюс найденный заодно баг.)
|
||||||
|
Дефолт `quit` сменён с `ctrl+alt+c` на `f10` (конфликтовал с DE/WM у
|
||||||
|
пользователя; существующие `hotkeys.toml` это не трогает — старые
|
||||||
|
конфиги правятся вручную). Заодно нашлась дыра: если сессия завершалась
|
||||||
|
сама (`exit`/`Ctrl+D` в шелле), таб не закрывался, а следующая же клавиша
|
||||||
|
в нём валила **всё приложение** — `tab.transport.write(...).await?`
|
||||||
|
пробрасывал ошибку записи в мёртвый канал через весь `main_loop`. Теперь
|
||||||
|
канал каждого таба каждый кадр проверяется на `TryRecvError::Disconnected`
|
||||||
|
(не просто "пусто сейчас", а "закрыт навсегда") — таб с завершившейся
|
||||||
|
сессией закрывается автоматически, без валящего приложение падения.
|
||||||
|
|
||||||
|
**Что осталось (честные пробелы):**
|
||||||
|
|
||||||
|
1. **ssh.rs**: `AuthMethod::Agent` **временно отключён** — метод, которым
|
||||||
|
это было изначально реализовано (`authenticate_future` на `Handle`, взят
|
||||||
|
из старого примера в репозитории russh), в используемой версии (0.62.2)
|
||||||
|
не существует. Актуальную замену не удалось подтвердить без живой
|
||||||
|
компиляции — вместо третьей попытки угадать API вслепую, честно
|
||||||
|
возвращаю ошибку с указанием использовать `Key` или `Password`. Если
|
||||||
|
agent важен — дай знать, разберёмся предметно по фактической ошибке
|
||||||
|
компилятора, как в этот раз.
|
||||||
|
2. **ui.rs**: мастер создания сессии (`n` в списке сессий) — текстовые поля
|
||||||
|
только добавляют/стирают символы с конца строки, без перемещения курсора
|
||||||
|
внутри неё и без валидации на лету (например, нечисловой порт просто
|
||||||
|
тихо упадёт на дефолт при парсинге). Для тех же целей `komiterm profile
|
||||||
|
add` (создание credential-профиля) по-прежнему только через
|
||||||
|
`komiterm profile add` в терминале, не из этого мастера — они пока не
|
||||||
|
связаны. Из боковой панели в режиме списка сессий (`F1`, не `F2`) `n`
|
||||||
|
пока не работает — только из самого пикера при `F2`/начальном запуске.
|
||||||
|
3. **sftp.rs**: закачка привязана к UI, но download/upload по-прежнему
|
||||||
|
грузят файл целиком в память, нет rename/mkdir/delete в браузере (у
|
||||||
|
SftpSession методы есть) — см. TODO в конце `sftp.rs`.
|
||||||
|
4. **hoststats.rs**: только Linux (`/proc`) и только SSH; опрос блокирует
|
||||||
|
главный цикл на время round-trip. Не проверено на реальном хосте.
|
||||||
|
5. **serial.rs**: создать serial-сессию можно и через мастер (`n`), и руками
|
||||||
|
в `sessions.toml` — но нет `SerialPort::available_ports()` ни там, ни
|
||||||
|
там (списка реально подключённых портов, а не текстового поля вслепую).
|
||||||
|
Не проверено на реальном устройстве.
|
||||||
|
6. **telnet.rs**: отвечаем на DO/WILL только для ECHO и SUPPRESS-GO-AHEAD;
|
||||||
|
NAWS (передача размера окна) не реализован.
|
||||||
|
7. **sshconfig.rs**: `Match`-блоки и полноценные glob-паттерны не поддержаны.
|
||||||
|
8. **profiles.rs**: добавление профиля — простые вопросы в stdin, не форма
|
||||||
|
в ratatui (осознанно: это CLI-подкоманда вне raw-режима основного TUI).
|
||||||
|
9. Версии крейтов в `Cargo.toml` по-прежнему не проверялись сборкой **в
|
||||||
|
песочнице** (тот же древний `rustc 1.75`, упирается в edition2024 на
|
||||||
|
первом же транзитивном пакете) — но теперь у нас есть подтверждение с
|
||||||
|
реальной машины на актуальном тулчейне, и именно оттуда взяты все
|
||||||
|
исправления выше.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Дальше по roadmap (после MVP)
|
||||||
|
|
||||||
|
- SSH-туннели (local/remote/dynamic port forwarding) — russh это умеет
|
||||||
|
- Логирование сессии в файл
|
||||||
|
- NAWS для telnet (передача размера окна серверу)
|
||||||
|
- rename/mkdir/delete в SFTP-браузере (методы у SftpSession уже есть)
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Сессии редактируются в `~/.config/komiterm/sessions.toml`, формат:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Явный ключ прямо в сессии, host — реальный, ssh_config не трогаем
|
||||||
|
[[session]]
|
||||||
|
name = "myserver"
|
||||||
|
protocol = "ssh"
|
||||||
|
host = "example.com"
|
||||||
|
port = 22
|
||||||
|
user = "vladimir"
|
||||||
|
use_ssh_config = false
|
||||||
|
[session.auth]
|
||||||
|
method = "key"
|
||||||
|
path = "/home/vladimir/.ssh/id_ed25519"
|
||||||
|
passphrase = { source = "prompt" }
|
||||||
|
|
||||||
|
# host — алиас из ~/.ssh/config; HostName/User/IdentityFile подтянутся
|
||||||
|
# оттуда сами, если тут не переопределены
|
||||||
|
[[session]]
|
||||||
|
name = "prod-box"
|
||||||
|
protocol = "ssh"
|
||||||
|
host = "prod" # ищется в ~/.ssh/config как `Host prod`
|
||||||
|
|
||||||
|
# Логин/способ входа переиспользуются из именованного профиля
|
||||||
|
[[session]]
|
||||||
|
name = "staging-box"
|
||||||
|
protocol = "ssh"
|
||||||
|
host = "staging.internal"
|
||||||
|
credential = "work-key" # см. `komiterm profile add` / credentials.toml
|
||||||
|
|
||||||
|
[[session]]
|
||||||
|
name = "old-switch"
|
||||||
|
protocol = "telnet"
|
||||||
|
host = "192.168.1.1"
|
||||||
|
port = 23
|
||||||
|
```
|
||||||
|
|
||||||
|
Профили входа лежат отдельно в `~/.config/komiterm/credentials.toml` и
|
||||||
|
управляются через `komiterm profile add/list/remove` (см. раздел выше) —
|
||||||
|
руками их редактировать не обязательно.
|
||||||
762
src/app.rs
Normal file
762
src/app.rs
Normal file
|
|
@ -0,0 +1,762 @@
|
||||||
|
use crate::hoststats::{CpuSample, HostStats};
|
||||||
|
use crate::hotkeys::{HotkeyConfig, Hotkeys};
|
||||||
|
use crate::localfs::{LocalBrowser, LocalEntry};
|
||||||
|
use crate::profiles::CredentialStore;
|
||||||
|
use crate::session::{Protocol, SessionConfig, SessionStore};
|
||||||
|
use crate::sftp::{SftpBrowser, SftpEntry};
|
||||||
|
use crate::sshconfig::SshConfig;
|
||||||
|
use crate::term::TerminalState;
|
||||||
|
use crate::{transport, ui};
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event as CEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use std::io::Stdout;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Один таб — своё подключение, свой канал входящих данных, своё
|
||||||
|
/// состояние эмулируемого терминала. Экран показывает только активный таб,
|
||||||
|
/// но incoming у всех табов вычитываются каждый кадр, чтобы фон не "замирал".
|
||||||
|
/// SFTP открывается лениво по F4 и кэшируется здесь же — второе нажатие
|
||||||
|
/// F4 на том же табе не открывает соединение заново.
|
||||||
|
pub struct Tab {
|
||||||
|
pub session_name: String,
|
||||||
|
transport: Box<dyn transport::Transport>,
|
||||||
|
incoming: transport::IncomingRx,
|
||||||
|
pub term_state: TerminalState,
|
||||||
|
pub sftp: Option<SftpBrowser>,
|
||||||
|
pub sftp_entries: Vec<SftpEntry>,
|
||||||
|
/// Статистика для строки состояния (см. hoststats.rs) — None до первого
|
||||||
|
/// успешного опроса или навсегда, если протокол/ОС не поддерживают.
|
||||||
|
pub host_stats: Option<HostStats>,
|
||||||
|
cpu_sample: Option<CpuSample>,
|
||||||
|
last_stats_fetch: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Как часто опрашивать хост под строку состояния. Опрос блокирует
|
||||||
|
/// главный цикл на время round-trip команды (обычно десятки-сотни мс) —
|
||||||
|
/// раз в 5 секунд это практически незаметно, чаще уже может быть заметно
|
||||||
|
/// на медленных/дальних соединениях.
|
||||||
|
const STATS_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Момент в прошлом, гарантированно старше STATS_INTERVAL — чтобы самый
|
||||||
|
/// первый опрос в главном цикле сработал сразу же после подключения,
|
||||||
|
/// а не только через 5 секунд ожидания.
|
||||||
|
fn stats_due_immediately() -> Instant {
|
||||||
|
Instant::now()
|
||||||
|
.checked_sub(STATS_INTERVAL)
|
||||||
|
.unwrap_or_else(Instant::now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Что сейчас показано в боковой панели, когда она развёрнута.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum SidebarMode {
|
||||||
|
Sessions,
|
||||||
|
Sftp,
|
||||||
|
/// Локальный файловый браузер для выбора файла на закачку —
|
||||||
|
/// временный режим поверх Sftp, входим по `u`, выходим по Esc или
|
||||||
|
/// после успешной закачки — обратно в Sftp.
|
||||||
|
Upload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Панель со списком сессий видна по умолчанию — как боковая панель
|
||||||
|
/// сессий в MobaXterm. Сворачивается по F3 (см. hotkeys.toml), освобождая
|
||||||
|
/// место под содержимое терминала.
|
||||||
|
const DEFAULT_SIDEBAR_VISIBLE: bool = true;
|
||||||
|
|
||||||
|
/// Хоткеи мультиплексирования (кроме Alt+1..Alt+9 — см. hotkeys.rs, почему
|
||||||
|
/// это не вынесено в конфиг) читаются из `~/.config/komiterm/hotkeys.toml`.
|
||||||
|
/// Переключиться в raw-режим + alternate screen buffer — то же самое, что
|
||||||
|
/// делают vim/htop/less: пока приложение работает, экран чистый и
|
||||||
|
/// отдельный от скроллбэка шелла, а при выходе терминал возвращается
|
||||||
|
/// ровно в то состояние, в котором был до запуска (в т.ч. видна история
|
||||||
|
/// команд шелла, а не вперемешку с нашим UI поверх неё).
|
||||||
|
fn enter_tui() -> Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = std::io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
Ok(Terminal::new(backend)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обратная операция — обязательно парная к enter_tui() на КАЖДОМ пути
|
||||||
|
/// выхода из run(), иначе терминал пользователя останется в raw-режиме
|
||||||
|
/// и/или на alternate screen после того, как komiterm уже завершился.
|
||||||
|
fn leave_tui(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
targets: Vec<SessionConfig>,
|
||||||
|
mut sessions: SessionStore,
|
||||||
|
credentials: CredentialStore,
|
||||||
|
ssh_config: SshConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
let hotkeys = HotkeyConfig::load()?.parse();
|
||||||
|
let sidebar_visible = DEFAULT_SIDEBAR_VISIBLE;
|
||||||
|
|
||||||
|
let mut tabs = Vec::with_capacity(targets.len());
|
||||||
|
for cfg in &targets {
|
||||||
|
let (transport, incoming) = transport::connect(cfg, &credentials, &ssh_config).await?;
|
||||||
|
print_serial_hint(cfg);
|
||||||
|
tabs.push(Tab {
|
||||||
|
session_name: cfg.name.clone(),
|
||||||
|
transport,
|
||||||
|
incoming,
|
||||||
|
term_state: TerminalState::new(80, 24), // пересчитается сразу ниже под реальный размер
|
||||||
|
sftp: None,
|
||||||
|
sftp_entries: Vec::new(),
|
||||||
|
host_stats: None,
|
||||||
|
cpu_sample: None,
|
||||||
|
last_stats_fetch: stats_due_immediately(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut terminal = enter_tui()?;
|
||||||
|
let size = terminal.size()?;
|
||||||
|
let content_rows = ui::content_rows(size.height); // tab bar + status bar
|
||||||
|
let cols = ui::content_cols(size.width, sidebar_visible);
|
||||||
|
for tab in &mut tabs {
|
||||||
|
tab.term_state.resize(cols, content_rows);
|
||||||
|
tab.transport.resize(cols, content_rows).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если сессии не заданы аргументами командной строки — показываем
|
||||||
|
// список для выбора прямо здесь, уже в raw-режиме, а не до него.
|
||||||
|
if tabs.is_empty() {
|
||||||
|
match ui::pick_session_tui(&mut terminal, &mut sessions) {
|
||||||
|
Ok(Some(cfg)) => match transport::connect(&cfg, &credentials, &ssh_config).await {
|
||||||
|
Ok((transport, incoming)) => {
|
||||||
|
print_serial_hint(&cfg);
|
||||||
|
// См. комментарий у аналогичного clear() в
|
||||||
|
// handle_sessions_key — тот же случай.
|
||||||
|
terminal.clear()?;
|
||||||
|
let mut term_state = TerminalState::new(cols, content_rows);
|
||||||
|
term_state.resize(cols, content_rows);
|
||||||
|
tabs.push(Tab {
|
||||||
|
session_name: cfg.name.clone(),
|
||||||
|
transport,
|
||||||
|
incoming,
|
||||||
|
term_state,
|
||||||
|
sftp: None,
|
||||||
|
sftp_entries: Vec::new(),
|
||||||
|
host_stats: None,
|
||||||
|
cpu_sample: None,
|
||||||
|
last_stats_fetch: stats_due_immediately(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
leave_tui(&mut terminal)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
leave_tui(&mut terminal)?;
|
||||||
|
return Ok(()); // пользователь отменил выбор — тихо выходим
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
leave_tui(&mut terminal)?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = 0usize;
|
||||||
|
let result = main_loop(
|
||||||
|
&mut terminal, &mut tabs, &mut active, &mut sessions, &credentials, &ssh_config, &hotkeys,
|
||||||
|
sidebar_visible,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
leave_tui(&mut terminal)?;
|
||||||
|
for tab in &mut tabs {
|
||||||
|
tab.transport.close().await.ok();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показывается один раз при подключении serial-сессии — нюансы, которые
|
||||||
|
/// нигде больше в UI не видны, но лучше знать заранее (та же справка есть
|
||||||
|
/// в README, секция про serial/COM).
|
||||||
|
fn print_serial_hint(cfg: &SessionConfig) {
|
||||||
|
if let Protocol::Serial { port, baud_rate, .. } = &cfg.protocol {
|
||||||
|
eprintln!("--- Serial-подключение: {port} @ {baud_rate} бод ---\r");
|
||||||
|
eprintln!("- Порт занят другим приложением? Один порт — одно соединение одновременно.\r");
|
||||||
|
eprintln!("- Linux: Permission denied при открытии? Добавь себя в группу dialout\r");
|
||||||
|
eprintln!(" (или uucp) и перелогинься: sudo usermod -aG dialout $USER\r");
|
||||||
|
eprintln!("- data bits/parity/stop bits/flow control должны совпадать с устройством\r");
|
||||||
|
eprintln!(" на другом конце — по умолчанию тут 8N1 без flow control.\r");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Листинг директории плюс синтетическая запись ".." для навигации наверх
|
||||||
|
/// (сам SftpBrowser::list() её не отдаёт — она нужна только для UI).
|
||||||
|
async fn list_with_parent(browser: &SftpBrowser) -> Result<Vec<SftpEntry>> {
|
||||||
|
let mut entries = browser.list().await?;
|
||||||
|
if browser.cwd() != "/" {
|
||||||
|
entries.insert(0, SftpEntry { name: "..".to_string(), is_dir: true, size: 0 });
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пересчитать размер PTY у всех табов под текущий размер терминала с
|
||||||
|
/// учётом того, видна ли сейчас боковая панель — общая логика для F1/F3
|
||||||
|
/// (и то, и другое может менять фактическую видимость панели).
|
||||||
|
async fn resize_tabs_for_sidebar(
|
||||||
|
terminal: &Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
tabs: &mut [Tab],
|
||||||
|
sidebar_visible: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let term_size = terminal.size()?;
|
||||||
|
let cols = ui::content_cols(term_size.width, sidebar_visible);
|
||||||
|
let rows = ui::content_rows(term_size.height);
|
||||||
|
for tab in tabs.iter_mut() {
|
||||||
|
tab.term_state.resize(cols, rows);
|
||||||
|
tab.transport.resize(cols, rows).await.ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn main_loop(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
tabs: &mut Vec<Tab>,
|
||||||
|
active: &mut usize,
|
||||||
|
sessions: &mut SessionStore,
|
||||||
|
credentials: &CredentialStore,
|
||||||
|
ssh_config: &SshConfig,
|
||||||
|
hotkeys: &Hotkeys,
|
||||||
|
sidebar_visible: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut sidebar_visible = sidebar_visible;
|
||||||
|
let mut sidebar_mode = SidebarMode::Sessions;
|
||||||
|
let mut sidebar_focused = false;
|
||||||
|
let mut sessions_selected = 0usize;
|
||||||
|
let mut sftp_selected = 0usize;
|
||||||
|
let mut local_browser: Option<LocalBrowser> = None;
|
||||||
|
let mut local_entries: Vec<LocalEntry> = Vec::new();
|
||||||
|
let mut local_selected = 0usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Отдельно от "пусто прямо сейчас" (Empty, нормально — просто нет
|
||||||
|
// новых данных в этом кадре) отличаем "канал закрыт навсегда"
|
||||||
|
// (Disconnected — удалённая сторона завершила сессию сама, вышли
|
||||||
|
// через exit/Ctrl+D в шелле). Раньше оба случая обрабатывались
|
||||||
|
// одинаково через `while let Ok`, из-за чего таб с завершённой
|
||||||
|
// сессией оставался висеть мёртвым, а следующая же клавиша в нём
|
||||||
|
// роняла всё приложение целиком (см. write() ниже).
|
||||||
|
let mut dead_tabs = Vec::new();
|
||||||
|
for (idx, tab) in tabs.iter_mut().enumerate() {
|
||||||
|
loop {
|
||||||
|
match tab.incoming.try_recv() {
|
||||||
|
Ok(data) => tab.term_state.advance(&data),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
dead_tabs.push(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Удаляем от большего индекса к меньшему, чтобы более ранние
|
||||||
|
// индексы в dead_tabs не съехали после предыдущего remove().
|
||||||
|
for idx in dead_tabs.into_iter().rev() {
|
||||||
|
let name = tabs[idx].session_name.clone();
|
||||||
|
tabs.remove(idx);
|
||||||
|
eprintln!("Сессия '{name}' завершилась — таб закрыт.\r");
|
||||||
|
if *active >= idx && *active > 0 {
|
||||||
|
*active -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tabs.is_empty() {
|
||||||
|
break; // все сессии закрылись сами — выходить и правда больше некуда
|
||||||
|
}
|
||||||
|
*active = (*active).min(tabs.len() - 1);
|
||||||
|
|
||||||
|
// Опрашиваем только активный таб — статистика неактивных вкладок
|
||||||
|
// в фоне никому не видна, а лишний трафик по их каналам того не
|
||||||
|
// стоит. Блокирует цикл на время round-trip команды, см. STATS_INTERVAL.
|
||||||
|
if let Some(tab) = tabs.get_mut(*active) {
|
||||||
|
if tab.last_stats_fetch.elapsed() >= STATS_INTERVAL {
|
||||||
|
tab.last_stats_fetch = Instant::now();
|
||||||
|
if let Ok(Some(raw)) = tab.transport.run_command(crate::hoststats::COMMAND).await {
|
||||||
|
if let Some((stats, sample)) = crate::hoststats::parse(&raw, tab.cpu_sample) {
|
||||||
|
tab.host_stats = Some(stats);
|
||||||
|
tab.cpu_sample = Some(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
ui::render_app(
|
||||||
|
frame, frame.area(), tabs, *active, sessions, sidebar_visible, sidebar_mode,
|
||||||
|
sidebar_focused, sessions_selected, sftp_selected, local_browser.as_ref(),
|
||||||
|
&local_entries, local_selected,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(16))? {
|
||||||
|
match event::read()? {
|
||||||
|
CEvent::Key(key) => {
|
||||||
|
if hotkeys.matches(hotkeys.quit, &key) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.next_tab, &key) {
|
||||||
|
if !tabs.is_empty() {
|
||||||
|
*active = (*active + 1) % tabs.len();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.prev_tab, &key) {
|
||||||
|
if !tabs.is_empty() {
|
||||||
|
*active = (*active + tabs.len() - 1) % tabs.len();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(idx) = direct_tab_index(&key) {
|
||||||
|
if idx < tabs.len() {
|
||||||
|
*active = idx;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.close_tab, &key) {
|
||||||
|
if !tabs.is_empty() {
|
||||||
|
tabs[*active].transport.close().await.ok();
|
||||||
|
tabs.remove(*active);
|
||||||
|
if tabs.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
*active = (*active).min(tabs.len() - 1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.new_tab, &key) {
|
||||||
|
if let Some(new_tab) = spawn_tab_interactive(
|
||||||
|
terminal, sessions, credentials, ssh_config, sidebar_visible,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
tabs.push(new_tab);
|
||||||
|
*active = tabs.len() - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.toggle_sidebar, &key) {
|
||||||
|
sidebar_visible = !sidebar_visible;
|
||||||
|
if !sidebar_visible {
|
||||||
|
sidebar_focused = false; // нечему быть в фокусе, если панель скрыта
|
||||||
|
}
|
||||||
|
// Панель отъедает колонки у терминала — при
|
||||||
|
// сворачивании/разворачивании PTY на удалённой
|
||||||
|
// стороне должен узнать новый размер, иначе
|
||||||
|
// full-screen приложения (vim, htop) не перерисуются
|
||||||
|
// под освободившееся место.
|
||||||
|
resize_tabs_for_sidebar(terminal, tabs, sidebar_visible).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.toggle_focus, &key) {
|
||||||
|
// F1 — переключить фокус между панелью и терминалом.
|
||||||
|
// Если панель была скрыта, заодно её показываем:
|
||||||
|
// фокусировать нечего невидимое.
|
||||||
|
if !sidebar_visible {
|
||||||
|
sidebar_visible = true;
|
||||||
|
resize_tabs_for_sidebar(terminal, tabs, sidebar_visible).await?;
|
||||||
|
sidebar_focused = true;
|
||||||
|
} else {
|
||||||
|
sidebar_focused = !sidebar_focused;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hotkeys.matches(hotkeys.toggle_sftp, &key) {
|
||||||
|
if sidebar_mode == SidebarMode::Sftp {
|
||||||
|
sidebar_mode = SidebarMode::Sessions;
|
||||||
|
sidebar_focused = false;
|
||||||
|
} else {
|
||||||
|
sidebar_mode = SidebarMode::Sftp;
|
||||||
|
sidebar_visible = true;
|
||||||
|
sidebar_focused = true;
|
||||||
|
sftp_selected = 0;
|
||||||
|
if let Some(tab) = tabs.get_mut(*active) {
|
||||||
|
if tab.sftp.is_none() {
|
||||||
|
match tab.transport.open_sftp().await {
|
||||||
|
Ok(Some(browser)) => match list_with_parent(&browser).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
tab.sftp_entries = entries;
|
||||||
|
tab.sftp = Some(browser);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("SFTP: не удалось получить список файлов: {e}\r"),
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
eprintln!("SFTP недоступен для этой сессии (не SSH)\r");
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("SFTP: не удалось открыть: {e}\r"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пока панель в фокусе, любая клавиша принадлежит ей,
|
||||||
|
// а не удалённому шеллу — иначе непонятно, куда должна
|
||||||
|
// идти навигация. Вернуть фокус терминалу — F1, Esc
|
||||||
|
// (внутри соответствующего режима) тоже это делает.
|
||||||
|
if sidebar_visible && sidebar_focused {
|
||||||
|
// 'u' внутри SFTP-режима — выбрать локальный файл
|
||||||
|
// на закачку. Только если для этого таба SFTP уже
|
||||||
|
// реально открыт (иначе некуда закачивать).
|
||||||
|
if sidebar_mode == SidebarMode::Sftp
|
||||||
|
&& key.code == KeyCode::Char('u')
|
||||||
|
&& tabs.get(*active).is_some_and(|t| t.sftp.is_some())
|
||||||
|
{
|
||||||
|
sidebar_mode = SidebarMode::Upload;
|
||||||
|
let browser = local_browser.get_or_insert_with(LocalBrowser::new);
|
||||||
|
local_entries = browser.list_with_parent().unwrap_or_default();
|
||||||
|
local_selected = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match sidebar_mode {
|
||||||
|
SidebarMode::Sessions => {
|
||||||
|
handle_sessions_key(
|
||||||
|
&key, sessions, tabs, active, &mut sessions_selected,
|
||||||
|
credentials, ssh_config, terminal, sidebar_visible,
|
||||||
|
&mut sidebar_focused,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
SidebarMode::Sftp => {
|
||||||
|
handle_sftp_key(
|
||||||
|
&key, tabs, *active, &mut sftp_selected, &mut sidebar_mode,
|
||||||
|
&mut sidebar_focused,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
SidebarMode::Upload => {
|
||||||
|
handle_upload_key(
|
||||||
|
&key, tabs, *active, &mut local_browser, &mut local_entries,
|
||||||
|
&mut local_selected, &mut sidebar_mode,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bytes) = key_to_bytes(key) {
|
||||||
|
if let Some(tab) = tabs.get_mut(*active) {
|
||||||
|
// Не пробрасываем ошибку через ? — если канал
|
||||||
|
// уже мёртв (сессия завершилась сама между
|
||||||
|
// кадрами), таб закроется сам на следующей
|
||||||
|
// итерации через ветку Disconnected выше, а не
|
||||||
|
// уронит всё приложение прямо здесь.
|
||||||
|
tab.transport.write(&bytes).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CEvent::Resize(term_cols, term_rows) => {
|
||||||
|
let cols = ui::content_cols(term_cols, sidebar_visible);
|
||||||
|
let rows = ui::content_rows(term_rows);
|
||||||
|
for tab in tabs.iter_mut() {
|
||||||
|
tab.term_state.resize(cols, rows);
|
||||||
|
tab.transport.resize(cols, rows).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка клавиш, пока панель в фокусе и показывает список сессий:
|
||||||
|
/// навигация ↑/↓/j/k, Enter — переключиться на уже открытый таб с этой
|
||||||
|
/// сессией либо открыть новый (не показывая модальный picker — сессия уже
|
||||||
|
/// выбрана курсором), Esc — вернуть фокус терминалу.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn handle_sessions_key(
|
||||||
|
key: &KeyEvent,
|
||||||
|
sessions: &SessionStore,
|
||||||
|
tabs: &mut Vec<Tab>,
|
||||||
|
active: &mut usize,
|
||||||
|
sessions_selected: &mut usize,
|
||||||
|
credentials: &CredentialStore,
|
||||||
|
ssh_config: &SshConfig,
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
sidebar_visible: bool,
|
||||||
|
sidebar_focused: &mut bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
*sidebar_focused = false;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if sessions.sessions.is_empty() {
|
||||||
|
return Ok(()); // нечего листать — просто игнорируем стрелки/Enter
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*sessions_selected = sessions_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
*sessions_selected = (*sessions_selected + 1).min(sessions.sessions.len() - 1);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let idx = (*sessions_selected).min(sessions.sessions.len() - 1);
|
||||||
|
let cfg = sessions.sessions[idx].clone();
|
||||||
|
|
||||||
|
if let Some(pos) = tabs.iter().position(|t| t.session_name == cfg.name) {
|
||||||
|
*active = pos;
|
||||||
|
} else {
|
||||||
|
match transport::connect(&cfg, credentials, ssh_config).await {
|
||||||
|
Ok((transport, incoming)) => {
|
||||||
|
print_serial_hint(&cfg);
|
||||||
|
// rpassword/TOFU-промпт внутри connect() пишет прямо в
|
||||||
|
// tty в обход ratatui — Terminal об этой записи не
|
||||||
|
// знает и на следующий кадр может не перерисовать то
|
||||||
|
// место (диффинг не видит "чужого" изменения). Полный
|
||||||
|
// clear() форсирует перерисовку всего экрана с нуля.
|
||||||
|
terminal.clear()?;
|
||||||
|
let size = terminal.size()?;
|
||||||
|
let cols = ui::content_cols(size.width, sidebar_visible);
|
||||||
|
let rows = ui::content_rows(size.height);
|
||||||
|
let mut term_state = TerminalState::new(cols, rows);
|
||||||
|
term_state.resize(cols, rows);
|
||||||
|
tabs.push(Tab {
|
||||||
|
session_name: cfg.name.clone(),
|
||||||
|
transport,
|
||||||
|
incoming,
|
||||||
|
term_state,
|
||||||
|
sftp: None,
|
||||||
|
sftp_entries: Vec::new(),
|
||||||
|
host_stats: None,
|
||||||
|
cpu_sample: None,
|
||||||
|
last_stats_fetch: stats_due_immediately(),
|
||||||
|
});
|
||||||
|
*active = tabs.len() - 1;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Не удалось подключиться к {}: {e}\r", cfg.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка клавиш, пока фокус на SFTP-браузере. Esc (и повторный F4 —
|
||||||
|
/// см. main_loop) возвращают режим панели к списку сессий и снимают фокус.
|
||||||
|
async fn handle_sftp_key(
|
||||||
|
key: &KeyEvent,
|
||||||
|
tabs: &mut [Tab],
|
||||||
|
active: usize,
|
||||||
|
sftp_selected: &mut usize,
|
||||||
|
sidebar_mode: &mut SidebarMode,
|
||||||
|
sidebar_focused: &mut bool,
|
||||||
|
) {
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
*sidebar_mode = SidebarMode::Sessions;
|
||||||
|
*sidebar_focused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(tab) = tabs.get_mut(active) else { return };
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*sftp_selected = sftp_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if !tab.sftp_entries.is_empty() {
|
||||||
|
*sftp_selected = (*sftp_selected + 1).min(tab.sftp_entries.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let Some(entry) = tab.sftp_entries.get(*sftp_selected).cloned() else { return };
|
||||||
|
let Some(browser) = tab.sftp.as_mut() else { return };
|
||||||
|
|
||||||
|
if entry.is_dir {
|
||||||
|
match browser.enter(&entry.name).await {
|
||||||
|
Ok(()) => match list_with_parent(browser).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
tab.sftp_entries = entries;
|
||||||
|
*sftp_selected = 0;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("SFTP: {e}\r"),
|
||||||
|
},
|
||||||
|
Err(e) => eprintln!("SFTP: не удалось зайти в {}: {e}\r", entry.name),
|
||||||
|
}
|
||||||
|
} else if let Some(download_dir) = crate::sftp::default_download_dir(&tab.session_name) {
|
||||||
|
let local_path = download_dir.join(&entry.name);
|
||||||
|
match browser.download(&entry.name, &local_path).await {
|
||||||
|
Ok(()) => eprintln!("Скачано в {}\r", local_path.display()),
|
||||||
|
Err(e) => eprintln!("SFTP: не удалось скачать {}: {e}\r", entry.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка клавиш в режиме выбора локального файла на закачку (`u`
|
||||||
|
/// внутри SFTP-режима). Esc возвращает в SFTP-режим без закачки; Enter на
|
||||||
|
/// файле закачивает его в текущую директорию SFTP-браузера активного таба
|
||||||
|
/// и тоже возвращает в SFTP-режим, но уже с обновлённым листингом.
|
||||||
|
async fn handle_upload_key(
|
||||||
|
key: &KeyEvent,
|
||||||
|
tabs: &mut [Tab],
|
||||||
|
active: usize,
|
||||||
|
local_browser: &mut Option<LocalBrowser>,
|
||||||
|
local_entries: &mut Vec<LocalEntry>,
|
||||||
|
local_selected: &mut usize,
|
||||||
|
sidebar_mode: &mut SidebarMode,
|
||||||
|
) {
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
*sidebar_mode = SidebarMode::Sftp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(browser) = local_browser.as_mut() else {
|
||||||
|
*sidebar_mode = SidebarMode::Sftp;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*local_selected = local_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if !local_entries.is_empty() {
|
||||||
|
*local_selected = (*local_selected + 1).min(local_entries.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let Some(entry) = local_entries.get(*local_selected).cloned() else { return };
|
||||||
|
|
||||||
|
if entry.is_dir {
|
||||||
|
match browser.enter(&entry.name) {
|
||||||
|
Ok(()) => match browser.list_with_parent() {
|
||||||
|
Ok(entries) => {
|
||||||
|
*local_entries = entries;
|
||||||
|
*local_selected = 0;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Локальная папка: {e}\r"),
|
||||||
|
},
|
||||||
|
Err(e) => eprintln!("Не удалось зайти в {}: {e}\r", entry.name),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_path = browser.full_path(&entry.name);
|
||||||
|
if let Some(tab) = tabs.get_mut(active) {
|
||||||
|
// upload/list берут &self, а не &mut — заимствование tab.sftp
|
||||||
|
// заканчивается вместе с этим блоком, до присваивания
|
||||||
|
// tab.sftp_entries чуть ниже (иначе конфликт заимствований
|
||||||
|
// с tab.sftp_entries как отдельным полем той же структуры).
|
||||||
|
let result = if let Some(sftp) = tab.sftp.as_ref() {
|
||||||
|
match sftp.upload(&local_path, &entry.name).await {
|
||||||
|
Ok(()) => list_with_parent(sftp).await,
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(entries) => {
|
||||||
|
tab.sftp_entries = entries;
|
||||||
|
eprintln!("Загружено: {}\r", entry.name);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Не удалось загрузить {}: {e}\r", entry.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*sidebar_mode = SidebarMode::Sftp;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открытие нового таба через тот же список сессий в ratatui, что и при
|
||||||
|
/// старте — никакого выхода из raw-режима и "грязного" экрана, в отличие
|
||||||
|
/// от прежней stdin-версии.
|
||||||
|
async fn spawn_tab_interactive(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
sessions: &mut SessionStore,
|
||||||
|
credentials: &CredentialStore,
|
||||||
|
ssh_config: &SshConfig,
|
||||||
|
sidebar_visible: bool,
|
||||||
|
) -> Result<Option<Tab>> {
|
||||||
|
let cfg = match ui::pick_session_tui(terminal, sessions) {
|
||||||
|
Ok(Some(cfg)) => cfg,
|
||||||
|
Ok(None) => return Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
// Например, нет сохранённых сессий вообще — просто не открываем
|
||||||
|
// таб, не роняя остальные уже открытые. TODO: показать это
|
||||||
|
// сообщение как toast внутри TUI, а не в stderr под raw-режимом.
|
||||||
|
eprintln!("{e}\r");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (transport, incoming) = transport::connect(&cfg, credentials, ssh_config).await?;
|
||||||
|
print_serial_hint(&cfg);
|
||||||
|
// См. комментарий у аналогичного clear() в handle_sessions_key — тот же случай.
|
||||||
|
terminal.clear()?;
|
||||||
|
let size = crossterm::terminal::size()?;
|
||||||
|
let cols = ui::content_cols(size.0, sidebar_visible);
|
||||||
|
let rows = ui::content_rows(size.1);
|
||||||
|
let mut term_state = TerminalState::new(cols, rows);
|
||||||
|
term_state.resize(cols, rows);
|
||||||
|
|
||||||
|
Ok(Some(Tab {
|
||||||
|
session_name: cfg.name.clone(),
|
||||||
|
transport,
|
||||||
|
incoming,
|
||||||
|
term_state,
|
||||||
|
sftp: None,
|
||||||
|
sftp_entries: Vec::new(),
|
||||||
|
host_stats: None,
|
||||||
|
cpu_sample: None,
|
||||||
|
last_stats_fetch: stats_due_immediately(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alt+1..Alt+9 — прямой переход на таб N (0-индексация внутри). Не вынесено
|
||||||
|
/// в hotkeys.toml — см. комментарий у Hotkeys в hotkeys.rs.
|
||||||
|
fn direct_tab_index(key: &KeyEvent) -> Option<usize> {
|
||||||
|
if !key.modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
|
||||||
|
Some(c as usize - '1' as usize)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перевод крестерм-события клавиши в байты, которые ожидает удалённая
|
||||||
|
/// сторона (обычная печать, Enter -> \r, стрелки -> ANSI escape и т.д.).
|
||||||
|
/// TODO: расширить до полного набора (F-клавиши кроме зарезервированных
|
||||||
|
/// под табы, Home/End, PageUp/Down без Ctrl, корректная обработка
|
||||||
|
/// application vs normal cursor mode).
|
||||||
|
fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => Some(c.to_string().into_bytes()),
|
||||||
|
KeyCode::Enter => Some(b"\r".to_vec()),
|
||||||
|
KeyCode::Backspace => Some(vec![0x7f]),
|
||||||
|
KeyCode::Tab => Some(b"\t".to_vec()),
|
||||||
|
KeyCode::Esc => Some(vec![0x1b]),
|
||||||
|
KeyCode::Up => Some(b"\x1b[A".to_vec()),
|
||||||
|
KeyCode::Down => Some(b"\x1b[B".to_vec()),
|
||||||
|
KeyCode::Right => Some(b"\x1b[C".to_vec()),
|
||||||
|
KeyCode::Left => Some(b"\x1b[D".to_vec()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/hoststats.rs
Normal file
96
src/hoststats.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/// Команда, которую периодически гоняем через `Transport::run_command`.
|
||||||
|
/// Три секции, разделённые "@@@", чтобы parse() не путался, где кончается
|
||||||
|
/// одна и начинается другая (метки не пересекаются с реальным выводом).
|
||||||
|
/// Linux-специфично (/proc) — на BSD/macOS просто не распарсится, и
|
||||||
|
/// строка состояния тихо не покажется (см. parse() -> Option).
|
||||||
|
pub const COMMAND: &str = "cat /proc/loadavg 2>/dev/null; echo '@@@'; \
|
||||||
|
cat /proc/meminfo 2>/dev/null | head -3; echo '@@@'; \
|
||||||
|
cat /proc/stat 2>/dev/null | head -1";
|
||||||
|
|
||||||
|
/// Что реально показываем в строке состояния.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HostStats {
|
||||||
|
pub load1: f32,
|
||||||
|
pub load5: f32,
|
||||||
|
pub load15: f32,
|
||||||
|
pub mem_total_kb: u64,
|
||||||
|
pub mem_available_kb: u64,
|
||||||
|
/// None до второй выборки — % CPU считается как дельта между двумя
|
||||||
|
/// последовательными снятиями /proc/stat, одной точки недостаточно.
|
||||||
|
pub cpu_percent: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сырые накопительные счётчики из /proc/stat, нужные только чтобы
|
||||||
|
/// посчитать дельту к следующей выборке — сами по себе бессмысленны.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CpuSample {
|
||||||
|
idle: u64,
|
||||||
|
total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разобрать вывод COMMAND. `prev` — CPU-снятие с предыдущего вызова этой
|
||||||
|
/// же функции для этого таба (или None при первом вызове после подключения
|
||||||
|
/// / после смены хоста). Возвращает разобранную статистику и новый сэмпл
|
||||||
|
/// CPU — его нужно сохранить и передать следующим вызовом.
|
||||||
|
pub fn parse(raw: &str, prev: Option<CpuSample>) -> Option<(HostStats, CpuSample)> {
|
||||||
|
let mut sections = raw.splitn(3, "@@@");
|
||||||
|
let loadavg_raw = sections.next()?.trim();
|
||||||
|
let meminfo_raw = sections.next()?.trim();
|
||||||
|
let stat_raw = sections.next()?.trim();
|
||||||
|
|
||||||
|
let mut loads = loadavg_raw.split_whitespace();
|
||||||
|
let load1: f32 = loads.next()?.parse().ok()?;
|
||||||
|
let load5: f32 = loads.next()?.parse().ok()?;
|
||||||
|
let load15: f32 = loads.next()?.parse().ok()?;
|
||||||
|
|
||||||
|
let mut mem_total_kb = 0u64;
|
||||||
|
let mut mem_available_kb = 0u64;
|
||||||
|
let mut mem_free_kb = 0u64;
|
||||||
|
for line in meminfo_raw.lines() {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let key = parts.next().unwrap_or("");
|
||||||
|
let value: u64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
match key {
|
||||||
|
"MemTotal:" => mem_total_kb = value,
|
||||||
|
"MemAvailable:" => mem_available_kb = value,
|
||||||
|
"MemFree:" => mem_free_kb = value,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ядра старее ~3.14 не отдают MemAvailable — тогда падаем на MemFree
|
||||||
|
// (грубее: не учитывает reclaimable cache, но лучше, чем ничего).
|
||||||
|
if mem_available_kb == 0 {
|
||||||
|
mem_available_kb = mem_free_kb;
|
||||||
|
}
|
||||||
|
if mem_total_kb == 0 {
|
||||||
|
return None; // /proc/meminfo не распарсился вообще — не Linux или нет доступа
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stat_fields = stat_raw.split_whitespace();
|
||||||
|
if stat_fields.next() != Some("cpu") {
|
||||||
|
return None; // не похоже на первую строку /proc/stat
|
||||||
|
}
|
||||||
|
let values: Vec<u64> = stat_fields.filter_map(|v| v.parse().ok()).collect();
|
||||||
|
if values.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// user nice system idle iowait irq softirq steal guest guest_nice —
|
||||||
|
// idle-время это idle + iowait, всё остальное — "занятость".
|
||||||
|
let idle = values[3] + values.get(4).copied().unwrap_or(0);
|
||||||
|
let total: u64 = values.iter().sum();
|
||||||
|
let sample = CpuSample { idle, total };
|
||||||
|
|
||||||
|
let cpu_percent = prev.and_then(|p| {
|
||||||
|
let total_delta = sample.total.checked_sub(p.total)?;
|
||||||
|
let idle_delta = sample.idle.checked_sub(p.idle)?;
|
||||||
|
if total_delta == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((total_delta.saturating_sub(idle_delta)) as f32 / total_delta as f32) * 100.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some((
|
||||||
|
HostStats { load1, load5, load15, mem_total_kb, mem_available_kb, cpu_percent },
|
||||||
|
sample,
|
||||||
|
))
|
||||||
|
}
|
||||||
155
src/hotkeys.rs
Normal file
155
src/hotkeys.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Комбинации клавиш для управления табами и выходом — вынесены из app.rs,
|
||||||
|
/// чтобы их можно было менять, не трогая код. Хранятся как человекочитаемые
|
||||||
|
/// строки вида "ctrl+pagedown", парсятся один раз при загрузке.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
pub quit: String,
|
||||||
|
pub next_tab: String,
|
||||||
|
pub prev_tab: String,
|
||||||
|
pub new_tab: String,
|
||||||
|
pub close_tab: String,
|
||||||
|
pub toggle_sidebar: String,
|
||||||
|
pub toggle_sftp: String,
|
||||||
|
pub toggle_focus: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HotkeyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quit: "f10".into(),
|
||||||
|
next_tab: "ctrl+pagedown".into(),
|
||||||
|
prev_tab: "ctrl+pageup".into(),
|
||||||
|
new_tab: "f2".into(),
|
||||||
|
close_tab: "ctrl+f2".into(),
|
||||||
|
toggle_sidebar: "f3".into(),
|
||||||
|
toggle_sftp: "f4".into(),
|
||||||
|
toggle_focus: "f1".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разобранная и готовая к сравнению с KeyEvent версия HotkeyConfig.
|
||||||
|
/// direct_tab_index (Alt+1..Alt+9) в конфиг сознательно не вынесен — это
|
||||||
|
/// не одна комбинация, а диапазон, конфигурировать его по одной клавише
|
||||||
|
/// было бы не многим полезнее, чем просто задать сам факт "Alt+цифра".
|
||||||
|
pub struct Hotkeys {
|
||||||
|
pub quit: (KeyCode, KeyModifiers),
|
||||||
|
pub next_tab: (KeyCode, KeyModifiers),
|
||||||
|
pub prev_tab: (KeyCode, KeyModifiers),
|
||||||
|
pub new_tab: (KeyCode, KeyModifiers),
|
||||||
|
pub close_tab: (KeyCode, KeyModifiers),
|
||||||
|
pub toggle_sidebar: (KeyCode, KeyModifiers),
|
||||||
|
pub toggle_sftp: (KeyCode, KeyModifiers),
|
||||||
|
pub toggle_focus: (KeyCode, KeyModifiers),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyConfig {
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm")
|
||||||
|
.context("не удалось определить домашнюю директорию")?;
|
||||||
|
Ok(dirs.config_dir().join("hotkeys.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let default = Self::default();
|
||||||
|
fs::write(&path, toml::to_string_pretty(&default)?)?;
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path)?;
|
||||||
|
Ok(toml::from_str(&raw)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разобрать строковые комбинации в (KeyCode, KeyModifiers). Если
|
||||||
|
/// какая-то строка не распозналась — откатываемся на дефолт для этого
|
||||||
|
/// конкретного поля и предупреждаем в stderr, а не падаем целиком:
|
||||||
|
/// опечатка в одной хоткее не должна ронять весь конфиг.
|
||||||
|
pub fn parse(&self) -> Hotkeys {
|
||||||
|
let default = HotkeyConfig::default();
|
||||||
|
Hotkeys {
|
||||||
|
quit: parse_combo(&self.quit).unwrap_or_else(|| {
|
||||||
|
warn_fallback("quit", &self.quit);
|
||||||
|
parse_combo(&default.quit).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
next_tab: parse_combo(&self.next_tab).unwrap_or_else(|| {
|
||||||
|
warn_fallback("next_tab", &self.next_tab);
|
||||||
|
parse_combo(&default.next_tab).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
prev_tab: parse_combo(&self.prev_tab).unwrap_or_else(|| {
|
||||||
|
warn_fallback("prev_tab", &self.prev_tab);
|
||||||
|
parse_combo(&default.prev_tab).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
new_tab: parse_combo(&self.new_tab).unwrap_or_else(|| {
|
||||||
|
warn_fallback("new_tab", &self.new_tab);
|
||||||
|
parse_combo(&default.new_tab).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
close_tab: parse_combo(&self.close_tab).unwrap_or_else(|| {
|
||||||
|
warn_fallback("close_tab", &self.close_tab);
|
||||||
|
parse_combo(&default.close_tab).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
toggle_sidebar: parse_combo(&self.toggle_sidebar).unwrap_or_else(|| {
|
||||||
|
warn_fallback("toggle_sidebar", &self.toggle_sidebar);
|
||||||
|
parse_combo(&default.toggle_sidebar).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
toggle_sftp: parse_combo(&self.toggle_sftp).unwrap_or_else(|| {
|
||||||
|
warn_fallback("toggle_sftp", &self.toggle_sftp);
|
||||||
|
parse_combo(&default.toggle_sftp).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
toggle_focus: parse_combo(&self.toggle_focus).unwrap_or_else(|| {
|
||||||
|
warn_fallback("toggle_focus", &self.toggle_focus);
|
||||||
|
parse_combo(&default.toggle_focus).expect("дефолтная комбинация валидна")
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_fallback(field: &str, value: &str) {
|
||||||
|
eprintln!("komiterm: не удалось разобрать хоткей {field} = \"{value}\", использую значение по умолчанию");
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hotkeys {
|
||||||
|
pub fn matches(&self, combo: (KeyCode, KeyModifiers), key: &KeyEvent) -> bool {
|
||||||
|
key.code == combo.0 && key.modifiers == combo.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает строку вида "ctrl+alt+c" или "f2" в (KeyCode, KeyModifiers).
|
||||||
|
/// Порядок частей не важен, регистр не важен. Поддержаны модификаторы
|
||||||
|
/// ctrl/alt/shift, буквы/цифры как есть, и f1..f12.
|
||||||
|
fn parse_combo(spec: &str) -> Option<(KeyCode, KeyModifiers)> {
|
||||||
|
let mut modifiers = KeyModifiers::NONE;
|
||||||
|
let mut code = None;
|
||||||
|
|
||||||
|
for part in spec.split('+') {
|
||||||
|
let part = part.trim().to_ascii_lowercase();
|
||||||
|
match part.as_str() {
|
||||||
|
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||||
|
"alt" => modifiers |= KeyModifiers::ALT,
|
||||||
|
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||||
|
"pageup" => code = Some(KeyCode::PageUp),
|
||||||
|
"pagedown" => code = Some(KeyCode::PageDown),
|
||||||
|
"" => {}
|
||||||
|
other => {
|
||||||
|
if let Some(n) = other.strip_prefix('f').and_then(|d| d.parse::<u8>().ok()) {
|
||||||
|
code = Some(KeyCode::F(n));
|
||||||
|
} else if other.chars().count() == 1 {
|
||||||
|
code = Some(KeyCode::Char(other.chars().next()?));
|
||||||
|
} else {
|
||||||
|
return None; // неизвестная часть комбинации
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code.map(|c| (c, modifiers))
|
||||||
|
}
|
||||||
86
src/localfs.rs
Normal file
86
src/localfs.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Один файл или директория в листинге локального браузера (для выбора,
|
||||||
|
/// что закачать в SFTP — см. app.rs::handle_upload_key).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Браузер локальной файловой системы — почти зеркало `sftp::SftpBrowser`,
|
||||||
|
/// но синхронный (обычный `std::fs`, а не сетевой SFTP) и без открытия
|
||||||
|
/// какого-либо соединения: начинает с домашней директории пользователя.
|
||||||
|
pub struct LocalBrowser {
|
||||||
|
cwd: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalBrowser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let cwd = directories::UserDirs::new()
|
||||||
|
.map(|d| d.home_dir().to_path_buf())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
Self { cwd }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cwd(&self) -> &Path {
|
||||||
|
&self.cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self) -> Result<Vec<LocalEntry>> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&self.cwd)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
entries.push(LocalEntry {
|
||||||
|
name: entry.file_name().to_string_lossy().to_string(),
|
||||||
|
is_dir: metadata.is_dir(),
|
||||||
|
size: metadata.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
|
(false, true) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Листинг плюс синтетическая ".." сверху — как и в SftpBrowser, эта
|
||||||
|
/// запись нужна только для UI, сам read_dir её не отдаёт.
|
||||||
|
pub fn list_with_parent(&self) -> Result<Vec<LocalEntry>> {
|
||||||
|
let mut entries = self.list()?;
|
||||||
|
if self.cwd.parent().is_some() {
|
||||||
|
entries.insert(0, LocalEntry { name: "..".to_string(), is_dir: true, size: 0 });
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter(&mut self, name: &str) -> Result<()> {
|
||||||
|
if name == ".." {
|
||||||
|
if let Some(parent) = self.cwd.parent() {
|
||||||
|
self.cwd = parent.to_path_buf();
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let new_path = self.cwd.join(name);
|
||||||
|
if !new_path.is_dir() {
|
||||||
|
anyhow::bail!("{} — не директория", new_path.display());
|
||||||
|
}
|
||||||
|
self.cwd = new_path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn full_path(&self, name: &str) -> PathBuf {
|
||||||
|
self.cwd.join(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: листинг синхронный (обычный std::fs) — вызывается прямо из async
|
||||||
|
// handle_upload_key в app.rs, что на медленных/сетевых ФС (NFS-примонтированная
|
||||||
|
// домашняя директория и т.п.) может на мгновение подвесить весь TUI. Для
|
||||||
|
// локального диска это практически всегда быстрее одного кадра, так что
|
||||||
|
// не обёрнуто в spawn_blocking — но если станет заметно, обернуть туда же,
|
||||||
|
// куда и TOFU-промпт в ssh.rs.
|
||||||
108
src/main.rs
Normal file
108
src/main.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
mod app;
|
||||||
|
mod hoststats;
|
||||||
|
mod hotkeys;
|
||||||
|
mod localfs;
|
||||||
|
mod profiles;
|
||||||
|
mod resolve;
|
||||||
|
mod session;
|
||||||
|
mod sftp;
|
||||||
|
mod sshconfig;
|
||||||
|
mod term;
|
||||||
|
mod transport;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
/// KomiTerm — минималистичный MobaXterm-подобный клиент (SSH/Telnet) на Rust
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "komiterm")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
|
||||||
|
/// Имена сохранённых сессий — каждая откроется в своём табе
|
||||||
|
/// (`komiterm myserver staging-box`). Без аргументов и без subcommand —
|
||||||
|
/// открывается менеджер сессий и запускается один таб.
|
||||||
|
session: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum Command {
|
||||||
|
/// Управление профилями входа (логин + пароль/ключ/agent),
|
||||||
|
/// переиспользуемыми между сессиями.
|
||||||
|
Profile {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ProfileAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum ProfileAction {
|
||||||
|
/// Добавить новый профиль (интерактивно спросит логин и способ входа)
|
||||||
|
Add,
|
||||||
|
/// Показать все сохранённые профили
|
||||||
|
List,
|
||||||
|
/// Удалить профиль по имени
|
||||||
|
Remove { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if let Some(Command::Profile { action }) = cli.command {
|
||||||
|
return handle_profile_action(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions = session::SessionStore::load()?;
|
||||||
|
|
||||||
|
// Пустой список — не ошибка: app::run покажет список сессий прямо
|
||||||
|
// в TUI (уже в raw-режиме), а не будет спрашивать тут через stdin.
|
||||||
|
let targets = cli
|
||||||
|
.session
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
sessions
|
||||||
|
.get(name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("сессия '{name}' не найдена"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let credentials = profiles::CredentialStore::load()?;
|
||||||
|
let ssh_config = sshconfig::SshConfig::load_default()?;
|
||||||
|
|
||||||
|
app::run(targets, sessions, credentials, ssh_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_profile_action(action: ProfileAction) -> Result<()> {
|
||||||
|
let mut store = profiles::CredentialStore::load()?;
|
||||||
|
match action {
|
||||||
|
ProfileAction::Add => {
|
||||||
|
let profile = profiles::prompt_new_profile()?;
|
||||||
|
store.profiles.retain(|p| p.name != profile.name);
|
||||||
|
store.profiles.push(profile);
|
||||||
|
store.save()?;
|
||||||
|
println!("Профиль сохранён в {}", profiles::CredentialStore::config_path()?.display());
|
||||||
|
}
|
||||||
|
ProfileAction::List => {
|
||||||
|
if store.profiles.is_empty() {
|
||||||
|
println!("Профилей пока нет. Добавь через `komiterm profile add`.");
|
||||||
|
}
|
||||||
|
for p in &store.profiles {
|
||||||
|
println!("- {} ({})", p.name, p.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProfileAction::Remove { name } => {
|
||||||
|
let before = store.profiles.len();
|
||||||
|
store.profiles.retain(|p| p.name != name);
|
||||||
|
if store.profiles.len() == before {
|
||||||
|
anyhow::bail!("профиль '{name}' не найден");
|
||||||
|
}
|
||||||
|
store.save()?;
|
||||||
|
println!("Профиль '{name}' удалён.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
116
src/profiles.rs
Normal file
116
src/profiles.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
use crate::session::{AuthMethod, PassphraseSource};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Именованный профиль входа — логин + способ аутентификации,
|
||||||
|
/// который можно переиспользовать в нескольких сессиях (аналог
|
||||||
|
/// отдельных "Credentials" в MobaXterm вместо дублирования в каждой сессии).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialProfile {
|
||||||
|
pub name: String,
|
||||||
|
pub username: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub auth: AuthMethod,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialStore {
|
||||||
|
#[serde(rename = "credential")]
|
||||||
|
pub profiles: Vec<CredentialProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialStore {
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm")
|
||||||
|
.context("не удалось определить домашнюю директорию")?;
|
||||||
|
Ok(dirs.config_dir().join("credentials.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let default = Self::default();
|
||||||
|
default.save()?;
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("не удалось прочитать {}", path.display()))?;
|
||||||
|
Ok(toml::from_str(&raw)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&path, toml::to_string_pretty(self)?)?;
|
||||||
|
restrict_permissions(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, name: &str) -> Option<&CredentialProfile> {
|
||||||
|
self.profiles.iter().find(|p| p.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// На Unix выставляем 600 — в файле лежат пути к ключам и (в теории,
|
||||||
|
/// если кто-то допишет) может однажды оказаться что-то чувствительнее.
|
||||||
|
/// Паролей в открытом виде мы принципиально не храним (см. AuthMethod::Password).
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn restrict_permissions(path: &std::path::Path) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(path)?.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(path, perms)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn restrict_permissions(_path: &std::path::Path) -> Result<()> {
|
||||||
|
// TODO: на Windows стоит ограничить ACL файла через icacls/винAPI —
|
||||||
|
// пока полагаемся на то, что права на %APPDATA% уже приватны для юзера.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Интерактивное добавление профиля через stdin — временная замена
|
||||||
|
/// нормальной формы в ratatui (см. TODO в ui.rs про список сессий).
|
||||||
|
pub fn prompt_new_profile() -> Result<CredentialProfile> {
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
let ask = |label: &str| -> Result<String> {
|
||||||
|
print!("{label}: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut s = String::new();
|
||||||
|
io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(s.trim().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = ask("Имя профиля")?;
|
||||||
|
let username = ask("Логин")?;
|
||||||
|
println!("Способ входа: 1) пароль 2) ключ 3) ssh-agent (временно не работает, см. README)");
|
||||||
|
let choice = ask("Выбор [1-3]")?;
|
||||||
|
|
||||||
|
let auth = match choice.as_str() {
|
||||||
|
"1" => AuthMethod::Password,
|
||||||
|
"2" => {
|
||||||
|
let path_str = ask("Путь к приватному ключу (пусто — определить автоматически)")?;
|
||||||
|
let path = if path_str.is_empty() { None } else { Some(PathBuf::from(path_str)) };
|
||||||
|
let has_pass = ask("Ключ защищён паролем? [y/N]")?;
|
||||||
|
let passphrase = if has_pass.eq_ignore_ascii_case("y") {
|
||||||
|
PassphraseSource::Prompt
|
||||||
|
} else {
|
||||||
|
PassphraseSource::None
|
||||||
|
};
|
||||||
|
AuthMethod::Key { path, passphrase }
|
||||||
|
}
|
||||||
|
"3" => AuthMethod::Agent,
|
||||||
|
_ => anyhow::bail!("неизвестный вариант: {choice}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CredentialProfile { name, username, auth })
|
||||||
|
}
|
||||||
106
src/resolve.rs
Normal file
106
src/resolve.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use crate::profiles::CredentialStore;
|
||||||
|
use crate::session::{AuthMethod, PassphraseSource, Protocol, SessionConfig};
|
||||||
|
use crate::sshconfig::SshConfig;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Итоговые параметры, с которыми реально идём подключаться —
|
||||||
|
/// после слияния приоритетов session > credential-профиль > ssh_config > дефолты.
|
||||||
|
pub struct ResolvedSsh {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
pub auth: AuthMethod,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_ssh(
|
||||||
|
cfg: &SessionConfig,
|
||||||
|
credentials: &CredentialStore,
|
||||||
|
ssh_config: &SshConfig,
|
||||||
|
) -> Result<ResolvedSsh> {
|
||||||
|
let Protocol::Ssh { host, port, user, auth, credential, use_ssh_config } = &cfg.protocol
|
||||||
|
else {
|
||||||
|
return Err(anyhow!("resolve_ssh вызван для сессии, которая не является SSH"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// host в сессии может быть как реальным хостом, так и алиасом из
|
||||||
|
// ~/.ssh/config (Host-паттерн) — lookup смотрит именно по нему,
|
||||||
|
// так же как это делает сам ssh, когда ты пишешь `ssh myalias`.
|
||||||
|
let entry = if *use_ssh_config {
|
||||||
|
ssh_config.lookup(host)
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let effective_host = entry.hostname.clone().unwrap_or_else(|| host.clone());
|
||||||
|
let effective_port = port.or(entry.port).unwrap_or(22);
|
||||||
|
|
||||||
|
let credential_profile = credential
|
||||||
|
.as_ref()
|
||||||
|
.map(|name| {
|
||||||
|
credentials
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| anyhow!("credential-профиль '{name}' не найден в credentials.toml"))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let effective_user = user
|
||||||
|
.clone()
|
||||||
|
.or_else(|| credential_profile.map(|p| p.username.clone()))
|
||||||
|
.or_else(|| entry.user.clone())
|
||||||
|
.or_else(current_os_user)
|
||||||
|
.ok_or_else(|| anyhow!("не удалось определить логин для подключения к {host}"))?;
|
||||||
|
|
||||||
|
// Приоритет способа входа: явный auth в сессии > credential-профиль >
|
||||||
|
// подсказка из ssh_config (IdentityFile) > запрос пароля как последний вариант.
|
||||||
|
let effective_auth = match auth.clone() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => match credential_profile {
|
||||||
|
Some(p) => p.auth.clone(),
|
||||||
|
None => default_auth(&entry.identity_files),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let effective_auth = fill_key_path(effective_auth, &entry.identity_files)?;
|
||||||
|
|
||||||
|
Ok(ResolvedSsh {
|
||||||
|
host: effective_host,
|
||||||
|
port: effective_port,
|
||||||
|
user: effective_user,
|
||||||
|
auth: effective_auth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_os_user() -> Option<String> {
|
||||||
|
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth(identity_files: &[PathBuf]) -> AuthMethod {
|
||||||
|
if let Some(path) = identity_files.first() {
|
||||||
|
AuthMethod::Key { path: Some(path.clone()), passphrase: PassphraseSource::Prompt }
|
||||||
|
} else {
|
||||||
|
AuthMethod::Password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Если выбран Key, но конкретный путь не указан явно ни в сессии,
|
||||||
|
/// ни в credential-профиле — подставляем IdentityFile из ~/.ssh/config,
|
||||||
|
/// а если и его нет — пробуем стандартные ~/.ssh/id_ed25519|id_ecdsa|id_rsa.
|
||||||
|
fn fill_key_path(auth: AuthMethod, identity_files: &[PathBuf]) -> Result<AuthMethod> {
|
||||||
|
if let AuthMethod::Key { path: None, passphrase } = auth {
|
||||||
|
let candidate = identity_files.first().cloned().or_else(default_key_candidate);
|
||||||
|
let path = candidate
|
||||||
|
.ok_or_else(|| anyhow!("не найден приватный ключ — укажи path явно в сессии или профиле"))?;
|
||||||
|
Ok(AuthMethod::Key { path: Some(path), passphrase })
|
||||||
|
} else {
|
||||||
|
Ok(auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_key_candidate() -> Option<PathBuf> {
|
||||||
|
let home = directories::UserDirs::new()?.home_dir().to_path_buf();
|
||||||
|
["id_ed25519", "id_ecdsa", "id_rsa"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| home.join(".ssh").join(name))
|
||||||
|
.find(|p| p.exists())
|
||||||
|
}
|
||||||
179
src/session.rs
Normal file
179
src/session.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Способ входа. Хранится либо прямо в сессии, либо в отдельном
|
||||||
|
/// именованном профиле (credentials.toml), на который сессия ссылается —
|
||||||
|
/// как отдельные "Credentials" в MobaXterm, переиспользуемые между сессиями.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "method", rename_all = "lowercase")]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
/// Спросить пароль в момент подключения. Никогда не сохраняется на диск.
|
||||||
|
Password,
|
||||||
|
/// Публичный ключ. Если `path` не задан — берём первый IdentityFile
|
||||||
|
/// из ~/.ssh/config (если разрешено) или стандартные ~/.ssh/id_*.
|
||||||
|
Key {
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
passphrase: PassphraseSource,
|
||||||
|
},
|
||||||
|
/// Через ssh-agent (SSH_AUTH_SOCK / Pageant на Windows).
|
||||||
|
Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "source", rename_all = "lowercase")]
|
||||||
|
pub enum PassphraseSource {
|
||||||
|
/// Спросить passphrase у ключа при подключении (если он зашифрован).
|
||||||
|
#[default]
|
||||||
|
Prompt,
|
||||||
|
/// Ключ точно без пароля — не спрашивать.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "protocol", rename_all = "lowercase")]
|
||||||
|
pub enum Protocol {
|
||||||
|
Ssh {
|
||||||
|
/// То, что ты бы написал после `ssh` — либо реальный хост, либо
|
||||||
|
/// алиас из ~/.ssh/config (Host-паттерн).
|
||||||
|
host: String,
|
||||||
|
/// Если None — берётся из ~/.ssh/config или дефолт 22.
|
||||||
|
port: Option<u16>,
|
||||||
|
/// Если None — берётся из ~/.ssh/config, credential-профиля
|
||||||
|
/// или текущего системного пользователя, в этом порядке.
|
||||||
|
user: Option<String>,
|
||||||
|
/// Способ входа прямо в сессии. Приоритет выше, чем `credential`.
|
||||||
|
auth: Option<AuthMethod>,
|
||||||
|
/// Имя профиля из credentials.toml — удобно, когда один и тот же
|
||||||
|
/// логин/ключ используется для нескольких сессий.
|
||||||
|
credential: Option<String>,
|
||||||
|
/// Подтягивать ли недостающие host/port/user/IdentityFile
|
||||||
|
/// из ~/.ssh/config (Linux/macOS/Windows — один и тот же путь
|
||||||
|
/// относительно домашней директории).
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
use_ssh_config: bool,
|
||||||
|
},
|
||||||
|
Telnet {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
Serial {
|
||||||
|
/// Путь к устройству: `/dev/ttyUSB0`, `/dev/ttyACM0` (Linux),
|
||||||
|
/// `/dev/cu.usbserial-*` (macOS) или `COM3`, `COM15` (Windows) —
|
||||||
|
/// платформенно-специфично, ничего общего с host/port у SSH/Telnet.
|
||||||
|
port: String,
|
||||||
|
/// Скорость порта в бодах. Типичные значения для консольных
|
||||||
|
/// портов сетевого оборудования — 9600 (старое/Cisco по
|
||||||
|
/// умолчанию) или 115200 (многие современные устройства).
|
||||||
|
baud_rate: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
data_bits: DataBits,
|
||||||
|
#[serde(default)]
|
||||||
|
parity: Parity,
|
||||||
|
#[serde(default)]
|
||||||
|
stop_bits: StopBits,
|
||||||
|
#[serde(default)]
|
||||||
|
flow_control: FlowControl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Число бит данных на символ. 8 — подавляющее большинство современных
|
||||||
|
/// устройств; 7 изредка встречается на старом телекоммуникационном
|
||||||
|
/// оборудовании.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DataBits {
|
||||||
|
Five,
|
||||||
|
Six,
|
||||||
|
Seven,
|
||||||
|
#[default]
|
||||||
|
Eight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Parity {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Odd,
|
||||||
|
Even,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StopBits {
|
||||||
|
#[default]
|
||||||
|
One,
|
||||||
|
Two,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Software = XON/XOFF (в потоке данных), Hardware = RTS/CTS (отдельные
|
||||||
|
/// провода) — нужны редко, но некоторое промышленное/сетевое оборудование
|
||||||
|
/// без них теряет данные на высоких скоростях.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FlowControl {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Software,
|
||||||
|
Hardware,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfig {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub protocol: Protocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
#[serde(rename = "session")]
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm")
|
||||||
|
.context("не удалось определить домашнюю директорию")?;
|
||||||
|
Ok(dirs.config_dir().join("sessions.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let default = Self::default();
|
||||||
|
fs::write(&path, toml::to_string_pretty(&default)?)?;
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("не удалось прочитать {}", path.display()))?;
|
||||||
|
Ok(toml::from_str(&raw)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Публичный API на будущее — например, для интерактивного
|
||||||
|
/// добавления/редактирования сессий вместо ручного редактирования
|
||||||
|
/// sessions.toml. Пока ничего в проекте это не вызывает (сессии
|
||||||
|
/// правятся только руками), поэтому явно глушим dead_code вместо
|
||||||
|
/// того, чтобы удалять рабочий и, вероятно, скоро нужный код.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
fs::write(&path, toml::to_string_pretty(self)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, name: &str) -> Option<&SessionConfig> {
|
||||||
|
self.sessions.iter().find(|s| s.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/sftp.rs
Normal file
125
src/sftp.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use russh_sftp::client::SftpSession;
|
||||||
|
use russh_sftp::protocol::OpenFlags;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// Один файл или директория в листинге SFTP-браузера.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SftpEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обёртка над russh_sftp::client::SftpSession — держит текущую директорию
|
||||||
|
/// и даёт минимальный набор операций для браузера: листинг, cd, скачать,
|
||||||
|
/// закачать. Открывается через Transport::open_sftp() поверх уже
|
||||||
|
/// установленного SSH-соединения (отдельный channel + subsystem "sftp"),
|
||||||
|
/// так что отдельного подключения/пароля для неё не нужно.
|
||||||
|
pub struct SftpBrowser {
|
||||||
|
session: SftpSession,
|
||||||
|
cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SftpBrowser {
|
||||||
|
pub async fn new(session: SftpSession) -> Result<Self> {
|
||||||
|
let cwd = session.canonicalize(".").await?;
|
||||||
|
Ok(Self { session, cwd })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cwd(&self) -> &str {
|
||||||
|
&self.cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Список записей текущей директории: сначала папки, потом файлы,
|
||||||
|
/// внутри каждой группы — по алфавиту (без учёта регистра), как
|
||||||
|
/// большинство файловых менеджеров, включая MobaXterm.
|
||||||
|
pub async fn list(&self) -> Result<Vec<SftpEntry>> {
|
||||||
|
let raw = self.session.read_dir(&self.cwd).await?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in raw {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry.metadata();
|
||||||
|
entries.push(SftpEntry {
|
||||||
|
name,
|
||||||
|
is_dir: metadata.is_dir(),
|
||||||
|
size: metadata.size.unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
|
(false, true) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Зайти в поддиректорию по имени из списка, либо в ".." для выхода
|
||||||
|
/// на уровень выше. canonicalize заодно проверяет, что путь реально
|
||||||
|
/// существует, и резолвит "..", не полагаясь на нашу склейку строк.
|
||||||
|
pub async fn enter(&mut self, name: &str) -> Result<()> {
|
||||||
|
let new_path = if name == ".." {
|
||||||
|
Path::new(&self.cwd)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string())
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", self.cwd.trim_end_matches('/'), name)
|
||||||
|
};
|
||||||
|
self.cwd = self.session.canonicalize(&new_path).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Скачать файл из текущей директории на локальный диск целиком в
|
||||||
|
/// память и обратно на диск — для действительно больших файлов это
|
||||||
|
/// не годится, см. TODO внизу файла про потоковую передачу.
|
||||||
|
pub async fn download(&self, remote_name: &str, local_path: &Path) -> Result<()> {
|
||||||
|
let remote_path = format!("{}/{}", self.cwd.trim_end_matches('/'), remote_name);
|
||||||
|
let mut remote_file = self.session.open_with_flags(&remote_path, OpenFlags::READ).await?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
remote_file.read_to_end(&mut buf).await?;
|
||||||
|
if let Some(parent) = local_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.ok();
|
||||||
|
}
|
||||||
|
tokio::fs::write(local_path, buf).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Закачать локальный файл в текущую директорию под именем `remote_name`.
|
||||||
|
pub async fn upload(&self, local_path: &Path, remote_name: &str) -> Result<()> {
|
||||||
|
let data = tokio::fs::read(local_path).await?;
|
||||||
|
let remote_path = format!("{}/{}", self.cwd.trim_end_matches('/'), remote_name);
|
||||||
|
let mut remote_file = self
|
||||||
|
.session
|
||||||
|
.open_with_flags(
|
||||||
|
&remote_path,
|
||||||
|
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
remote_file.write_all(&data).await?;
|
||||||
|
remote_file.shutdown().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Папка на локальном диске, куда скачиваются файлы из браузера —
|
||||||
|
/// `~/Downloads/komiterm/<имя_сессии>/`. TODO: сделать настраиваемым.
|
||||||
|
pub fn default_download_dir(session_name: &str) -> Option<PathBuf> {
|
||||||
|
let dirs = directories::UserDirs::new()?;
|
||||||
|
let downloads = dirs.download_dir().unwrap_or_else(|| dirs.home_dir());
|
||||||
|
Some(downloads.join("komiterm").join(session_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// - upload из браузера пока не привязан ни к какому UI (нет локального
|
||||||
|
// file-picker'а) — метод есть, кнопки нет.
|
||||||
|
// - download/upload грузят файл целиком в память; для больших файлов
|
||||||
|
// нужно стримить чанками через AsyncRead/AsyncWrite напрямую в/из
|
||||||
|
// tokio::fs::File, а не через Vec<u8>.
|
||||||
|
// - переименование/удаление/mkdir — у SftpSession методы есть
|
||||||
|
// (remove_file/remove_dir/rename/create_dir), в браузере не выведены.
|
||||||
|
// - права доступа/владелец есть в metadata, но никак не показываются.
|
||||||
225
src/sshconfig.rs
Normal file
225
src/sshconfig.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Разобранные директивы для одного хоста после слияния всех подходящих
|
||||||
|
/// Host-блоков — то, что реально пригодится для подключения.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct HostEntry {
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub identity_files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Распарсенный ~/.ssh/config целиком (включая всё, что подтянуто через
|
||||||
|
/// Include) — список (паттерны Host, директивы). Порядок блоков сохраняется,
|
||||||
|
/// потому что в OpenSSH первое встреченное значение параметра побеждает —
|
||||||
|
/// так же делаем и мы в `lookup`.
|
||||||
|
pub struct SshConfig {
|
||||||
|
blocks: Vec<(Vec<String>, HostEntry)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Предел глубины Include — защита от случайного (или специально
|
||||||
|
/// подстроенного) цикла a.conf включает b.conf включает a.conf.
|
||||||
|
const MAX_INCLUDE_DEPTH: usize = 16;
|
||||||
|
|
||||||
|
impl SshConfig {
|
||||||
|
/// Путь к пользовательскому конфигу. На Linux/macOS/Windows OpenSSH
|
||||||
|
/// (включая встроенный в Windows 10+) использует один и тот же
|
||||||
|
/// относительный путь `.ssh/config` от домашней директории —
|
||||||
|
/// поэтому специальной ветки под Windows не нужно.
|
||||||
|
pub fn default_path() -> Result<PathBuf> {
|
||||||
|
let dirs = directories::UserDirs::new()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("не удалось определить домашнюю директорию"))?;
|
||||||
|
Ok(dirs.home_dir().join(".ssh").join("config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default() -> Result<Self> {
|
||||||
|
let path = Self::default_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Self { blocks: Vec::new() });
|
||||||
|
}
|
||||||
|
Self::load(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
parse_file(path, &mut blocks, &mut visited, 0)?;
|
||||||
|
Ok(Self { blocks })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Слить директивы всех Host-блоков, чьи паттерны матчат алиас,
|
||||||
|
/// в одну запись. Первое встреченное значение каждого поля побеждает
|
||||||
|
/// (поведение OpenSSH), а IdentityFile — накапливаются из всех блоков.
|
||||||
|
pub fn lookup(&self, alias: &str) -> HostEntry {
|
||||||
|
let mut result = HostEntry::default();
|
||||||
|
for (patterns, entry) in &self.blocks {
|
||||||
|
if !patterns.iter().any(|p| host_pattern_matches(p, alias)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if result.hostname.is_none() {
|
||||||
|
result.hostname.clone_from(&entry.hostname);
|
||||||
|
}
|
||||||
|
if result.user.is_none() {
|
||||||
|
result.user.clone_from(&entry.user);
|
||||||
|
}
|
||||||
|
if result.port.is_none() {
|
||||||
|
result.port = entry.port;
|
||||||
|
}
|
||||||
|
result.identity_files.extend(entry.identity_files.iter().cloned());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разобрать один файл конфигурации, дописывая блоки в общий `blocks` по
|
||||||
|
/// мере встречи (включая рекурсивно всё, что подтянуто через Include —
|
||||||
|
/// OpenSSH ведёт себя так же: как будто содержимое файла подставлено на
|
||||||
|
/// место директивы Include).
|
||||||
|
fn parse_file(
|
||||||
|
path: &Path,
|
||||||
|
blocks: &mut Vec<(Vec<String>, HostEntry)>,
|
||||||
|
visited: &mut HashSet<PathBuf>,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
if depth > MAX_INCLUDE_DEPTH {
|
||||||
|
return Ok(()); // молча обрываем цикл — лучше неполный конфиг, чем зависание
|
||||||
|
}
|
||||||
|
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
if !visited.insert(canonical) {
|
||||||
|
return Ok(()); // этот файл уже разобран где-то выше по цепочке Include
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(content) = fs::read_to_string(path) else {
|
||||||
|
return Ok(()); // Include на несуществующий файл — OpenSSH тоже это терпит
|
||||||
|
};
|
||||||
|
let base_dir = path.parent().map(Path::to_path_buf);
|
||||||
|
let mut current: Option<(Vec<String>, HostEntry)> = None;
|
||||||
|
|
||||||
|
for raw_line in content.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once(char::is_whitespace) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim().to_ascii_lowercase();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
if key == "include" {
|
||||||
|
for resolved in resolve_include_pattern(value, base_dir.as_deref()) {
|
||||||
|
parse_file(&resolved, blocks, visited, depth + 1)?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "host" {
|
||||||
|
if let Some(block) = current.take() {
|
||||||
|
blocks.push(block);
|
||||||
|
}
|
||||||
|
let patterns = value.split_whitespace().map(String::from).collect();
|
||||||
|
current = Some((patterns, HostEntry::default()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match-блоки пока не поддержаны — см. TODO внизу файла.
|
||||||
|
let Some((_, entry)) = current.as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match key.as_str() {
|
||||||
|
"hostname" => entry.hostname = Some(value.to_string()),
|
||||||
|
"user" => entry.user = Some(value.to_string()),
|
||||||
|
"port" => entry.port = value.parse().ok(),
|
||||||
|
"identityfile" => entry.identity_files.push(expand_tilde(value)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(block) = current.take() {
|
||||||
|
blocks.push(block);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Раскрывает значение директивы Include в список реальных файлов.
|
||||||
|
/// Поддержано: абсолютные пути, "~/"-пути, относительные (считаются
|
||||||
|
/// относительно директории текущего конфига — как в OpenSSH), и один
|
||||||
|
/// '*' как wildcard в имени файла (без вложенных ** и без [classes]).
|
||||||
|
/// Несколько паттернов через пробел на одной строке — тоже как в OpenSSH.
|
||||||
|
fn resolve_include_pattern(value: &str, base_dir: Option<&Path>) -> Vec<PathBuf> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for raw_pattern in value.split_whitespace() {
|
||||||
|
let expanded = expand_tilde(raw_pattern);
|
||||||
|
let pattern_path = if expanded.is_absolute() {
|
||||||
|
expanded
|
||||||
|
} else if let Some(base) = base_dir {
|
||||||
|
base.join(expanded)
|
||||||
|
} else {
|
||||||
|
expanded
|
||||||
|
};
|
||||||
|
|
||||||
|
if !raw_pattern.contains('*') {
|
||||||
|
if pattern_path.is_file() {
|
||||||
|
results.push(pattern_path);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dir) = pattern_path.parent() else { continue };
|
||||||
|
let Some(file_pattern) = pattern_path.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(read_dir) = fs::read_dir(dir) else { continue };
|
||||||
|
|
||||||
|
let mut matched: Vec<PathBuf> = read_dir
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.filter(|p| {
|
||||||
|
p.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|name| host_pattern_matches(file_pattern, name))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// OpenSSH обходит совпадения glob в отсортированном порядке —
|
||||||
|
// без этого порядок Include был бы недетерминированным (порядок
|
||||||
|
// read_dir не гарантирован), а порядок важен из-за "первый выигрывает".
|
||||||
|
matched.sort();
|
||||||
|
results.extend(matched);
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Раскрывает "~/" в начале значения IdentityFile/Include в домашнюю директорию.
|
||||||
|
fn expand_tilde(value: &str) -> PathBuf {
|
||||||
|
let rest = value.strip_prefix("~/").or_else(|| value.strip_prefix("~\\"));
|
||||||
|
if let Some(rest) = rest {
|
||||||
|
if let Some(dirs) = directories::UserDirs::new() {
|
||||||
|
return dirs.home_dir().join(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сопоставление простого паттерна с '*' как wildcard: используется и для
|
||||||
|
/// Host-паттернов, и для имён файлов в Include. Настоящий ssh_config умеет
|
||||||
|
/// больше (?, [abc], отрицания через !) — для MVP этого достаточно для
|
||||||
|
/// подавляющего большинства реальных конфигов.
|
||||||
|
fn host_pattern_matches(pattern: &str, value: &str) -> bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||||
|
return value.starts_with(prefix);
|
||||||
|
}
|
||||||
|
pattern == value
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// - Match-блоки
|
||||||
|
// - полноценные glob-паттерны и отрицания в Host/Include (?, [abc], !)
|
||||||
|
// - системный /etc/ssh/ssh_config (Linux/macOS) и %ProgramData%\ssh\ssh_config
|
||||||
|
// (Windows) как fallback с приоритетом ниже пользовательского конфига
|
||||||
111
src/term.rs
Normal file
111
src/term.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use alacritty_terminal::event::{Event, EventListener};
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::term::{Config, Term};
|
||||||
|
use alacritty_terminal::vte::ansi::Processor;
|
||||||
|
use alacritty_terminal::Grid;
|
||||||
|
|
||||||
|
/// EventListener для alacritty_terminal — сюда прилетают события вроде
|
||||||
|
/// "заголовок изменился", "нужен звонок" и т.п. Для MVP просто складываем
|
||||||
|
/// в очередь, ui.rs потом разбирает.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EventProxy(std::sync::mpsc::Sender<Event>);
|
||||||
|
|
||||||
|
impl EventListener for EventProxy {
|
||||||
|
fn send_event(&self, event: Event) {
|
||||||
|
let _ = self.0.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Term::new`/`Term::resize` принимают не размер в пикселях (это отдельное
|
||||||
|
/// понятие `WindowSize`, для другого — сообщать PTY реальный размер окна
|
||||||
|
/// в пикселях, нам не нужное), а нечто, реализующее трейт `Dimensions` —
|
||||||
|
/// то есть размер именно в колонках/строках символьной сетки. Такого
|
||||||
|
/// готового публичного типа под рукой нет, поэтому просто заводим свой.
|
||||||
|
struct GridSize {
|
||||||
|
cols: usize,
|
||||||
|
rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dimensions for GridSize {
|
||||||
|
fn total_lines(&self) -> usize {
|
||||||
|
self.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_lines(&self) -> usize {
|
||||||
|
self.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn columns(&self) -> usize {
|
||||||
|
self.cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Держит состояние эмулируемого терминала: сетку ячеек с атрибутами,
|
||||||
|
/// курсор, режимы (alt screen, wrap и т.д.) и VTE-парсер поверх неё.
|
||||||
|
pub struct TerminalState {
|
||||||
|
term: Term<EventProxy>,
|
||||||
|
parser: Processor,
|
||||||
|
/// События от alacritty_terminal (смена заголовка через OSC, звонок,
|
||||||
|
/// clipboard-запросы и т.п.) — очередь собирается, но пока никто её не
|
||||||
|
/// читает: например, обновление имени таба по Event::Title ещё не
|
||||||
|
/// подключено к ui.rs/app.rs. Оставлено как задел на будущее, а не
|
||||||
|
/// удалено, поэтому и глушим dead_code явно, а не молчим о причине.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
events: std::sync::mpsc::Receiver<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalState {
|
||||||
|
pub fn new(cols: u16, rows: u16) -> Self {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let proxy = EventProxy(tx);
|
||||||
|
let size = GridSize { cols: cols as usize, rows: rows as usize };
|
||||||
|
let term = Term::new(Config::default(), &size, proxy);
|
||||||
|
Self { term, parser: Processor::new(), events: rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Скормить парсеру очередную порцию байт, пришедших от транспорта.
|
||||||
|
/// Именно тут живёт вся сложность интерпретации escape-последовательностей —
|
||||||
|
/// alacritty_terminal берёт её на себя целиком.
|
||||||
|
pub fn advance(&mut self, bytes: &[u8]) {
|
||||||
|
for byte in bytes {
|
||||||
|
self.parser.advance(&mut self.term, *byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||||
|
let size = GridSize { cols: cols as usize, rows: rows as usize };
|
||||||
|
self.term.resize(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Текущее содержимое сетки для рендера в ratatui.
|
||||||
|
pub fn grid(&self) -> &Grid<alacritty_terminal::term::cell::Cell> {
|
||||||
|
self.term.grid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Позиция курсора в системе координат видимого вьюпорта: (line, column).
|
||||||
|
/// line может быть отрицательной в теории (если вьюпорт проскроллен в
|
||||||
|
/// историю), но у активного текстового курсора такого не бывает —
|
||||||
|
/// проверка `row_idx as i32 == cursor.0` в ui.rs всё равно безопасна.
|
||||||
|
pub fn cursor_position(&self) -> (i32, usize) {
|
||||||
|
let point = self.term.grid().cursor.point;
|
||||||
|
(point.line.0, point.column.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ширина/высота видимой области терминала — то, что реально нужно
|
||||||
|
/// пройти циклом в рендере, а не полагаться на то, что area в ratatui
|
||||||
|
/// всегда совпадает по размеру (на случай рассинхрона после resize).
|
||||||
|
pub fn columns(&self) -> usize {
|
||||||
|
self.term.columns()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_lines(&self) -> usize {
|
||||||
|
self.term.screen_lines()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// См. комментарий у поля `events` — задел на будущее (обновление
|
||||||
|
/// заголовка таба, звонок и т.п.), пока никем не вызывается.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn drain_events(&mut self) -> Vec<Event> {
|
||||||
|
self.events.try_iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/transport/mod.rs
Normal file
66
src/transport/mod.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
mod serial;
|
||||||
|
mod ssh;
|
||||||
|
mod telnet;
|
||||||
|
|
||||||
|
use crate::profiles::CredentialStore;
|
||||||
|
use crate::resolve;
|
||||||
|
use crate::session::{Protocol, SessionConfig};
|
||||||
|
use crate::sshconfig::SshConfig;
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Абстракция над байтовым каналом до удалённого хоста.
|
||||||
|
/// SSH и Telnet реализуют её одинаково, поэтому terminal-слой
|
||||||
|
/// выше не знает и не должен знать, что именно используется.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Transport: Send {
|
||||||
|
/// Отправить байты, введённые пользователем (клавиатура), на удалённую сторону.
|
||||||
|
async fn write(&mut self, data: &[u8]) -> Result<()>;
|
||||||
|
|
||||||
|
/// Изменить размер PTY при ресайзе терминального окна.
|
||||||
|
async fn resize(&mut self, cols: u16, rows: u16) -> Result<()>;
|
||||||
|
|
||||||
|
/// Закрыть соединение.
|
||||||
|
async fn close(&mut self) -> Result<()>;
|
||||||
|
|
||||||
|
/// Открыть SFTP-подсистему поверх этого же соединения, если протокол
|
||||||
|
/// это поддерживает. Дефолт — `Ok(None)`, чтобы Telnet не обязан был
|
||||||
|
/// это реализовывать (у него просто нет такого понятия). SSH
|
||||||
|
/// переопределяет это отдельным channel + subsystem "sftp".
|
||||||
|
async fn open_sftp(&mut self) -> Result<Option<crate::sftp::SftpBrowser>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выполнить одноразовую команду на удалённой стороне (для строки
|
||||||
|
/// состояния — CPU/память/load average, см. hoststats.rs) и вернуть её
|
||||||
|
/// stdout целиком. Дефолт — `Ok(None)`: у Telnet нет понятия "команда
|
||||||
|
/// выполнилась и вернула вывод", есть только один непрерывный поток
|
||||||
|
/// байт в интерактивную сессию.
|
||||||
|
async fn run_command(&mut self, _cmd: &str) -> Result<Option<String>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Канал, по которому входящие байты от удалённого хоста
|
||||||
|
/// прилетают в terminal-парсер (alacritty_terminal).
|
||||||
|
pub type IncomingRx = mpsc::UnboundedReceiver<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Установить соединение согласно конфигурации сессии. Для SSH сюда же
|
||||||
|
/// подмешиваются credential-профили и ~/.ssh/config — см. resolve.rs.
|
||||||
|
pub async fn connect(
|
||||||
|
cfg: &SessionConfig,
|
||||||
|
credentials: &CredentialStore,
|
||||||
|
ssh_config: &SshConfig,
|
||||||
|
) -> Result<(Box<dyn Transport>, IncomingRx)> {
|
||||||
|
match &cfg.protocol {
|
||||||
|
Protocol::Ssh { .. } => {
|
||||||
|
let resolved = resolve::resolve_ssh(cfg, credentials, ssh_config)?;
|
||||||
|
ssh::connect(resolved).await
|
||||||
|
}
|
||||||
|
Protocol::Telnet { host, port } => telnet::connect(host, *port).await,
|
||||||
|
Protocol::Serial { port, baud_rate, data_bits, parity, stop_bits, flow_control } => {
|
||||||
|
serial::connect(port, *baud_rate, *data_bits, *parity, *stop_bits, *flow_control).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/transport/serial.rs
Normal file
107
src/transport/serial.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
use super::{IncomingRx, Transport};
|
||||||
|
use crate::session::{DataBits, FlowControl, Parity, StopBits};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::io::{split, AsyncReadExt, AsyncWriteExt, WriteHalf};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_serial::{SerialPortBuilderExt, SerialStream};
|
||||||
|
|
||||||
|
pub struct SerialTransport {
|
||||||
|
writer: WriteHalf<SerialStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for SerialTransport {
|
||||||
|
async fn write(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
self.writer.write_all(data).await?;
|
||||||
|
self.writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// У физического порта нет понятия "размер терминала" — устройство на
|
||||||
|
/// другом конце (свитч, роутер, встраиваемая плата) просто пишет и
|
||||||
|
/// читает байты, ожидая свой обычный vt100/ANSI без уведомлений о
|
||||||
|
/// resize. Ресайз окна KomiTerm тут просто ни на что не влияет.
|
||||||
|
async fn resize(&mut self, _cols: u16, _rows: u16) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<()> {
|
||||||
|
self.writer.shutdown().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// open_sftp/run_command — дефолт из трейта (Ok(None)): у serial-порта
|
||||||
|
// нет ни SFTP-подсистемы, ни удалённой ОС, которой можно что-то
|
||||||
|
// "исполнить" — это просто провод.
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(
|
||||||
|
port: &str,
|
||||||
|
baud_rate: u32,
|
||||||
|
data_bits: DataBits,
|
||||||
|
parity: Parity,
|
||||||
|
stop_bits: StopBits,
|
||||||
|
flow_control: FlowControl,
|
||||||
|
) -> Result<(Box<dyn Transport>, IncomingRx)> {
|
||||||
|
let stream = tokio_serial::new(port, baud_rate)
|
||||||
|
.data_bits(map_data_bits(data_bits))
|
||||||
|
.parity(map_parity(parity))
|
||||||
|
.stop_bits(map_stop_bits(stop_bits))
|
||||||
|
.flow_control(map_flow_control(flow_control))
|
||||||
|
.open_native_async()?;
|
||||||
|
|
||||||
|
let (mut reader, writer) = split(stream);
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
// Обрыв связи (отключили USB-адаптер на лету и т.п.) на разных
|
||||||
|
// платформах может прийти и как Ok(0), и как Err — оба
|
||||||
|
// трактуем одинаково: завершаем задачу, канал закроется,
|
||||||
|
// main_loop увидит закрытый incoming естественным образом.
|
||||||
|
match reader.read(&mut buf).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if tx.send(buf[..n].to_vec()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((Box::new(SerialTransport { writer }), rx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_data_bits(v: DataBits) -> tokio_serial::DataBits {
|
||||||
|
match v {
|
||||||
|
DataBits::Five => tokio_serial::DataBits::Five,
|
||||||
|
DataBits::Six => tokio_serial::DataBits::Six,
|
||||||
|
DataBits::Seven => tokio_serial::DataBits::Seven,
|
||||||
|
DataBits::Eight => tokio_serial::DataBits::Eight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_parity(v: Parity) -> tokio_serial::Parity {
|
||||||
|
match v {
|
||||||
|
Parity::None => tokio_serial::Parity::None,
|
||||||
|
Parity::Odd => tokio_serial::Parity::Odd,
|
||||||
|
Parity::Even => tokio_serial::Parity::Even,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_stop_bits(v: StopBits) -> tokio_serial::StopBits {
|
||||||
|
match v {
|
||||||
|
StopBits::One => tokio_serial::StopBits::One,
|
||||||
|
StopBits::Two => tokio_serial::StopBits::Two,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_flow_control(v: FlowControl) -> tokio_serial::FlowControl {
|
||||||
|
match v {
|
||||||
|
FlowControl::None => tokio_serial::FlowControl::None,
|
||||||
|
FlowControl::Software => tokio_serial::FlowControl::Software,
|
||||||
|
FlowControl::Hardware => tokio_serial::FlowControl::Hardware,
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/transport/ssh.rs
Normal file
288
src/transport/ssh.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
use super::{IncomingRx, Transport};
|
||||||
|
use crate::resolve::ResolvedSsh;
|
||||||
|
use crate::session::{AuthMethod, PassphraseSource};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use russh::client::{self, Handle};
|
||||||
|
use russh::keys::{check_known_hosts, load_secret_key, PrivateKeyWithHashAlg, PublicKey};
|
||||||
|
use russh::Disconnect;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Обработчик событий SSH-сессии. Единственное, что нам тут реально нужно
|
||||||
|
/// переопределить — check_server_key, куда попадает вся TOFU/known_hosts-логика.
|
||||||
|
/// host/port храним прямо в обработчике: колбэк отдаёт только публичный ключ,
|
||||||
|
/// а нам нужно знать, под каким именем его сверять в known_hosts.
|
||||||
|
struct ClientHandler {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl client::Handler for ClientHandler {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
async fn check_server_key(&mut self, server_public_key: &PublicKey) -> Result<bool> {
|
||||||
|
match check_known_hosts(&self.host, self.port, server_public_key) {
|
||||||
|
// Ключ уже записан и совпадает — обычный путь, без лишних вопросов.
|
||||||
|
Ok(true) => Ok(true),
|
||||||
|
|
||||||
|
// Записи для этого хоста нет вообще — первое подключение.
|
||||||
|
// Это единственный случай, где уместно спросить пользователя
|
||||||
|
// (Trust On First Use), как делает обычный ssh при "the
|
||||||
|
// authenticity of host ... can't be established".
|
||||||
|
Ok(false) => {
|
||||||
|
let host = self.host.clone();
|
||||||
|
let port = self.port;
|
||||||
|
let algorithm = server_public_key.algorithm().to_string();
|
||||||
|
let fingerprint = server_public_key.fingerprint(Default::default()).to_string();
|
||||||
|
// to_openssh() даёт готовую строку "<алгоритм> <base64>" —
|
||||||
|
// ровно то, что нужно дописать в known_hosts после hostname.
|
||||||
|
let openssh_line = server_public_key
|
||||||
|
.to_openssh()
|
||||||
|
.map_err(|e| anyhow!("не удалось закодировать ключ хоста: {e}"))?;
|
||||||
|
|
||||||
|
// Блокирующий stdin-ввод уводим в spawn_blocking, чтобы не
|
||||||
|
// морозить весь tokio-рантайм на время ответа пользователя —
|
||||||
|
// check_server_key вызывается ещё до входа в основной цикл
|
||||||
|
// (см. app.rs), так что единственная жертва — это именно
|
||||||
|
// соединение, которое и так ждёт подтверждения.
|
||||||
|
let confirmed = tokio::task::spawn_blocking(move || {
|
||||||
|
use std::io::Write;
|
||||||
|
eprintln!("Подлинность хоста {host}:{port} не может быть установлена.");
|
||||||
|
eprintln!("Отпечаток ключа ({algorithm}): {fingerprint}");
|
||||||
|
eprint!("Всё равно продолжить подключение? [y/N] ");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input).ok();
|
||||||
|
input.trim().eq_ignore_ascii_case("y")
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if confirmed {
|
||||||
|
learn_known_hosts(&self.host, self.port, &openssh_line)?;
|
||||||
|
}
|
||||||
|
Ok(confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В known_hosts ЕСТЬ запись для этого хоста, но с другим ключом
|
||||||
|
// того же алгоритма — классический признак MITM или того, что
|
||||||
|
// сервер переустановили и не почистили клиентам known_hosts.
|
||||||
|
// Здесь принципиально НЕТ интерактивного "всё равно продолжить":
|
||||||
|
// если это легитимная смена ключа сервера, пусть человек руками
|
||||||
|
// почистит строку в ~/.ssh/known_hosts, зная, что делает.
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("!!! ВНИМАНИЕ: ключ хоста {}:{} изменился !!!", self.host, self.port);
|
||||||
|
eprintln!("Это может значить как переустановку сервера, так и атаку");
|
||||||
|
eprintln!("man-in-the-middle. Подключение отклонено: {e}");
|
||||||
|
eprintln!(
|
||||||
|
"Если уверен, что это легитимная смена ключа — удали \
|
||||||
|
соответствующую строку в ~/.ssh/known_hosts вручную."
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Дописать хост + уже готовую OpenSSH-строку ключа в ~/.ssh/known_hosts.
|
||||||
|
/// Не полагаемся на russh::keys::learn_known_hosts — в используемой версии
|
||||||
|
/// russh (0.62.2) это имя не реэкспортируется на верхнем уровне keys
|
||||||
|
/// (доступен только check_known_hosts), поэтому пишем сами: формат
|
||||||
|
/// known_hosts стабилен десятилетиями и тривиален.
|
||||||
|
fn learn_known_hosts(host: &str, port: u16, openssh_key_line: &str) -> Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let dirs = directories::UserDirs::new()
|
||||||
|
.ok_or_else(|| anyhow!("не удалось определить домашнюю директорию"))?;
|
||||||
|
let path = dirs.home_dir().join(".ssh").join("known_hosts");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_field = if port == 22 {
|
||||||
|
host.to_string()
|
||||||
|
} else {
|
||||||
|
format!("[{host}]:{port}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
writeln!(file, "{host_field} {openssh_key_line}")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SshTransport {
|
||||||
|
handle: Handle<ClientHandler>,
|
||||||
|
cmd_tx: mpsc::UnboundedSender<ChannelCmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Команды основному интерактивному каналу (shell/PTY). Канал целиком
|
||||||
|
/// (и на чтение, и на запись) живёт в одной задаче — см. connect() — чтобы
|
||||||
|
/// не зависеть от точного API разделения Channel на read/write половины,
|
||||||
|
/// который в этой версии russh не был проверен вживую.
|
||||||
|
enum ChannelCmd {
|
||||||
|
Data(Vec<u8>),
|
||||||
|
Resize(u32, u32),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for SshTransport {
|
||||||
|
async fn write(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
self.cmd_tx
|
||||||
|
.send(ChannelCmd::Data(data.to_vec()))
|
||||||
|
.map_err(|_| anyhow!("канал уже закрыт"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
|
||||||
|
self.cmd_tx
|
||||||
|
.send(ChannelCmd::Resize(cols as u32, rows as u32))
|
||||||
|
.map_err(|_| anyhow!("канал уже закрыт"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<()> {
|
||||||
|
self.cmd_tx.send(ChannelCmd::Close).ok(); // не критично, если канал уже сам закрылся
|
||||||
|
self.handle
|
||||||
|
.disconnect(Disconnect::ByApplication, "", "en")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_sftp(&mut self) -> Result<Option<crate::sftp::SftpBrowser>> {
|
||||||
|
// Отдельный канал той же SSH-сессии, поднятый как SFTP-subsystem —
|
||||||
|
// не требует нового подключения/пароля, использует уже
|
||||||
|
// аутентифицированный handle.
|
||||||
|
let channel = self.handle.channel_open_session().await?;
|
||||||
|
channel.request_subsystem(true, "sftp").await?;
|
||||||
|
let session = russh_sftp::client::SftpSession::new(channel.into_stream()).await?;
|
||||||
|
Ok(Some(crate::sftp::SftpBrowser::new(session).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
||||||
|
// Ещё один отдельный канал — exec, а не shell/PTY, так что не
|
||||||
|
// пересекается ни с основным интерактивным сеансом, ни с SFTP.
|
||||||
|
let mut channel = self.handle.channel_open_session().await?;
|
||||||
|
channel.exec(true, cmd.as_bytes()).await?;
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
while let Some(msg) = channel.wait().await {
|
||||||
|
match msg {
|
||||||
|
russh::ChannelMsg::Data { data } => output.extend_from_slice(&data),
|
||||||
|
russh::ChannelMsg::Eof | russh::ChannelMsg::Close => break,
|
||||||
|
// ExitStatus может прийти раньше, чем все данные —
|
||||||
|
// не прерываемся сразу, ждём Eof/Close (см. пример в
|
||||||
|
// russh/examples/client_exec_simple.rs).
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(String::from_utf8_lossy(&output).into_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(target: ResolvedSsh) -> Result<(Box<dyn Transport>, IncomingRx)> {
|
||||||
|
let ResolvedSsh { host, port, user, auth } = target;
|
||||||
|
|
||||||
|
let config = Arc::new(client::Config::default());
|
||||||
|
let ssh_handler = ClientHandler { host: host.clone(), port };
|
||||||
|
let mut handle = client::connect(config, (host.as_str(), port), ssh_handler).await?;
|
||||||
|
|
||||||
|
let authenticated = match auth {
|
||||||
|
AuthMethod::Key { path, passphrase } => {
|
||||||
|
let path = path.ok_or_else(|| anyhow!("путь к ключу не определён"))?;
|
||||||
|
let pass = match passphrase {
|
||||||
|
PassphraseSource::Prompt => {
|
||||||
|
let prompt = format!("Пароль для ключа {}: ", path.display());
|
||||||
|
Some(rpassword::prompt_password(prompt)?)
|
||||||
|
}
|
||||||
|
PassphraseSource::None => None,
|
||||||
|
};
|
||||||
|
let key_pair = load_secret_key(&path, pass.as_deref())?;
|
||||||
|
// Спрашиваем у сервера (через server-sig-algs extension), какой
|
||||||
|
// хеш для RSA он реально поддерживает, вместо того чтобы всегда
|
||||||
|
// бить в ssh-rsa/SHA-1. Для ed25519/ecdsa результат игнорируется
|
||||||
|
// самим PrivateKeyWithHashAlg, так что вызов безвреден для них.
|
||||||
|
let rsa_hash = handle.best_supported_rsa_hash().await?.flatten();
|
||||||
|
let key_with_alg = PrivateKeyWithHashAlg::new(Arc::new(key_pair), rsa_hash);
|
||||||
|
handle.authenticate_publickey(&user, key_with_alg).await?.success()
|
||||||
|
}
|
||||||
|
AuthMethod::Password => {
|
||||||
|
let prompt = format!("Пароль для {user}@{host}: ");
|
||||||
|
let pass = rpassword::prompt_password(prompt)?;
|
||||||
|
handle.authenticate_password(&user, &pass).await?.success()
|
||||||
|
}
|
||||||
|
AuthMethod::Agent => {
|
||||||
|
// TODO: аутентификация через ssh-agent временно отключена.
|
||||||
|
// В используемой версии russh (0.62.2) метод, которым мы
|
||||||
|
// изначально это реализовали (authenticate_future на Handle,
|
||||||
|
// взятый из старого примера в репозитории), не существует —
|
||||||
|
// похоже, эта часть API изменилась, а актуальную замену не
|
||||||
|
// удалось подтвердить по документации/исходникам без живой
|
||||||
|
// компиляции. Чтобы не гадать в третий раз и не ломать сборку
|
||||||
|
// повторно, честно отключаю: используй Key или Password.
|
||||||
|
return Err(anyhow!(
|
||||||
|
"аутентификация через ssh-agent временно недоступна в этой сборке \
|
||||||
|
(API russh для этого изменился, замена ещё не подтверждена) — \
|
||||||
|
используй Key или Password"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
return Err(anyhow!("SSH-аутентификация не удалась для {user}@{host}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut channel = handle.channel_open_session().await?;
|
||||||
|
channel
|
||||||
|
.request_pty(false, "xterm-256color", 80, 24, 0, 0, &[])
|
||||||
|
.await?;
|
||||||
|
channel.request_shell(false).await?;
|
||||||
|
|
||||||
|
let (incoming_tx, incoming_rx) = mpsc::unbounded_channel();
|
||||||
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<ChannelCmd>();
|
||||||
|
|
||||||
|
// Один и тот же Channel обслуживает и чтение, и запись — вместо того,
|
||||||
|
// чтобы разделять его на read/write половины (Channel::split() в этой
|
||||||
|
// версии есть, но точные имена и generic-параметры типов половин не
|
||||||
|
// удалось перепроверить вживую), просто мультиплексируем через
|
||||||
|
// tokio::select! входящие сообщения от сервера и команды от Transport
|
||||||
|
// в одной задаче, которая единолично владеет channel.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = channel.wait() => {
|
||||||
|
match msg {
|
||||||
|
Some(russh::ChannelMsg::Data { data }) => {
|
||||||
|
if incoming_tx.send(data.to_vec()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
match cmd {
|
||||||
|
Some(ChannelCmd::Data(data)) => {
|
||||||
|
// Channel::data (в отличие от Handle::data) принимает
|
||||||
|
// impl AsyncRead, а не что-то конвертируемое через
|
||||||
|
// Into — &[u8] реализует AsyncRead и подходит напрямую.
|
||||||
|
if channel.data(&data[..]).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ChannelCmd::Resize(cols, rows)) => {
|
||||||
|
channel.window_change(cols, rows, 0, 0).await.ok();
|
||||||
|
}
|
||||||
|
Some(ChannelCmd::Close) | None => {
|
||||||
|
channel.close().await.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((Box::new(SshTransport { handle, cmd_tx }), incoming_rx))
|
||||||
|
}
|
||||||
193
src/transport/telnet.rs
Normal file
193
src/transport/telnet.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
use super::{IncomingRx, Transport};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::tcp::OwnedWriteHalf;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// Основные telnet-команды и опции (RFC 854 / RFC 855).
|
||||||
|
const IAC: u8 = 255;
|
||||||
|
const DONT: u8 = 254;
|
||||||
|
const DO: u8 = 253;
|
||||||
|
const WONT: u8 = 252;
|
||||||
|
const WILL: u8 = 251;
|
||||||
|
const SB: u8 = 250;
|
||||||
|
const SE: u8 = 240;
|
||||||
|
|
||||||
|
const OPT_ECHO: u8 = 1;
|
||||||
|
const OPT_SUPPRESS_GO_AHEAD: u8 = 3;
|
||||||
|
|
||||||
|
pub struct TelnetTransport {
|
||||||
|
writer: Arc<Mutex<OwnedWriteHalf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for TelnetTransport {
|
||||||
|
async fn write(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
let mut writer = self.writer.lock().await;
|
||||||
|
// Экранируем 0xFF как IAC IAC, если пользователь его вводит буквально
|
||||||
|
// (иначе одиночный байт 0xFF в пользовательском вводе телнет-сервер
|
||||||
|
// прочтёт как начало команды, а не как данные).
|
||||||
|
if data.contains(&IAC) {
|
||||||
|
let mut escaped = Vec::with_capacity(data.len());
|
||||||
|
for &b in data {
|
||||||
|
escaped.push(b);
|
||||||
|
if b == IAC {
|
||||||
|
escaped.push(IAC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.write_all(&escaped).await?;
|
||||||
|
} else {
|
||||||
|
writer.write_all(data).await?;
|
||||||
|
}
|
||||||
|
writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resize(&mut self, _cols: u16, _rows: u16) -> Result<()> {
|
||||||
|
// Telnet NAWS (option 31) можно добавить отдельно; для MVP no-op.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<()> {
|
||||||
|
self.writer.lock().await.shutdown().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(host: &str, port: u16) -> Result<(Box<dyn Transport>, IncomingRx)> {
|
||||||
|
let stream = TcpStream::connect((host, port)).await?;
|
||||||
|
let (mut reader, writer) = stream.into_split();
|
||||||
|
let writer = Arc::new(Mutex::new(writer));
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
// Читающая задача теперь и сама умеет писать (через тот же Arc<Mutex<>>,
|
||||||
|
// что и обычная запись пользовательского ввода) — нужно, чтобы отвечать
|
||||||
|
// на DO/WILL по мере их поступления, а не откладывать до следующего
|
||||||
|
// вызова write() снаружи.
|
||||||
|
let negotiation_writer = writer.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
pending.extend_from_slice(&buf[..n]);
|
||||||
|
let (clean, replies, consumed) = process_incoming(&pending);
|
||||||
|
pending.drain(..consumed);
|
||||||
|
|
||||||
|
if !replies.is_empty() {
|
||||||
|
let mut w = negotiation_writer.lock().await;
|
||||||
|
if w.write_all(&replies).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if w.flush().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !clean.is_empty() && tx.send(clean).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((Box::new(TelnetTransport { writer }), rx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает входящий буфер: возвращает (данные для терминала, ответные
|
||||||
|
/// IAC-команды для сервера, сколько байт из `input` реально обработано).
|
||||||
|
/// Последнее нужно, потому что IAC SB...SE subnegotiation может не влезть
|
||||||
|
/// целиком в один TCP-пакет — в этом случае оставляем хвост в `pending`
|
||||||
|
/// и дообработаем его вместе со следующей порцией данных.
|
||||||
|
fn process_incoming(input: &[u8]) -> (Vec<u8>, Vec<u8>, usize) {
|
||||||
|
let mut clean = Vec::with_capacity(input.len());
|
||||||
|
let mut replies = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < input.len() {
|
||||||
|
if input[i] != IAC {
|
||||||
|
clean.push(input[i]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match input.get(i + 1) {
|
||||||
|
Some(&DO) | Some(&WILL) => {
|
||||||
|
let Some(&opt) = input.get(i + 2) else {
|
||||||
|
break; // команда обрезана пакетом — ждём остаток
|
||||||
|
};
|
||||||
|
let is_do = input[i + 1] == DO;
|
||||||
|
replies.extend_from_slice(&negotiation_reply(is_do, opt));
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
Some(&DONT) | Some(&WONT) => {
|
||||||
|
// Сервер сообщает, что не будет / просит нас не использовать
|
||||||
|
// опцию — подтверждать нечем, это не вопрос, а уведомление.
|
||||||
|
if input.get(i + 2).is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
Some(&SB) => {
|
||||||
|
// Subnegotiation целиком пропускаем — своих опций с
|
||||||
|
// содержимым (TERMINAL-TYPE, NAWS и т.п.) пока не отвечаем.
|
||||||
|
let Some(end) = find_subnegotiation_end(input, i + 2) else {
|
||||||
|
break; // IAC SE ещё не пришёл — ждём остаток пакета
|
||||||
|
};
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
Some(&IAC) => {
|
||||||
|
clean.push(IAC); // экранированный 0xFF в данных
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Some(_) => i += 2, // однобайтовая команда без опции (NOP, AYT, ...)
|
||||||
|
None => break, // одинокий IAC на конце пакета — ждём остаток
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(clean, replies, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Что ответить на DO/WILL от сервера. Для ECHO и SUPPRESS-GO-AHEAD
|
||||||
|
/// соглашаемся (DO/WILL) — это стандартная пара для char-mode интерактивной
|
||||||
|
/// сессии и то, что реально ожидает большинство telnet-серверов. На всё
|
||||||
|
/// остальное отвечаем отказом (WONT/DONT), а не молчим — молчание как раз
|
||||||
|
/// и было причиной, по которой некоторые серверы подвисали в ожидании ответа.
|
||||||
|
fn negotiation_reply(is_do: bool, opt: u8) -> [u8; 3] {
|
||||||
|
let accept = matches!(opt, OPT_ECHO | OPT_SUPPRESS_GO_AHEAD);
|
||||||
|
let reply_cmd = match (is_do, accept) {
|
||||||
|
(true, true) => WILL, // сервер спросил DO <opt> у нас — отвечаем WILL
|
||||||
|
(true, false) => WONT,
|
||||||
|
(false, true) => DO, // сервер предложил WILL <opt> — отвечаем DO
|
||||||
|
(false, false) => DONT,
|
||||||
|
};
|
||||||
|
[IAC, reply_cmd, opt]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ищет конец subnegotiation (IAC SE), начиная поиск с позиции `from`.
|
||||||
|
/// Возвращает индекс сразу после SE, если нашли, иначе None (пакет обрезан).
|
||||||
|
fn find_subnegotiation_end(input: &[u8], from: usize) -> Option<usize> {
|
||||||
|
let mut i = from;
|
||||||
|
while i + 1 < input.len() {
|
||||||
|
if input[i] == IAC && input[i + 1] == SE {
|
||||||
|
return Some(i + 2);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// - NAWS (option 31): сейчас resize() ничего не шлёт на сервер. Стоило бы
|
||||||
|
// отвечать WILL NAWS и слать текущий размер терминала subnegotiation'ом
|
||||||
|
// при подключении и при каждом resize — многие full-screen TUI на той
|
||||||
|
// стороне (vim, htop и т.п. через telnet) без этого не узнают о ресайзе.
|
||||||
|
// - TERMINAL-TYPE (option 24): сейчас отвечаем WONT на DO, то есть не
|
||||||
|
// сообщаем тип терминала вообще — часть серверов деградирует до vt100
|
||||||
|
// по умолчанию, что обычно приемлемо, но не всегда.
|
||||||
739
src/ui.rs
Normal file
739
src/ui.rs
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
use crate::app::{SidebarMode, Tab};
|
||||||
|
use crate::localfs::{LocalBrowser, LocalEntry};
|
||||||
|
use crate::session::{AuthMethod, PassphraseSource, Protocol, SessionConfig, SessionStore};
|
||||||
|
use crate::term::TerminalState;
|
||||||
|
use alacritty_terminal::index::{Column, Line};
|
||||||
|
use alacritty_terminal::term::cell::Flags;
|
||||||
|
use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor};
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event as CEvent, KeyCode};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line as RLine, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||||
|
use ratatui::{Frame, Terminal};
|
||||||
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Список сохранённых сессий с навигацией стрелками — рисуется прямо
|
||||||
|
/// поверх текущего терминала (без выхода из raw-режима). Пустой список —
|
||||||
|
/// не ошибка: показываем подсказку и предлагаем создать сессию через `n`.
|
||||||
|
/// Возвращает `None`, если пользователь отменил выбор через Esc/q.
|
||||||
|
pub fn pick_session_tui(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
sessions: &mut SessionStore,
|
||||||
|
) -> Result<Option<SessionConfig>> {
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if !sessions.sessions.is_empty() {
|
||||||
|
state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
render_session_picker(frame, frame.area(), sessions, &mut state);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let CEvent::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
let i = state.selected().unwrap_or(0);
|
||||||
|
state.select(Some(i.saturating_sub(1)));
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if !sessions.sessions.is_empty() {
|
||||||
|
let i = state.selected().unwrap_or(0);
|
||||||
|
state.select(Some((i + 1).min(sessions.sessions.len() - 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if sessions.sessions.is_empty() {
|
||||||
|
continue; // нечего открывать — n, чтобы создать
|
||||||
|
}
|
||||||
|
let i = state.selected().unwrap_or(0);
|
||||||
|
return Ok(sessions.sessions.get(i).cloned());
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
if let Some(new_cfg) = create_session_wizard(terminal)? {
|
||||||
|
sessions.sessions.push(new_cfg.clone());
|
||||||
|
// Не критично, если сохранить не получилось (например,
|
||||||
|
// нет прав на директорию) — сессия всё равно доступна
|
||||||
|
// в рамках текущего запуска, просто не переживёт выход.
|
||||||
|
sessions.save().ok();
|
||||||
|
return Ok(Some(new_cfg));
|
||||||
|
}
|
||||||
|
// Esc внутри мастера — просто возвращаемся к списку.
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => return Ok(None),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_session_picker(frame: &mut Frame, area: Rect, sessions: &SessionStore, state: &mut ListState) {
|
||||||
|
if sessions.sessions.is_empty() {
|
||||||
|
let hint = Paragraph::new(
|
||||||
|
"Сохранённых сессий пока нет.\n\n- n — создать новую сессию\n- Esc — выйти",
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" KomiTerm "));
|
||||||
|
frame.render_widget(hint, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = sessions
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| ListItem::new(s.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Выбери сессию (\u{2191}/\u{2193} или j/k, Enter — открыть, n — новая, Esc — отмена) "),
|
||||||
|
)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ширина боковой панели в колонках, когда она развёрнута. Фиксированная,
|
||||||
|
/// а не в процентах — иначе на очень широких терминалах панель раздувалась
|
||||||
|
/// бы неоправданно, а на узких съедала бы слишком много места под контент.
|
||||||
|
pub const SIDEBAR_WIDTH: u16 = 30;
|
||||||
|
|
||||||
|
/// Отрисовать весь экран приложения: боковая панель (если развёрнута) +
|
||||||
|
/// полоска табов сверху + содержимое активной вкладки под ней.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn render_app(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
tabs: &[Tab],
|
||||||
|
active: usize,
|
||||||
|
sessions: &SessionStore,
|
||||||
|
sidebar_visible: bool,
|
||||||
|
sidebar_mode: SidebarMode,
|
||||||
|
sidebar_focused: bool,
|
||||||
|
sessions_selected: usize,
|
||||||
|
sftp_selected: usize,
|
||||||
|
local_browser: Option<&LocalBrowser>,
|
||||||
|
local_entries: &[LocalEntry],
|
||||||
|
local_selected: usize,
|
||||||
|
) {
|
||||||
|
let main_area = if sidebar_visible {
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
match sidebar_mode {
|
||||||
|
SidebarMode::Sessions => render_sidebar(
|
||||||
|
frame, cols[0], sessions, tabs, active, sidebar_focused, sessions_selected,
|
||||||
|
),
|
||||||
|
SidebarMode::Sftp => {
|
||||||
|
render_sftp_browser(frame, cols[0], tabs.get(active), sftp_selected, sidebar_focused)
|
||||||
|
}
|
||||||
|
SidebarMode::Upload => render_local_browser(
|
||||||
|
frame, cols[0], local_browser, local_entries, local_selected, sidebar_focused,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cols[1]
|
||||||
|
} else {
|
||||||
|
area
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
|
||||||
|
.split(main_area);
|
||||||
|
|
||||||
|
render_tab_bar(frame, rows[0], tabs, active);
|
||||||
|
if let Some(tab) = tabs.get(active) {
|
||||||
|
render_terminal(frame, rows[1], &tab.term_state);
|
||||||
|
}
|
||||||
|
render_status_bar(frame, rows[2], tabs, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строка состояния хоста активного таба — CPU/память/load average (см.
|
||||||
|
/// hoststats.rs). Обновляется периодически из app.rs, не на каждый кадр:
|
||||||
|
/// сама отрисовка просто показывает то, что уже лежит в Tab.host_stats.
|
||||||
|
fn render_status_bar(frame: &mut Frame, area: Rect, tabs: &[Tab], active: usize) {
|
||||||
|
let Some(tab) = tabs.get(active) else { return };
|
||||||
|
|
||||||
|
let text = match &tab.host_stats {
|
||||||
|
Some(stats) => {
|
||||||
|
let mem_used_gb =
|
||||||
|
stats.mem_total_kb.saturating_sub(stats.mem_available_kb) as f64 / 1024.0 / 1024.0;
|
||||||
|
let mem_total_gb = stats.mem_total_kb as f64 / 1024.0 / 1024.0;
|
||||||
|
let cpu = stats
|
||||||
|
.cpu_percent
|
||||||
|
.map(|c| format!("{c:.0}%"))
|
||||||
|
.unwrap_or_else(|| "…".to_string());
|
||||||
|
format!(
|
||||||
|
" CPU: {cpu} Load: {:.2} {:.2} {:.2} Mem: {mem_used_gb:.1}/{mem_total_gb:.1} GB ",
|
||||||
|
stats.load1, stats.load5, stats.load15
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Пусто до первого успешного снятия (только что подключились) и
|
||||||
|
// навсегда, если протокол это не поддерживает (Telnet) или
|
||||||
|
// /proc не распарсился (не Linux) — тихая деградация, без ошибок в UI.
|
||||||
|
None => format!(" {} ", tab.session_name),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bar = Paragraph::new(text).style(Style::default().fg(Color::Black).bg(Color::DarkGray));
|
||||||
|
frame.render_widget(bar, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ширина области под содержимое терминала с учётом того, видна ли сейчас
|
||||||
|
/// боковая панель. Нужна и в app.rs (чтобы знать, до какого размера
|
||||||
|
/// ресайзить PTY), и здесь для собственно раскладки — вынесена в одну
|
||||||
|
/// функцию, чтобы эти два места не могли разъехаться.
|
||||||
|
pub fn content_cols(total_cols: u16, sidebar_visible: bool) -> u16 {
|
||||||
|
if sidebar_visible {
|
||||||
|
total_cols.saturating_sub(SIDEBAR_WIDTH)
|
||||||
|
} else {
|
||||||
|
total_cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Высота области под содержимое терминала: одна строка уходит под tab
|
||||||
|
/// bar сверху, ещё одна — под строку состояния хоста снизу. Как и
|
||||||
|
/// content_cols, нужна и в app.rs (ресайз PTY), и здесь (раскладка) — чтобы
|
||||||
|
/// эти два места не могли разъехаться в размерах.
|
||||||
|
pub fn content_rows(total_rows: u16) -> u16 {
|
||||||
|
total_rows.saturating_sub(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рамка панели жёлтая, когда она в фокусе (F1/F4), и обычная серая —
|
||||||
|
/// когда просто видна, но клавиши всё ещё идут в терминал. Единственный
|
||||||
|
/// визуальный сигнал того, куда сейчас уйдёт следующее нажатие клавиши.
|
||||||
|
fn sidebar_border_style(focused: bool) -> Style {
|
||||||
|
if focused {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Список сохранённых сессий: открытые сейчас как табы помечены точкой,
|
||||||
|
/// активный таб — подсвечен. В фокусе (см. sidebar_border_style) реагирует
|
||||||
|
/// на ↑/↓/j/k и Enter (см. handle_sessions_key в app.rs) — Enter либо
|
||||||
|
/// переключает на уже открытый таб этой сессии, либо открывает новый.
|
||||||
|
fn render_sidebar(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
sessions: &SessionStore,
|
||||||
|
tabs: &[Tab],
|
||||||
|
active: usize,
|
||||||
|
focused: bool,
|
||||||
|
selected: usize,
|
||||||
|
) {
|
||||||
|
let active_name = tabs.get(active).map(|t| t.session_name.as_str());
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = sessions
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let is_open = tabs.iter().any(|t| t.session_name == s.name);
|
||||||
|
let is_active = active_name == Some(s.name.as_str());
|
||||||
|
let marker = if is_open { "* " } else { " " };
|
||||||
|
|
||||||
|
let style = if is_active {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_open {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(format!("{marker}{}", s.name)).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if focused && !items.is_empty() {
|
||||||
|
state.select(Some(selected.min(items.len() - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(sidebar_border_style(focused))
|
||||||
|
.title(format!(" Сессии ({}) ", sessions.sessions.len())),
|
||||||
|
)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Мастер создания новой сессии: протокол -> имя -> поля по протоколу.
|
||||||
|
/// Возвращает `None`, если пользователь отменил через Esc на любом шаге —
|
||||||
|
/// в этом случае ничего не сохраняется и не создаётся.
|
||||||
|
fn create_session_wizard(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
) -> Result<Option<SessionConfig>> {
|
||||||
|
let Some(protocol_choice) = choose_from(
|
||||||
|
terminal,
|
||||||
|
" Новая сессия: протокол (Enter, Esc — отмена) ",
|
||||||
|
&["SSH", "Telnet", "Serial/COM"],
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(name) = read_text_field(terminal, "Имя сессии (для списка и таб-бара)", "")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Ok(None); // пустое имя — трактуем как отмену, не создаём мусорную запись
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = match protocol_choice.as_str() {
|
||||||
|
"SSH" => {
|
||||||
|
let Some(host) =
|
||||||
|
read_text_field(terminal, "Хост (или алиас из ~/.ssh/config)", "")?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(port_str) = read_text_field(terminal, "Порт", "22")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(user) = read_text_field(
|
||||||
|
terminal,
|
||||||
|
"Пользователь (пусто — определить автоматически)",
|
||||||
|
"",
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(auth_choice) = choose_from(
|
||||||
|
terminal,
|
||||||
|
" Способ входа ",
|
||||||
|
&["Пароль", "Ключ", "Определить из ~/.ssh/config"],
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth = match auth_choice.as_str() {
|
||||||
|
"Пароль" => Some(AuthMethod::Password),
|
||||||
|
"Ключ" => {
|
||||||
|
let Some(path) = read_text_field(
|
||||||
|
terminal,
|
||||||
|
"Путь к приватному ключу (полный путь, без ~)",
|
||||||
|
"",
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
None // пустой путь — доверяем resolve.rs подобрать ключ самому
|
||||||
|
} else {
|
||||||
|
Some(AuthMethod::Key {
|
||||||
|
path: Some(PathBuf::from(path.trim())),
|
||||||
|
passphrase: PassphraseSource::Prompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Protocol::Ssh {
|
||||||
|
host,
|
||||||
|
port: port_str.trim().parse().ok(),
|
||||||
|
user: if user.trim().is_empty() { None } else { Some(user) },
|
||||||
|
auth,
|
||||||
|
credential: None,
|
||||||
|
use_ssh_config: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Telnet" => {
|
||||||
|
let Some(host) = read_text_field(terminal, "Хост", "")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(port_str) = read_text_field(terminal, "Порт", "23")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Protocol::Telnet { host, port: port_str.trim().parse().unwrap_or(23) }
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let Some(port) = read_text_field(
|
||||||
|
terminal,
|
||||||
|
"Путь к устройству (/dev/ttyUSB0, /dev/ttyACM0 или COM3)",
|
||||||
|
"",
|
||||||
|
)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(baud_str) = read_text_field(terminal, "Скорость (бод)", "115200")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Protocol::Serial {
|
||||||
|
port,
|
||||||
|
baud_rate: baud_str.trim().parse().unwrap_or(115200),
|
||||||
|
data_bits: Default::default(),
|
||||||
|
parity: Default::default(),
|
||||||
|
stop_bits: Default::default(),
|
||||||
|
flow_control: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(SessionConfig { name, protocol }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Однострочный выбор из нескольких вариантов (список без прокрутки,
|
||||||
|
/// как и pick_session_tui, только переиспользуемый с произвольным набором
|
||||||
|
/// пунктов). Esc/q — отмена всего мастера, а не только этого шага.
|
||||||
|
fn choose_from(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
title: &str,
|
||||||
|
options: &[&str],
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let mut selected = 0usize;
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
let items: Vec<ListItem> = options.iter().map(|o| ListItem::new(*o)).collect();
|
||||||
|
let mut state = ListState::default();
|
||||||
|
state.select(Some(selected));
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
frame.render_stateful_widget(list, frame.area(), &mut state);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let CEvent::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
selected = (selected + 1).min(options.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
KeyCode::Enter => return Ok(Some(options[selected].to_string())),
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => return Ok(None),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Однострочное текстовое поле (только добавление/удаление символов с
|
||||||
|
/// конца — без перемещения курсора внутри строки, для наших полей этого
|
||||||
|
/// достаточно). `default` — исходное значение, которое можно сразу
|
||||||
|
/// подтвердить пустым Enter, если оно и так устраивает (например, порт по
|
||||||
|
/// умолчанию). Esc — отмена всего мастера.
|
||||||
|
fn read_text_field(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
label: &str,
|
||||||
|
default: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let mut value = default.to_string();
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
render_text_field(frame, frame.area(), label, &value);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let CEvent::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Enter => return Ok(Some(value)),
|
||||||
|
KeyCode::Esc => return Ok(None),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
value.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => value.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_field(frame: &mut Frame, area: Rect, label: &str, value: &str) {
|
||||||
|
let text = format!("{value}_\n\nEnter — далее, Esc — отменить создание сессии");
|
||||||
|
let field = Paragraph::new(text).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" {label} ")),
|
||||||
|
);
|
||||||
|
frame.render_widget(field, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SFTP-браузер активного таба: список файлов текущей директории с
|
||||||
|
/// навигацией (см. handle_sftp_key в app.rs — ↑/↓/j/k, Enter, Esc).
|
||||||
|
/// Показывает подсказку вместо списка, если для этого таба SFTP ещё не
|
||||||
|
/// открыт или недоступен (telnet-сессия, ошибка подключения).
|
||||||
|
fn render_sftp_browser(frame: &mut Frame, area: Rect, tab: Option<&Tab>, selected: usize, focused: bool) {
|
||||||
|
let Some(tab) = tab else {
|
||||||
|
let placeholder = Paragraph::new("Нет активного таба")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" SFTP "));
|
||||||
|
frame.render_widget(placeholder, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(browser) = &tab.sftp else {
|
||||||
|
let placeholder = Paragraph::new(
|
||||||
|
"SFTP недоступен для этой сессии\n(поддерживается только SSH)\n\nEsc — назад к списку сессий",
|
||||||
|
)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" SFTP "));
|
||||||
|
frame.render_widget(placeholder, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = tab
|
||||||
|
.sftp_entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let label = if entry.is_dir {
|
||||||
|
format!("[D] {}", entry.name)
|
||||||
|
} else {
|
||||||
|
format!(" {} ({})", entry.name, human_size(entry.size))
|
||||||
|
};
|
||||||
|
ListItem::new(label)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if !items.is_empty() {
|
||||||
|
state.select(Some(selected.min(items.len() - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(sidebar_border_style(focused))
|
||||||
|
.title(format!(" {} (u — загрузить файл) ", browser.cwd())),
|
||||||
|
)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Локальный файловый браузер для выбора файла на закачку (режим Upload,
|
||||||
|
/// вход по `u` внутри SFTP-режима — см. handle_upload_key в app.rs).
|
||||||
|
fn render_local_browser(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
browser: Option<&LocalBrowser>,
|
||||||
|
entries: &[LocalEntry],
|
||||||
|
selected: usize,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
let title = browser
|
||||||
|
.map(|b| b.cwd().display().to_string())
|
||||||
|
.unwrap_or_else(|| "?".to_string());
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let label = if entry.is_dir {
|
||||||
|
format!("[D] {}", entry.name)
|
||||||
|
} else {
|
||||||
|
format!(" {} ({})", entry.name, human_size(entry.size))
|
||||||
|
};
|
||||||
|
ListItem::new(label)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if !items.is_empty() {
|
||||||
|
state.select(Some(selected.min(items.len() - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(sidebar_border_style(focused))
|
||||||
|
.title(format!(" Загрузить: {title} ")),
|
||||||
|
)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Человекочитаемый размер файла (1.5 MB вместо 1572864) для списка SFTP.
|
||||||
|
fn human_size(bytes: u64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} {}", UNITS[unit])
|
||||||
|
} else {
|
||||||
|
format!("{size:.1} {}", UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Полоска табов: "1:myserver 2:staging-box 3:old-switch", активная
|
||||||
|
/// вкладка выделена инверсией цвета. Номера соответствуют хоткею Alt+N.
|
||||||
|
fn render_tab_bar(frame: &mut Frame, area: Rect, tabs: &[Tab], active: usize) {
|
||||||
|
let mut spans = Vec::with_capacity(tabs.len());
|
||||||
|
for (i, tab) in tabs.iter().enumerate() {
|
||||||
|
let label = format!(" {}:{} ", i + 1, tab.session_name);
|
||||||
|
let style = if i == active {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(label, style));
|
||||||
|
}
|
||||||
|
frame.render_widget(Paragraph::new(RLine::from(spans)), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отрисовать содержимое эмулируемого терминала: проходим по сетке
|
||||||
|
/// alacritty_terminal напрямую через Line/Column-индексацию (а не
|
||||||
|
/// display_iter — так проще сохранить построчную структуру и не
|
||||||
|
/// разбираться, где именно проходит граница строк в плоском потоке ячеек).
|
||||||
|
/// Соседние ячейки с одинаковым стилем схлопываются в один Span, чтобы не
|
||||||
|
/// плодить по одному Span на символ.
|
||||||
|
pub fn render_terminal(frame: &mut Frame, area: Rect, state: &TerminalState) {
|
||||||
|
let grid = state.grid();
|
||||||
|
let (cursor_line, cursor_col) = state.cursor_position();
|
||||||
|
|
||||||
|
let rows = (area.height as usize).min(state.screen_lines());
|
||||||
|
let cols = (area.width as usize).min(state.columns());
|
||||||
|
|
||||||
|
let mut lines: Vec<RLine> = Vec::with_capacity(area.height as usize);
|
||||||
|
|
||||||
|
for row_idx in 0..rows {
|
||||||
|
let grid_row = &grid[Line(row_idx as i32)];
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
let mut run: Option<(Style, String)> = None;
|
||||||
|
|
||||||
|
for col_idx in 0..cols {
|
||||||
|
let cell = &grid_row[Column(col_idx)];
|
||||||
|
|
||||||
|
let mut style = Style::default()
|
||||||
|
.fg(map_color(cell.fg))
|
||||||
|
.bg(map_color(cell.bg));
|
||||||
|
if cell.flags.contains(Flags::BOLD) {
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if cell.flags.contains(Flags::DIM) {
|
||||||
|
style = style.add_modifier(Modifier::DIM);
|
||||||
|
}
|
||||||
|
if cell.flags.contains(Flags::ITALIC) {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if cell.flags.intersects(Flags::UNDERLINE | Flags::DOUBLE_UNDERLINE) {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
if cell.flags.contains(Flags::STRIKEOUT) {
|
||||||
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||||
|
}
|
||||||
|
if cell.flags.contains(Flags::INVERSE) {
|
||||||
|
style = style.add_modifier(Modifier::REVERSED);
|
||||||
|
}
|
||||||
|
// Текстовый курсор рисуем инверсией — без отдельного "блочного"
|
||||||
|
// символа, это не отличить от Flags::INVERSE самой ячейки, но
|
||||||
|
// для MVP достаточно, чтобы было видно, где курсор.
|
||||||
|
if row_idx as i32 == cursor_line && col_idx == cursor_col {
|
||||||
|
style = style.add_modifier(Modifier::REVERSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
match &mut run {
|
||||||
|
Some((run_style, text)) if *run_style == style => text.push(cell.c),
|
||||||
|
_ => {
|
||||||
|
if let Some((s, t)) = run.take() {
|
||||||
|
spans.push(Span::styled(t, s));
|
||||||
|
}
|
||||||
|
run = Some((style, cell.c.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((s, t)) = run.take() {
|
||||||
|
spans.push(Span::styled(t, s));
|
||||||
|
}
|
||||||
|
lines.push(RLine::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(lines), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// alacritty различает Named (16 ANSI + спец-цвета типа Foreground/Cursor),
|
||||||
|
/// Indexed (0-255, включая 6x6x6-куб и градации серого) и Spec (прямой RGB).
|
||||||
|
/// ratatui поддерживает Color::Indexed(u8) напрямую, так что для Indexed
|
||||||
|
/// достаточно просто перекинуть индекс дальше — терминал сам разрешит его
|
||||||
|
/// в свою палитру. Named и Spec матчим на конкретные ratatui-цвета.
|
||||||
|
fn map_color(color: AnsiColor) -> Color {
|
||||||
|
match color {
|
||||||
|
AnsiColor::Named(named) => match named {
|
||||||
|
NamedColor::Black => Color::Black,
|
||||||
|
NamedColor::Red => Color::Red,
|
||||||
|
NamedColor::Green => Color::Green,
|
||||||
|
NamedColor::Yellow => Color::Yellow,
|
||||||
|
NamedColor::Blue => Color::Blue,
|
||||||
|
NamedColor::Magenta => Color::Magenta,
|
||||||
|
NamedColor::Cyan => Color::Cyan,
|
||||||
|
NamedColor::White => Color::Gray,
|
||||||
|
NamedColor::BrightBlack => Color::DarkGray,
|
||||||
|
NamedColor::BrightRed => Color::LightRed,
|
||||||
|
NamedColor::BrightGreen => Color::LightGreen,
|
||||||
|
NamedColor::BrightYellow => Color::LightYellow,
|
||||||
|
NamedColor::BrightBlue => Color::LightBlue,
|
||||||
|
NamedColor::BrightMagenta => Color::LightMagenta,
|
||||||
|
NamedColor::BrightCyan => Color::LightCyan,
|
||||||
|
NamedColor::BrightWhite => Color::White,
|
||||||
|
NamedColor::Foreground => Color::Reset,
|
||||||
|
NamedColor::Background => Color::Reset,
|
||||||
|
// Курсор рисуем отдельно через Modifier::REVERSED на самой
|
||||||
|
// ячейке (см. render_terminal) — как цвет он тут не нужен.
|
||||||
|
NamedColor::Cursor => Color::Reset,
|
||||||
|
// У ratatui нет отдельных "приглушённых" вариантов Color — берём
|
||||||
|
// обычный (не bright) цвет той же группы. Более точное затемнение
|
||||||
|
// потребовало бы явного Color::Rgb с посчитанной яркостью.
|
||||||
|
NamedColor::DimBlack => Color::Black,
|
||||||
|
NamedColor::DimRed => Color::Red,
|
||||||
|
NamedColor::DimGreen => Color::Green,
|
||||||
|
NamedColor::DimYellow => Color::Yellow,
|
||||||
|
NamedColor::DimBlue => Color::Blue,
|
||||||
|
NamedColor::DimMagenta => Color::Magenta,
|
||||||
|
NamedColor::DimCyan => Color::Cyan,
|
||||||
|
NamedColor::DimWhite => Color::Gray,
|
||||||
|
NamedColor::BrightForeground => Color::White,
|
||||||
|
NamedColor::DimForeground => Color::Gray,
|
||||||
|
},
|
||||||
|
AnsiColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b),
|
||||||
|
AnsiColor::Indexed(idx) => Color::Indexed(idx),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue