fix: stabilize multi-account chat handling #28

Merged
killingdruid merged 2 commits from refactor into main 2026-05-17 01:08:52 +00:00
22 changed files with 471 additions and 203 deletions

View File

@@ -1,23 +1,30 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# powershell python python_jedi r rego
# ruby ruby_solargraph rust scala swift
# terraform toml typescript typescript_vts vue
# yaml zig
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
@@ -31,8 +38,9 @@ encoding: "utf-8"
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in all projects
# same syntax as gitignore, so you can use * and **
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
@@ -40,45 +48,9 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -87,7 +59,9 @@ initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "tele-tui"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -98,19 +72,25 @@ included_optional_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
@@ -119,3 +99,42 @@ symbol_info_budget:
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []

View File

@@ -83,6 +83,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
self.message_scroll_offset = 0;
self.last_typing_sent = None;
self.pending_chat_init = None;
self.chat_init_rx = None;
// Останавливаем фоновую загрузку фото (drop receiver)
#[cfg(feature = "images")]
{

View File

@@ -16,7 +16,7 @@ pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::types::ChatId;
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::path::PathBuf;
@@ -33,6 +33,17 @@ pub struct PendingImageOpen {
pub photo_height: i32,
}
/// Result from background chat initialization tasks.
#[derive(Debug, Clone)]
pub enum ChatInitEvent {
ReplyInfoLoaded {
chat_id: ChatId,
message_id: MessageId,
sender_name: String,
text: String,
},
}
/// State of the account switcher modal overlay.
#[derive(Debug, Clone)]
pub enum AccountSwitcherState {
@@ -149,14 +160,16 @@ pub struct App<T: TdClientTrait = TdClient> {
pub current_account_name: String,
/// Pending account switch: (account_name, db_path)
pub pending_account_switch: Option<(String, PathBuf)>,
/// Pending background chat init (reply info, pinned) after fast open
/// Pending background chat init (reply info, photos) after fast open
pub pending_chat_init: Option<ChatId>,
/// Receiver for background chat initialization results
pub chat_init_rx: Option<tokio::sync::mpsc::UnboundedReceiver<ChatInitEvent>>,
/// Receiver for background photo downloads (file_id, result path)
#[cfg(feature = "images")]
pub photo_download_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
// Voice playback
/// Аудиопроигрыватель для голосовых сообщений (rodio)
/// Аудиопроигрыватель для голосовых сообщений (ffplay)
pub audio_player: Option<crate::audio::AudioPlayer>,
/// Кэш голосовых файлов (LRU, max 100 MB)
pub voice_cache: Option<crate::audio::VoiceCache>,
@@ -223,6 +236,7 @@ impl<T: TdClientTrait> App<T> {
current_account_name: "default".to_string(),
pending_account_switch: None,
pending_chat_init: None,
chat_init_rx: None,
#[cfg(feature = "images")]
photo_download_rx: None,
#[cfg(feature = "images")]

View File

@@ -1,7 +1,7 @@
//! Audio playback module for voice messages.
//!
//! Provides:
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
//! - AudioPlayer: ffplay-based playback with play/pause/seek controls
//! - VoiceCache: LRU cache for downloaded OGG voice files
pub mod cache;

View File

@@ -23,12 +23,15 @@ pub struct AudioPlayer {
impl AudioPlayer {
/// Creates a new AudioPlayer
pub fn new() -> Result<Self, String> {
Command::new("which")
let ffplay_check = Command::new("which")
.arg("ffplay")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
if !ffplay_check.status.success() {
return Err("ffplay not found (install ffmpeg)".to_string());
}
Ok(Self {
current_pid: Arc::new(Mutex::new(None)),
@@ -48,44 +51,48 @@ impl AudioPlayer {
self.stop();
let path_owned = path.as_ref().to_path_buf();
*self.current_path.lock().unwrap() = Some(path_owned.clone());
*self.starting.lock().unwrap() = true;
let current_pid = self.current_pid.clone();
let paused = self.paused.clone();
let starting = self.starting.clone();
std::thread::spawn(move || {
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel")
.arg("quiet");
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
}
let mut child = match cmd
.arg(&path_owned)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => {
*self.starting.lock().unwrap() = false;
return Err(format!("failed to start ffplay: {}", e));
}
};
if let Ok(mut child) = cmd
.arg(&path_owned)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
let pid = child.id();
*current_pid.lock().unwrap() = Some(pid);
let pid = child.id();
*self.current_path.lock().unwrap() = Some(path_owned);
*current_pid.lock().unwrap() = Some(pid);
*paused.lock().unwrap() = false;
*starting.lock().unwrap() = false;
std::thread::spawn(move || {
let _ = child.wait();
// Обнуляем только если это наш pid (новый play мог уже заменить его)
let mut pid_guard = current_pid.lock().unwrap();
if *pid_guard == Some(pid) {
*pid_guard = None;
*paused.lock().unwrap() = false;
*starting.lock().unwrap() = false;
let _ = child.wait();
// Обнуляем только если это наш pid (новый play мог уже заменить его)
let mut pid_guard = current_pid.lock().unwrap();
if *pid_guard == Some(pid) {
*pid_guard = None;
*paused.lock().unwrap() = false;
}
} else {
*starting.lock().unwrap() = false;
}
});

View File

@@ -71,6 +71,49 @@ pub enum Command {
OpenProfile,
}
const COMMAND_LOOKUP_ORDER: &[Command] = &[
Command::Quit,
Command::Cancel,
Command::SubmitMessage,
Command::OpenSearch,
Command::OpenSearchInChat,
Command::OpenProfile,
Command::Help,
Command::MoveUp,
Command::MoveDown,
Command::MoveLeft,
Command::MoveRight,
Command::PageUp,
Command::PageDown,
Command::SelectFolder1,
Command::SelectFolder2,
Command::SelectFolder3,
Command::SelectFolder4,
Command::SelectFolder5,
Command::SelectFolder6,
Command::SelectFolder7,
Command::SelectFolder8,
Command::SelectFolder9,
Command::OpenChat,
Command::EditMessage,
Command::DeleteMessage,
Command::ReplyMessage,
Command::ForwardMessage,
Command::CopyMessage,
Command::ReactMessage,
Command::SelectMessage,
Command::ViewImage,
Command::TogglePlayback,
Command::SeekForward,
Command::SeekBackward,
Command::NewLine,
Command::DeleteChar,
Command::DeleteWord,
Command::MoveToStart,
Command::MoveToEnd,
Command::EnterInsertMode,
];
/// Привязка клавиши к команде
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyBinding {
@@ -114,13 +157,33 @@ pub struct Keybindings {
impl Keybindings {
/// Ищет команду по клавише
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
for (command, bindings) in &self.bindings {
if bindings.iter().any(|binding| binding.matches(event)) {
for command in COMMAND_LOOKUP_ORDER {
if self
.bindings
.get(command)
.is_some_and(|bindings| bindings.iter().any(|binding| binding.matches(event)))
{
return Some(*command);
}
}
None
}
#[cfg(test)]
fn duplicate_bindings(&self) -> Vec<(KeyBinding, Vec<Command>)> {
let mut by_key: HashMap<KeyBinding, Vec<Command>> = HashMap::new();
for command in COMMAND_LOOKUP_ORDER {
if let Some(bindings) = self.bindings.get(command) {
for binding in bindings {
by_key.entry(binding.clone()).or_default().push(*command);
}
}
}
by_key
.into_iter()
.filter(|(_, commands)| commands.len() > 1)
.collect()
}
}
impl Default for Keybindings {
@@ -133,7 +196,7 @@ impl Default for Keybindings {
vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
KeyBinding::new(KeyCode::Char('л')), // RU
],
);
bindings.insert(
@@ -149,7 +212,7 @@ impl Default for Keybindings {
vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
KeyBinding::new(KeyCode::Char('р')), // RU
],
);
bindings.insert(
@@ -264,8 +327,9 @@ impl Default for Keybindings {
// Voice playback
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]);
bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]);
// Left/Right are MoveLeft/MoveRight globally; message selection treats them as voice seek.
bindings.insert(Command::SeekForward, vec![]);
bindings.insert(Command::SeekBackward, vec![]);
// Input
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
@@ -279,13 +343,7 @@ impl Default for Keybindings {
KeyBinding::with_ctrl(KeyCode::Char('w')),
],
);
bindings.insert(
Command::MoveToStart,
vec![
KeyBinding::new(KeyCode::Home),
KeyBinding::with_ctrl(KeyCode::Char('a')),
],
);
bindings.insert(Command::MoveToStart, vec![KeyBinding::new(KeyCode::Home)]);
bindings.insert(
Command::MoveToEnd,
vec![
@@ -307,8 +365,8 @@ impl Default for Keybindings {
bindings.insert(
Command::OpenProfile,
vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
KeyBinding::with_ctrl(KeyCode::Char('i')),
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
],
);
@@ -462,7 +520,8 @@ mod tests {
// Проверяем навигацию
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('л'))), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveLeft));
}
#[test]
@@ -485,4 +544,11 @@ mod tests {
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
}
#[test]
fn test_default_bindings_have_no_conflicts() {
let kb = Keybindings::default();
let duplicates = kb.duplicate_bindings();
assert!(duplicates.is_empty(), "duplicate default keybindings: {:?}", duplicates);
}
}

View File

@@ -379,7 +379,7 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
}
KeyCode::Char(c) => {
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
// Это позволяет обрабатывать хоткеи типа Ctrl+I для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
@@ -602,14 +602,11 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
let client_id = app.td_client.client_id();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(30), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
.await
{
Ok(tdlib_rs::enums::File::File(f))
if f.local.is_downloading_completed
&& !f.local.path.is_empty() =>
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
{
Ok(f.local.path)
}
@@ -618,8 +615,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
}
})
.await;
let result =
result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
let _ = tx.send((file_id, result));
});
}

View File

@@ -1,22 +1,24 @@
//! Chat loading logic — all three phases of message loading
//!
//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages)
//! - Phase 2: `process_pending_chat_init` — background tasks (reply info, pinned, photos)
//! - Phase 2: `process_pending_chat_init` — starts background tasks (reply info, photos)
//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::app::{App, ChatInitEvent};
use crate::tdlib::message_conversion::{extract_content_text, extract_sender_name};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_ignore, with_timeout_msg};
use crate::utils::{with_timeout, with_timeout_msg};
use std::time::Duration;
use tokio::sync::mpsc::error::TryRecvError;
/// Открывает чат и загружает последние сообщения (быстро).
///
/// Загружает только 50 последних сообщений для мгновенного отображения.
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
/// и выполняются на следующем тике main loop.
/// Фоновые задачи (reply info, photos) откладываются в `pending_chat_init`
/// и стартуют после первого redraw.
///
/// При ошибке устанавливает error_message и очищает status_message.
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
@@ -62,7 +64,7 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
app.input_mode = InputMode::Normal;
app.start_message_selection();
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
// Фоновые задачи (reply info, photos) — после первого redraw
app.pending_chat_init = Some(ChatId::new(chat_id));
}
Err(e) => {
@@ -72,21 +74,71 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
}
}
/// Выполняет фоновую инициализацию после открытия чата.
/// Запускает фоновую инициализацию после открытия чата.
///
/// Вызывается на следующем тике main loop после `open_chat_and_load_data`.
/// Загружает reply info, закреплённое сообщение и начинает авто-загрузку фото.
pub async fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
// Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
.await;
/// Вызывается после первого redraw после `open_chat_and_load_data`.
/// Не блокирует UI loop: TDLib запросы выполняются в отдельных Tokio tasks,
/// а готовые результаты применяются через `process_chat_init_events`.
pub fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
app.chat_init_rx = None;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
with_timeout_ignore(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
)
.await;
let mut reply_message_ids: Vec<MessageId> = app
.td_client
.current_chat_messages()
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
reply_message_ids.sort_unstable();
reply_message_ids.dedup();
if !reply_message_ids.is_empty() {
let client_id = app.td_client.client_id();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ChatInitEvent>();
app.chat_init_rx = Some(rx);
for message_id in reply_message_ids {
let tx = tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(5), async {
let Ok(original_msg_enum) = tdlib_rs::functions::get_message(
chat_id.as_i64(),
message_id.as_i64(),
client_id,
)
.await
else {
return None;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let sender_name = extract_sender_name(&original_msg, client_id).await;
let text: String = extract_content_text(&original_msg)
.chars()
.take(50)
.collect();
Some((sender_name, text))
})
.await
.ok()
.flatten();
if let Some((sender_name, text)) = result {
let _ = tx.send(ChatInitEvent::ReplyInfoLoaded {
chat_id,
message_id,
sender_name,
text,
});
}
});
}
}
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
#[cfg(feature = "images")]
@@ -145,8 +197,55 @@ pub async fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_
}
}
}
}
app.needs_redraw = true;
/// Применяет готовые результаты фоновой инициализации чата.
pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
let mut events = Vec::new();
let mut disconnected = false;
if let Some(rx) = app.chat_init_rx.as_mut() {
loop {
match rx.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
if disconnected {
app.chat_init_rx = None;
}
for event in events {
match event {
ChatInitEvent::ReplyInfoLoaded { chat_id, message_id, sender_name, text } => {
if app.td_client.current_chat_id() != Some(chat_id) {
continue;
}
let mut changed = false;
for msg in app.td_client.current_chat_messages_mut() {
let Some(reply) = msg.interactions.reply_to.as_mut() else {
continue;
};
if reply.message_id == message_id {
reply.sender_name = sender_name.clone();
reply.text = text.clone();
changed = true;
}
}
if changed {
app.needs_redraw = true;
}
}
}
}
}
/// Подгружает старые сообщения если скролл близко к верху

View File

@@ -21,7 +21,10 @@ pub mod modal;
pub mod profile;
pub mod search;
pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init};
pub use chat_loader::{
load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events,
process_pending_chat_init,
};
pub use clipboard::*;
pub use global::*;
pub use profile::get_available_actions_count;

View File

@@ -231,7 +231,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(
}
}
/// Обработка Ctrl+U для открытия профиля чата/пользователя
/// Обработка Ctrl+I для открытия профиля чата/пользователя
///
/// Загружает информацию о профиле и переключает в режим просмотра профиля
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {

View File

@@ -28,8 +28,8 @@ use tdlib_rs::enums::Update;
use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::handlers::{process_chat_init_events, process_pending_chat_init};
use input::{handle_auth_input, handle_main_input};
use input::handlers::process_pending_chat_init;
use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore};
@@ -84,7 +84,9 @@ async fn main() -> Result<(), io::Error> {
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
let account_lock = accounts::acquire_lock(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
)
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
@@ -166,16 +168,18 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Канал для передачи updates из polling задачи в main loop
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
// Канал для передачи updates из polling задачи в main loop.
// client_id нужен при переключении аккаунтов: TDLib может ещё отдать
// updates от старого клиента после recreate_client().
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<(i32, Update)>();
// Запускаем polling TDLib receive() в отдельной задаче
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() {
if let Ok(Some((update, client_id))) = result {
if update_tx.send((client_id, update)).is_err() {
break; // Канал закрыт, выходим
}
}
@@ -185,9 +189,18 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
loop {
// Обрабатываем updates от TDLib из канала (неблокирующе)
let mut had_updates = false;
while let Ok(update) = update_rx.try_recv() {
app.td_client.handle_update(update);
had_updates = true;
let active_client_id = app.td_client.client_id();
while let Ok((client_id, update)) = update_rx.try_recv() {
if client_id == active_client_id {
app.td_client.handle_update(update);
had_updates = true;
} else {
tracing::debug!(
"Ignoring TDLib update for inactive client_id={} (active={})",
client_id,
active_client_id
);
}
}
// Помечаем UI как требующий перерисовки если были обновления
@@ -195,6 +208,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.needs_redraw = true;
}
process_chat_init_events(app);
// Обрабатываем результаты фоновой загрузки фото
#[cfg(feature = "images")]
{
@@ -224,11 +239,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
.unwrap_or(false);
if pending_matches {
// Ищем путь из обновлённого состояния
let downloaded_path = app
.td_client
.current_chat_messages()
.iter()
.find_map(|m| {
let downloaded_path =
app.td_client.current_chat_messages().iter().find_map(|m| {
m.photo_info().and_then(|p| {
if p.file_id == file_id {
if let PhotoDownloadState::Downloaded(ref path) =
@@ -385,9 +397,13 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
}
}
// Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() {
process_pending_chat_init(app, chat_id).await;
// Process pending chat initialization only after pending redraw is flushed.
// This guarantees the initial 50-message chat view is rendered before slower
// reply/photo initialization tasks start.
if !app.needs_redraw {
if let Some(chat_id) = app.pending_chat_init.take() {
process_pending_chat_init(app, chat_id);
}
}
// Check pending account switch
@@ -410,17 +426,20 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// 1. Stop playback
app.stop_playback();
// 2. Recreate client (closes old, creates new, inits TDLib params)
// 2. Drop queued updates from the old client before recreating TDLib.
while update_rx.try_recv().is_ok() {}
// 3. Recreate client (closes old, creates new, inits TDLib params)
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
app.error_message = Some(format!("Ошибка переключения: {}", e));
continue;
}
// 3. Reset app state
// 4. Reset app state
app.current_account_name = account_name.clone();
app.screen = AppScreen::Loading;
// 4. Persist selected account as default for next launch
// 5. Persist selected account as default for next launch
let mut accounts_config = accounts::load_or_create();
accounts_config.default_account = account_name;
if let Err(e) = accounts::save(&accounts_config) {
@@ -438,6 +457,12 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.cursor_position = 0;
app.message_scroll_offset = 0;
app.pending_chat_init = None;
app.chat_init_rx = None;
#[cfg(feature = "images")]
{
app.photo_download_rx = None;
app.pending_image_open = None;
}
app.account_switcher = None;
app.needs_redraw = true;

View File

@@ -8,7 +8,7 @@ use crate::utils::get_day;
/// Элемент группированного списка сообщений
#[derive(Debug, Clone)]
pub enum MessageGroup {
pub enum MessageGroup<'a> {
/// Разделитель даты (день в формате timestamp)
DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name)
@@ -17,9 +17,9 @@ pub enum MessageGroup {
sender_name: String,
},
/// Сообщение
Message(Box<MessageInfo>),
Message(&'a MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
Album(Vec<&'a MessageInfo>),
}
/// Группирует сообщения по дате и отправителю
@@ -63,14 +63,14 @@ pub enum MessageGroup {
/// }
/// }
/// ```
pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup<'_>> {
let mut result = Vec::new();
let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
let mut album_acc: Vec<MessageInfo> = Vec::new();
let mut album_acc: Vec<&MessageInfo> = Vec::new();
/// Сбрасывает аккумулятор альбома в результат
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
fn flush_album<'a>(acc: &mut Vec<&'a MessageInfo>, result: &mut Vec<MessageGroup<'a>>) {
if acc.is_empty() {
return;
}
@@ -78,7 +78,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
result.push(MessageGroup::Album(std::mem::take(acc)));
} else {
// Одно сообщение — не альбом
result.push(MessageGroup::Message(Box::new(acc.remove(0))));
result.push(MessageGroup::Message(acc.remove(0)));
}
}
@@ -120,24 +120,24 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
if let Some(first) = album_acc.first() {
if first.media_album_id() == album_id {
// Тот же альбом — добавляем
album_acc.push(msg.clone());
album_acc.push(msg);
continue;
} else {
// Другой альбом — flush старый, начинаем новый
flush_album(&mut album_acc, &mut result);
album_acc.push(msg.clone());
album_acc.push(msg);
continue;
}
} else {
// Аккумулятор пуст — начинаем новый альбом
album_acc.push(msg.clone());
album_acc.push(msg);
continue;
}
}
// Обычное сообщение (не альбом) — flush аккумулятор
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::Message(Box::new(msg.clone())));
result.push(MessageGroup::Message(msg));
}
// Flush оставшийся аккумулятор

View File

@@ -238,13 +238,24 @@ impl TdClient {
self.chat_manager.clear_stale_typing_status()
}
fn last_read_outbox_message_id(&self, chat_id: ChatId) -> MessageId {
self.chats()
.iter()
.find(|chat| chat.id == chat_id)
.map(|chat| chat.last_read_outbox_message_id)
.unwrap_or(MessageId::new(0))
}
// Делегирование к message_manager
pub async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_chat_history(chat_id, limit).await
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.get_chat_history(chat_id, limit, last_read_outbox_message_id)
.await
}
pub async fn load_older_messages(
@@ -252,8 +263,9 @@ impl TdClient {
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.load_older_messages(chat_id, from_message_id)
.load_older_messages(chat_id, from_message_id, last_read_outbox_message_id)
.await
}
@@ -261,7 +273,10 @@ impl TdClient {
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_pinned_messages(chat_id).await
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.get_pinned_messages(chat_id, last_read_outbox_message_id)
.await
}
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
@@ -275,7 +290,10 @@ impl TdClient {
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.search_messages(chat_id, query).await
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.search_messages(chat_id, query, last_read_outbox_message_id)
.await
}
pub async fn send_message(
@@ -285,8 +303,15 @@ impl TdClient {
reply_to_message_id: Option<MessageId>,
reply_info: Option<super::types::ReplyInfo>,
) -> Result<MessageInfo, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.send_message(chat_id, text, reply_to_message_id, reply_info)
.send_message(
chat_id,
text,
reply_to_message_id,
reply_info,
last_read_outbox_message_id,
)
.await
}
@@ -296,8 +321,9 @@ impl TdClient {
message_id: MessageId,
text: String,
) -> Result<MessageInfo, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.edit_message(chat_id, message_id, text)
.edit_message(chat_id, message_id, text, last_read_outbox_message_id)
.await
}

View File

@@ -94,9 +94,7 @@ impl TdClientTrait for TdClient {
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
self.message_manager
.send_message(chat_id, text, reply_to_message_id, reply_info)
.await
TdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
}
async fn edit_message(
@@ -105,9 +103,7 @@ impl TdClientTrait for TdClient {
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
self.message_manager
.edit_message(chat_id, message_id, new_text)
.await
TdClient::edit_message(self, chat_id, message_id, new_text).await
}
async fn delete_messages(

View File

@@ -10,7 +10,11 @@ use super::MessageManager;
impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
pub(crate) async fn convert_message(
&self,
msg: &TdMessage,
last_read_outbox_message_id: MessageId,
) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_reactions, extract_reply_info, extract_sender_name,
@@ -39,7 +43,8 @@ impl MessageManager {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
let is_read = !msg.is_outgoing || msg.id <= last_read_outbox_message_id.as_i64();
if is_read {
builder = builder.read();
} else {
builder = builder.unread();
@@ -117,7 +122,7 @@ impl MessageManager {
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
let Some(orig_info) = self.convert_message(&original_msg, MessageId::new(0)).await else {
return;
};

View File

@@ -41,6 +41,7 @@ impl MessageManager {
&mut self,
chat_id: ChatId,
limit: i32,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
// ВАЖНО: Сначала открываем чат в TDLib
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
@@ -119,7 +120,8 @@ impl MessageManager {
// Конвертируем сообщения (от новых к старым, потом реверсим)
let mut chunk_messages = Vec::new();
for msg in messages_obj.messages.iter().flatten() {
if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
chunk_messages.push(info);
}
}
@@ -192,6 +194,7 @@ impl MessageManager {
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::get_chat_history(
chat_id.as_i64(),
@@ -207,7 +210,8 @@ impl MessageManager {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new();
for msg in messages_obj.messages.iter().rev().flatten() {
if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
messages.push(info);
}
}
@@ -239,6 +243,7 @@ impl MessageManager {
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
@@ -258,7 +263,8 @@ impl MessageManager {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut pinned_messages = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
pinned_messages.push(info);
}
}
@@ -307,6 +313,7 @@ impl MessageManager {
&self,
chat_id: ChatId,
query: &str,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
@@ -326,7 +333,8 @@ impl MessageManager {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut search_results = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
search_results.push(info);
}
}
@@ -377,6 +385,7 @@ impl MessageManager {
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
last_read_outbox_message_id: MessageId,
) -> Result<MessageInfo, String> {
// Парсим markdown в тексте
let formatted_text = match functions::parse_text_entities(
@@ -419,7 +428,7 @@ impl MessageManager {
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => {
let mut msg_info = self
.convert_message(&msg)
.convert_message(&msg, last_read_outbox_message_id)
.await
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
@@ -451,6 +460,7 @@ impl MessageManager {
chat_id: ChatId,
message_id: MessageId,
text: String,
last_read_outbox_message_id: MessageId,
) -> Result<MessageInfo, String> {
let formatted_text = match functions::parse_text_entities(
text.clone(),
@@ -481,7 +491,7 @@ impl MessageManager {
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
.convert_message(&msg)
.convert_message(&msg, last_read_outbox_message_id)
.await
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),

View File

@@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
mod message_conversion; // Message conversion utilities (for messages.rs)
pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages;
pub mod reactions;

View File

@@ -531,7 +531,7 @@ pub struct DeferredImageRender {
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
#[cfg(feature = "images")]
pub fn render_album_bubble(
messages: &[MessageInfo],
messages: &[&MessageInfo],
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
@@ -550,7 +550,7 @@ pub fn render_album_bubble(
let selection_marker = if is_selected { "" } else { " " };
// Фильтруем фото
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect();
let photo_count = photos.len();
if photo_count == 0 {

View File

@@ -187,7 +187,8 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
// Используем message_grouping для группировки сообщений
let grouped = group_messages(&app.td_client.current_chat_messages());
let current_messages = app.td_client.current_chat_messages();
let grouped = group_messages(&current_messages);
let mut is_first_date = true;
let mut is_first_sender = true;
@@ -218,7 +219,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
// Рендерим сообщение
let bubble_lines = components::render_message_bubble(
&msg,
msg,
app.config(),
content_width,
selected_msg_id,

View File

@@ -83,7 +83,7 @@ async fn test_vim_navigation_in_chat_list() {
assert_eq!(app.chat_list_state.selected(), Some(0));
}
/// Test: Русские клавиши о/р для навигации
/// Test: Русские клавиши о/л для навигации
#[tokio::test]
async fn test_russian_keyboard_navigation() {
let mut app = TestAppBuilder::new()
@@ -99,8 +99,8 @@ async fn test_russian_keyboard_navigation() {
handle_main_input(&mut app, key(KeyCode::Char('о'))).await;
assert_eq!(app.chat_list_state.selected(), Some(1));
// р (русская k) - вверх
handle_main_input(&mut app, key(KeyCode::Char('р'))).await;
// л (русская k) - вверх
handle_main_input(&mut app, key(KeyCode::Char('л'))).await;
assert_eq!(app.chat_list_state.selected(), Some(0));
}

View File

@@ -25,4 +25,4 @@ expression: output
┌──────────────────────┐│ │
│ ││ │
└──────────────────────┘└──────────────────────────────────────────────────────┘
Инициализация TDLib...
[default] Инициализация TDLib...

View File

@@ -435,9 +435,9 @@ async fn test_ctrl_w_deletes_word_with_spaces() {
assert_eq!(app.cursor_position, 9);
}
/// Ctrl+A → курсор в начало в Insert mode
/// Home → курсор в начало в Insert mode
#[tokio::test]
async fn test_ctrl_a_moves_to_start_in_insert() {
async fn test_home_moves_to_start_in_insert() {
let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101)
@@ -446,7 +446,7 @@ async fn test_ctrl_a_moves_to_start_in_insert() {
.build();
app.cursor_position = 11;
handle_main_input(&mut app, ctrl_key('a')).await;
handle_main_input(&mut app, key(KeyCode::Home)).await;
assert_eq!(app.cursor_position, 0);
}