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