From ff9a0aae8d995c0b2de1ffcf2786393093922c29 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Fri, 17 Jul 2026 09:30:05 +0300 Subject: [PATCH] Fix render --- .gitignore | 2 + Cargo.toml | 29 ++ README.md | 486 +++++++++++++++++++++++++ src/app.rs | 762 ++++++++++++++++++++++++++++++++++++++++ src/hoststats.rs | 96 +++++ src/hotkeys.rs | 155 ++++++++ src/localfs.rs | 86 +++++ src/main.rs | 108 ++++++ src/profiles.rs | 116 ++++++ src/resolve.rs | 106 ++++++ src/session.rs | 179 ++++++++++ src/sftp.rs | 125 +++++++ src/sshconfig.rs | 225 ++++++++++++ src/term.rs | 111 ++++++ src/transport/mod.rs | 66 ++++ src/transport/serial.rs | 107 ++++++ src/transport/ssh.rs | 288 +++++++++++++++ src/transport/telnet.rs | 193 ++++++++++ src/ui.rs | 739 ++++++++++++++++++++++++++++++++++++++ 19 files changed, 3979 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/hoststats.rs create mode 100644 src/hotkeys.rs create mode 100644 src/localfs.rs create mode 100644 src/main.rs create mode 100644 src/profiles.rs create mode 100644 src/resolve.rs create mode 100644 src/session.rs create mode 100644 src/sftp.rs create mode 100644 src/sshconfig.rs create mode 100644 src/term.rs create mode 100644 src/transport/mod.rs create mode 100644 src/transport/serial.rs create mode 100644 src/transport/ssh.rs create mode 100644 src/transport/telnet.rs create mode 100644 src/ui.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2f9e58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7411548 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..84ddc7f --- /dev/null +++ b/README.md @@ -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`) — учтено в текущем коде. + +## ~/.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(&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` (см. раздел выше) — +руками их редактировать не обязательно. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..5f11288 --- /dev/null +++ b/src/app.rs @@ -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, + incoming: transport::IncomingRx, + pub term_state: TerminalState, + pub sftp: Option, + pub sftp_entries: Vec, + /// Статистика для строки состояния (см. hoststats.rs) — None до первого + /// успешного опроса или навсегда, если протокол/ОС не поддерживают. + pub host_stats: Option, + cpu_sample: Option, + 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>> { + 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>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +pub async fn run( + targets: Vec, + 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> { + 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>, + 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>, + tabs: &mut Vec, + 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 = None; + let mut local_entries: Vec = 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, + active: &mut usize, + sessions_selected: &mut usize, + credentials: &CredentialStore, + ssh_config: &SshConfig, + terminal: &mut Terminal>, + 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, + local_entries: &mut Vec, + 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>, + sessions: &mut SessionStore, + credentials: &CredentialStore, + ssh_config: &SshConfig, + sidebar_visible: bool, +) -> Result> { + 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 { + 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> { + 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, + } +} diff --git a/src/hoststats.rs b/src/hoststats.rs new file mode 100644 index 0000000..ff2fa29 --- /dev/null +++ b/src/hoststats.rs @@ -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, +} + +/// Сырые накопительные счётчики из /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) -> 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 = 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, + )) +} diff --git a/src/hotkeys.rs b/src/hotkeys.rs new file mode 100644 index 0000000..2ff915f --- /dev/null +++ b/src/hotkeys.rs @@ -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 { + let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm") + .context("не удалось определить домашнюю директорию")?; + Ok(dirs.config_dir().join("hotkeys.toml")) + } + + pub fn load() -> Result { + 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::().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)) +} diff --git a/src/localfs.rs b/src/localfs.rs new file mode 100644 index 0000000..5be8e53 --- /dev/null +++ b/src/localfs.rs @@ -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> { + 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> { + 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. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d0cad8b --- /dev/null +++ b/src/main.rs @@ -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, + + /// Имена сохранённых сессий — каждая откроется в своём табе + /// (`komiterm myserver staging-box`). Без аргументов и без subcommand — + /// открывается менеджер сессий и запускается один таб. + session: Vec, +} + +#[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::>>()?; + + 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(()) +} diff --git a/src/profiles.rs b/src/profiles.rs new file mode 100644 index 0000000..ed061e4 --- /dev/null +++ b/src/profiles.rs @@ -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, +} + +impl CredentialStore { + pub fn config_path() -> Result { + let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm") + .context("не удалось определить домашнюю директорию")?; + Ok(dirs.config_dir().join("credentials.toml")) + } + + pub fn load() -> Result { + 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 { + use std::io::{self, Write}; + + let ask = |label: &str| -> Result { + 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 }) +} diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..e03aa31 --- /dev/null +++ b/src/resolve.rs @@ -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 { + 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 { + 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 { + 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 { + 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()) +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..8add794 --- /dev/null +++ b/src/session.rs @@ -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, + #[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, + /// Если None — берётся из ~/.ssh/config, credential-профиля + /// или текущего системного пользователя, в этом порядке. + user: Option, + /// Способ входа прямо в сессии. Приоритет выше, чем `credential`. + auth: Option, + /// Имя профиля из credentials.toml — удобно, когда один и тот же + /// логин/ключ используется для нескольких сессий. + credential: Option, + /// Подтягивать ли недостающие 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, +} + +impl SessionStore { + pub fn config_path() -> Result { + let dirs = directories::ProjectDirs::from("dev", "komiterm", "komiterm") + .context("не удалось определить домашнюю директорию")?; + Ok(dirs.config_dir().join("sessions.toml")) + } + + pub fn load() -> Result { + 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) + } +} diff --git a/src/sftp.rs b/src/sftp.rs new file mode 100644 index 0000000..d8f2eb7 --- /dev/null +++ b/src/sftp.rs @@ -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 { + let cwd = session.canonicalize(".").await?; + Ok(Self { session, cwd }) + } + + pub fn cwd(&self) -> &str { + &self.cwd + } + + /// Список записей текущей директории: сначала папки, потом файлы, + /// внутри каждой группы — по алфавиту (без учёта регистра), как + /// большинство файловых менеджеров, включая MobaXterm. + pub async fn list(&self) -> Result> { + 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 { + 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. +// - переименование/удаление/mkdir — у SftpSession методы есть +// (remove_file/remove_dir/rename/create_dir), в браузере не выведены. +// - права доступа/владелец есть в metadata, но никак не показываются. diff --git a/src/sshconfig.rs b/src/sshconfig.rs new file mode 100644 index 0000000..8ec44d5 --- /dev/null +++ b/src/sshconfig.rs @@ -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, + pub user: Option, + pub port: Option, + pub identity_files: Vec, +} + +/// Распарсенный ~/.ssh/config целиком (включая всё, что подтянуто через +/// Include) — список (паттерны Host, директивы). Порядок блоков сохраняется, +/// потому что в OpenSSH первое встреченное значение параметра побеждает — +/// так же делаем и мы в `lookup`. +pub struct SshConfig { + blocks: Vec<(Vec, 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 { + let dirs = directories::UserDirs::new() + .ok_or_else(|| anyhow::anyhow!("не удалось определить домашнюю директорию"))?; + Ok(dirs.home_dir().join(".ssh").join("config")) + } + + pub fn load_default() -> Result { + let path = Self::default_path()?; + if !path.exists() { + return Ok(Self { blocks: Vec::new() }); + } + Self::load(&path) + } + + pub fn load(path: &Path) -> Result { + 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, HostEntry)>, + visited: &mut HashSet, + 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, 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 { + 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 = 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 с приоритетом ниже пользовательского конфига diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 0000000..9d67688 --- /dev/null +++ b/src/term.rs @@ -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); + +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, + parser: Processor, + /// События от alacritty_terminal (смена заголовка через OSC, звонок, + /// clipboard-запросы и т.п.) — очередь собирается, но пока никто её не + /// читает: например, обновление имени таба по Event::Title ещё не + /// подключено к ui.rs/app.rs. Оставлено как задел на будущее, а не + /// удалено, поэтому и глушим dead_code явно, а не молчим о причине. + #[allow(dead_code)] + events: std::sync::mpsc::Receiver, +} + +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 { + 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 { + self.events.try_iter().collect() + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 0000000..351fe29 --- /dev/null +++ b/src/transport/mod.rs @@ -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> { + Ok(None) + } + + /// Выполнить одноразовую команду на удалённой стороне (для строки + /// состояния — CPU/память/load average, см. hoststats.rs) и вернуть её + /// stdout целиком. Дефолт — `Ok(None)`: у Telnet нет понятия "команда + /// выполнилась и вернула вывод", есть только один непрерывный поток + /// байт в интерактивную сессию. + async fn run_command(&mut self, _cmd: &str) -> Result> { + Ok(None) + } +} + +/// Канал, по которому входящие байты от удалённого хоста +/// прилетают в terminal-парсер (alacritty_terminal). +pub type IncomingRx = mpsc::UnboundedReceiver>; + +/// Установить соединение согласно конфигурации сессии. Для SSH сюда же +/// подмешиваются credential-профили и ~/.ssh/config — см. resolve.rs. +pub async fn connect( + cfg: &SessionConfig, + credentials: &CredentialStore, + ssh_config: &SshConfig, +) -> Result<(Box, 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 + } + } +} diff --git a/src/transport/serial.rs b/src/transport/serial.rs new file mode 100644 index 0000000..b785f8c --- /dev/null +++ b/src/transport/serial.rs @@ -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, +} + +#[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, 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, + } +} diff --git a/src/transport/ssh.rs b/src/transport/ssh.rs new file mode 100644 index 0000000..ecc1cf9 --- /dev/null +++ b/src/transport/ssh.rs @@ -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 { + 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() даёт готовую строку "<алгоритм> " — + // ровно то, что нужно дописать в 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, + cmd_tx: mpsc::UnboundedSender, +} + +/// Команды основному интерактивному каналу (shell/PTY). Канал целиком +/// (и на чтение, и на запись) живёт в одной задаче — см. connect() — чтобы +/// не зависеть от точного API разделения Channel на read/write половины, +/// который в этой версии russh не был проверен вживую. +enum ChannelCmd { + Data(Vec), + 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> { + // Отдельный канал той же 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> { + // Ещё один отдельный канал — 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, 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::(); + + // Один и тот же 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)) +} diff --git a/src/transport/telnet.rs b/src/transport/telnet.rs new file mode 100644 index 0000000..b86621a --- /dev/null +++ b/src/transport/telnet.rs @@ -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>, +} + +#[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, 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>, + // что и обычная запись пользовательского ввода) — нужно, чтобы отвечать + // на 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, Vec, 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 у нас — отвечаем WILL + (true, false) => WONT, + (false, true) => DO, // сервер предложил WILL — отвечаем DO + (false, false) => DONT, + }; + [IAC, reply_cmd, opt] +} + +/// Ищет конец subnegotiation (IAC SE), начиная поиск с позиции `from`. +/// Возвращает индекс сразу после SE, если нашли, иначе None (пакет обрезан). +fn find_subnegotiation_end(input: &[u8], from: usize) -> Option { + 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 +// по умолчанию, что обычно приемлемо, но не всегда. diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..dac7861 --- /dev/null +++ b/src/ui.rs @@ -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>, + sessions: &mut SessionStore, +) -> Result> { + 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 = 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 = 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>, +) -> Result> { + 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>, + title: &str, + options: &[&str], +) -> Result> { + let mut selected = 0usize; + loop { + terminal.draw(|frame| { + let items: Vec = 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>, + label: &str, + default: &str, +) -> Result> { + 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 = 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 = 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 = 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 = 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), + } +}