Files
telegram-tui/CONTEXT.md
Mikhail Kilin 25c57c55fb feat: add per-account lock file protection via fs2
Prevent running multiple tele-tui instances with the same account by
using advisory file locks (flock). Lock is acquired before raw mode so
errors print to normal terminal. Account switching acquires new lock
before releasing old. Also log set_tdlib_parameters errors via tracing
instead of silently discarding them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:35:06 +03:00

21 KiB
Raw Blame History

Текущий контекст проекта

Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)

Per-Account Lock File Protection — DONE

Защита от запуска двух экземпляров tele-tui с одним аккаунтом + логирование ошибок TDLib.

Проблема: При запуске второго экземпляра с тем же аккаунтом, TDLib не может залочить свою БД. set_tdlib_parameters молча падает (let _ = ...), и приложение зависает на "Инициализация TDLib...".

Решение: Advisory file locks через fs2 (flock):

  • Lock файл: ~/.local/share/tele-tui/accounts/{name}/tele-tui.lock
  • Автоматическое освобождение при crash/SIGKILL (ядро ОС закрывает file descriptors)
  • При старте: acquire lock ДО enable_raw_mode() → ошибка выводится в обычный терминал
  • При переключении аккаунтов: acquire new → release old → switch (при ошибке — остаёмся на старом)
  • Логирование: set_tdlib_parameters ошибки теперь логируются через tracing::error!

Новые файлы:

  • src/accounts/lock.rsacquire_lock(), release_lock(), account_lock_path() + 4 теста

Модифицированные файлы:

  • Cargo.toml — зависимость fs2 = "0.4"
  • src/accounts/mod.rspub mod lock; + re-exports
  • src/app/mod.rs — поле account_lock: Option<File> в App<T>
  • src/main.rs — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters
  • src/tdlib/client.rs — логирование set_tdlib_parameters в recreate_client()

Photo Albums (Media Groups) — DONE

Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.

Проблема: TDLib отправляет альбомы как отдельные Message с общим media_album_id: i64. Ранее проект это поле игнорировал — каждое фото отображалось как отдельный пузырь.

Решение:

  1. Data Modelmedia_album_id: i64 в MessageMetadata, MessageBuilder, getter MessageInfo::media_album_id(). Оба конвертера (async + sync) передают поле из TDLib.

  2. Message Grouping — новый вариант MessageGroup::Album(Vec<MessageInfo>). Сообщения с одинаковым media_album_id != 0 группируются; одиночное сообщение с album_id остаётся Message.

  3. Album Grid ConstantsALBUM_PHOTO_WIDTH: 16, ALBUM_PHOTO_HEIGHT: 8, ALBUM_PHOTO_GAP: 1, ALBUM_GRID_MAX_COLS: 3 (3×16 + 2×1 = 50 = INLINE_IMAGE_MAX_WIDTH).

  4. render_album_bubble() — сетка фото (до 3 в ряд), DeferredImageRender с x_offset для каждого фото, общая подпись и timestamp, индикация выбора, статусы загрузки.

  5. IntegrationAlbum arm в render_message_list, x_offset в second pass. Без feature images — fallback через отдельные bubble.

Модифицированные файлы:

  • src/tdlib/types.rsmedia_album_id в MessageMetadata, MessageBuilder, getter
  • src/tdlib/messages/convert.rs — передача media_album_id в builder
  • src/tdlib/message_converter.rs — передача media_album_id в builder
  • src/message_grouping.rsAlbum variant + album detection + 4 новых теста
  • src/constants.rs — album grid constants
  • src/ui/components/message_bubble.rsx_offset в DeferredImageRender, render_album_bubble()
  • src/ui/components/mod.rs — export render_album_bubble
  • src/ui/messages.rsAlbum arm + x_offset в second pass
  1. Навигация j/k по альбомам — альбом обрабатывается как одно сообщение. select_previous_message() / select_next_message() перескакивают через все сообщения альбома. start_message_selection() встаёт на первый элемент альбома если последнее сообщение — часть альбома.

  2. Тесты — 4 unit-теста в message_grouping.rs, 5 snapshot-тестов в tests/messages.rs, 3 теста навигации в tests/input_navigation.rs.

Дополнительно модифицированные файлы:

  • src/app/methods/messages.rs — навигация перескакивает альбомы
  • tests/helpers/test_data.rsTestMessageBuilder::media_album_id()
  • tests/messages.rs — 5 snapshot-тестов для альбомов
  • tests/input_navigation.rs — 3 теста навигации по альбомам

Что НЕ меняется: image modal (v), auto-download, одиночные фото.


Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE)

Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов.

Проблема: open_chat_and_load_data() блокировал UI до полной загрузки ВСЕХ сообщений (get_chat_history(chat_id, i32::MAX)). Для чата с 500+ сообщениями это 10+ запросов к TDLib.

Решение:

  • Загрузка только 50 последних сообщений (один запрос) → чат виден сразу
  • Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop через pending_chat_init
  • Старые сообщения подгружаются при скролле вверх (существующий load_older_messages_if_needed)

Модифицированные файлы:

  • src/app/mod.rs — поле pending_chat_init: Option<ChatId>
  • src/input/handlers/chat_list.rsopen_chat_and_load_data(): 50 сообщений + pending_chat_init
  • src/main.rs — обработка pending_chat_init в main loop (reply info, pinned, photos)
  • src/app/methods/navigation.rsсброс pending_chat_init в close_chat()

Bugfix: Авто-загрузка фото в чате (DONE)

Фото не отображались — отсутствовал код загрузки файлов после открытия чата.

Проблема: extract_media_info() создавал PhotoInfo с PhotoDownloadState::NotDownloaded, но никакой код не инициировал download_file(). Фото оставались в состоянии "📷 [Фото]" без inline превью.

Исправление:

  • Авто-загрузка при открытии чата: после загрузки истории сообщений скачиваются фото из последних 30 сообщений (если auto_download_images = true и show_images = true). Каждый файл — с таймаутом 5 сек.
  • Загрузка по v: вместо "Фото не загружено" — скачивание + открытие модалки. Также повторная попытка при Error.
  • Обновление PhotoDownloadState в сообщении после успешной/неуспешной загрузки.

Модифицированные файлы:

  • src/input/handlers/chat_list.rs — авто-загрузка фото в open_chat_and_load_data()
  • src/input/handlers/chat.rshandle_view_image(): download on NotDownloaded + retry on Error

Этап 2+3: Account Switcher Modal + Переключение аккаунтов (DONE)

Реализована модалка переключения аккаунтов и механизм переключения:

  • Модалка Ctrl+A: глобальный оверлей поверх любого экрана (Loading/Auth/Main)
  • Навигация: j/k по списку, Enter выбор, a добавление, Esc закрытие
  • Переключение: close TDLib → recreate_client(new_db_path) → auth flow
  • Добавление аккаунта: ввод имени в модалке → валидация → add_account() → переключение
  • Footer индикатор: [account_name] если не "default"
  • AccountSwitcherState: enum SelectAccount / AddAccount — глобальный оверлей в App<T>
  • recreate_client(): новый метод в TdClientTrait — close old → new TdClient → spawn set_tdlib_parameters

Новые файлы:

  • src/ui/modals/account_switcher.rs — UI рендеринг (SelectAccount + AddAccount)
  • tests/account_switcher.rs — 12 тестов

Модифицированные файлы:

  • src/app/mod.rsAccountSwitcherState enum, 3 поля (account_switcher, current_account_name, pending_account_switch), 8 методов
  • src/accounts/manager.rsadd_account() (validate + save + ensure_dir)
  • src/accounts/mod.rs — re-export add_account
  • src/tdlib/trait.rsrecreate_client(&mut self, db_path) в TdClientTrait
  • src/tdlib/client.rs — реализация recreate_client (close → new → set_tdlib_parameters)
  • src/tdlib/client_impl.rs — trait impl делегирование
  • tests/helpers/fake_tdclient_impl.rs — no-op recreate_client
  • src/input/main_input.rs — account_switcher роутинг (highest priority)
  • src/input/handlers/global.rsCtrl+A → open_account_switcher
  • src/input/handlers/modal.rshandle_account_switcher() (SelectAccount + AddAccount input)
  • src/ui/modals/mod.rspub mod account_switcher;
  • src/ui/mod.rs — overlay поверх любого экрана
  • src/ui/footer.rs[account_name] индикатор
  • src/main.rspending_account_switch check в run_app, Ctrl+A из любого экрана

Этап 1: Инфраструктура профилей аккаунтов (DONE)

Реализована инфраструктура для мультиаккаунта:

  • Модуль accounts/: profile.rs (типы + валидация) + manager.rs (загрузка/сохранение/миграция)
  • accounts.toml: конфиг списка аккаунтов в ~/.config/tele-tui/accounts.toml
  • XDG data dir: БД TDLib хранится в ~/.local/share/tele-tui/accounts/{name}/tdlib_data/
  • Автомиграция: ./tdlib_data/ → XDG path при первом запуске
  • CLI флаг --account <name>: выбор аккаунта при запуске
  • Параметризация db_path: TdClient::new(db_path), App::new(config, db_path)

Предыдущий статус: Multiline Message Display (DONE)

Multiline в сообщениях

  • Multiline в сообщениях: \n корректно отображается в пузырях сообщений (split по \n + word wrap)
  • Маркер выделения: ▶ показывается только на первой строке multiline-сообщения
  • Перенос строки в инпуте отключён (Shift+Enter/Alt+Enter/Ctrl+J не вставляют \n)

Файлы изменены:

  • ui/components/message_bubble.rswrap_text_with_offsets() split по \n + wrap_paragraph() + selection marker fix

Vim Normal/Insert Mode (DONE)

Реализован Vim-like режим работы с двумя состояниями:

  • Normal mode (по умолчанию при открытии чата): навигация j/k, команды d/r/f/y, автоматический вход в MessageSelection
  • Insert mode (нажать i/ш): набор текста, Esc возвращает в Normal
  • Автопереключение в Insert при Reply (r) и Edit (Enter)
  • Визуальные индикаторы: [NORMAL]/[INSERT] в footer, зелёная/серая рамка compose bar
  • В Insert mode блокируются все команды кроме текстового ввода и Esc

Файлы изменены:

  • app/chat_state.rs — enum InputMode
  • app/mod.rs — поле input_mode в App<T>
  • config/keybindings.rsCommand::EnterInsertMode + keybinding i/ш
  • app/methods/navigation.rsclose_chat() сбрасывает input_mode
  • input/main_input.rs — главный роутер Insert/Normal
  • input/handlers/chat.rs — EnterInsertMode, auto-Insert при Reply/Edit
  • input/handlers/chat_list.rs — auto-MessageSelection при открытии чата
  • ui/footer.rs — mode indicator
  • ui/compose_bar.rs — visual mode differentiation
  • tests/ — обновлены для нового поведения

Предыдущий статус: Фаза 12 — Прослушивание голосовых сообщений (DONE)

Завершённые фазы (краткий итог)

Фаза Описание Статус
1 Базовая инфраструктура (ratatui + crossterm, vim-навигация) DONE
2 TDLib интеграция (авторизация, чаты, сообщения) DONE
3 Улучшение UX (отправка, поиск, скролл, realtime) DONE
4 Папки и фильтрация (загрузка папок, переключение 1-9) DONE
5 Расширенный функционал (онлайн-статус, медиа-заглушки, muted) DONE
6 Полировка (60 FPS, память, graceful shutdown, динамический инпут) DONE
7 Рефакторинг памяти (единый источник данных, LRU-кэш) DONE
8 Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) DONE
9 Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) DONE
10 Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) DONE (83%)
11 Inline просмотр фото (ratatui-image, кэш, загрузка) DONE
12 Прослушивание голосовых сообщений (ffplay, play/pause, seek, ticker, cache, config) DONE
13 Глубокий рефакторинг архитектуры (7 этапов) DONE

Фаза 11: Inline фото + оптимизации (подробности)

Feature-gated (images), 2-tier архитектура:

Базовая реализация:

  1. Типы: MediaInfo, PhotoInfo, PhotoDownloadState, ImageModalState, ImagesConfig
  2. Зависимости: ratatui-image 8.1, image 0.25 (feature-gated)
  3. Media модуль: ImageCache (LRU), dual ImageRenderer (inline + modal)
  4. UX: Always-show inline preview (фикс. ширина 50 chars) + полноэкранная модалка на v/м
  5. Метаданные: extract_media_info() из TDLib MessagePhoto; auto-download visible photos

Оптимизации производительности:

  1. Dual protocol strategy:
    • inline_image_renderer: Halfblocks → быстро (Unicode блоки), для навигации
    • modal_image_renderer: iTerm2/Sixel → медленно (high quality), для просмотра
  2. Frame throttling: inline images 15 FPS (66ms), текст 60 FPS
  3. Lazy loading: загрузка только видимых изображений (не все сразу)
  4. LRU кэш: max 100 протоколов, eviction старых
  5. Loading indicator: " Загрузка..." в модалке при первом открытии
  6. Navigation hotkeys: / между фото, Esc/q закрыть модалку

UI рендеринг:

  • message_bubble.rs: photo status (Downloading/Error/placeholder), inline preview
  • messages.rs: второй проход с render_images() + throttling + только видимые
  • modals/image_viewer.rs: fullscreen modal с aspect ratio + loading state

Фаза 13: Рефакторинг (подробности)

Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру:

  • input/main_input.rs (1199→164): чистый роутер + 5 handler модулей в handlers/
  • app/mod.rs (1015→371): 5 trait модулей в methods/ (Navigation, Message, Compose, Search, Modal)
  • ui/messages.rs (893→365): модули modals/ (search, pinned, delete, reactions) + compose_bar.rs
  • tdlib/messages.rs (836→3 файла): messages/ (mod, convert, operations)
  • config/mod.rs (642→3 файла): validation.rs, loader.rs
  • Очистка дублей: ~220 строк удалено (shared components, format_user_status, scroll_to_message)
  • Документация: PROJECT_STRUCTURE.md переписан, 16 файлов получили //! docs

Фаза 12: Голосовые сообщения (подробности)

Реализовано:

  • AudioPlayer на ffplay (subprocess): play, pause (SIGSTOP), resume (SIGCONT), stop
  • VoiceCache: LRU кэш OGG файлов в ~/.cache/tele-tui/voice/ (max 100 MB)
  • Типы: VoiceInfo, VoiceDownloadState, PlaybackState, PlaybackStatus
  • TDLib интеграция: download_voice_note(), конвертация MessageVoiceNote
  • Хоткеи: Space (play/pause), ←/→ (seek ±5s via ffplay restart с -ss)
  • Автостоп: при навигации на другое сообщение воспроизведение останавливается
  • Ticker: last_playback_tick в App + обновление position в event loop (1 FPS redraw)
  • VoiceCache: проверка кэша перед загрузкой, кэширование после download
  • AudioConfig: [audio] секция в config.toml (cache_size_mb, auto_download_voice)
  • UI: progress bar (━●─) + waveform (▁▂▃▄▅▆▇█) + иконки статуса в message_bubble.rs
  • Race condition fixes: starting flag + pid ownership guard в потоках AudioPlayer
  • Seek: resume_from() перезапускает ffplay с -ss offset; MoveLeft/MoveRight как alias для SeekBackward/SeekForward
  • Resume with rewind: пауза→продолжение откатывает на 1 секунду назад
  • Graceful shutdown: stop_playback() + Drop impl для AudioPlayer

Ключевая архитектура

main.rs → event loop (16ms poll)
├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search)
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/)
├── audio/ → player.rs (ffplay), cache.rs (VoiceCache)
├── media/ → [feature=images] cache.rs, image_renderer.rs
└── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types)

Тестирование

500+ тестов (0 failures):

  • Snapshot tests: 57 (UI компоненты)
  • Integration tests: 93 (user flows)
  • E2E tests: 12 (smoke + journey)
  • Utils tests: 18
  • Performance benchmarks: 8

Ключевые решения

  1. Неблокирующий receive: TDLib updates через mpsc::channel в отдельном потоке
  2. Trait-based App: методы разбиты на traits — требуют use import на call site
  3. FakeTdClient: mock для тестов без TDLib (реализует TdClientTrait)
  4. Оптимизация рендеринга: needs_redraw флаг, рендеринг только при изменениях
  5. Конфиг: TOML ~/.config/tele-tui/config.toml, credentials с приоритетом (XDG → .env)
  6. Feature-gated images: images feature flag для ratatui-image + image deps
  7. Dual renderer: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества
  8. Audio via ffplay: subprocess с SIGSTOP/SIGCONT для pause/resume, автостоп при навигации

Зависимости (основные)

ratatui = "0.29"           # TUI фреймворк
crossterm = "0.28"          # Терминальный backend
tdlib-rs = "1.1"            # Telegram TDLib binding
tokio = "1"                 # Async runtime
notify-rust = "4.11"        # Desktop уведомления (feature flag)
ratatui-image = "8.1"       # Inline images (feature flag)
image = "0.25"              # Image decoding (feature flag)

Полная структура проекта: см. PROJECT_STRUCTURE.md Подробный план: см. ROADMAP.md