Fix render

This commit is contained in:
Magnus Root 2026-07-17 09:30:05 +03:00
parent f9b6b4e6fe
commit ff9a0aae8d
19 changed files with 3979 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target
Cargo.lock

29
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
}
}