refactoring

This commit is contained in:
2026-10-31 01:06:21 +03:00
parent 27385a3edb
commit 19e05e7ed0
17 changed files with 2673 additions and 671 deletions
+292 -90
View File
@@ -13,14 +13,18 @@ use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons;
use crate::keymap::{KeyBinding, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState};
use crate::tree::{TreeHandle, TreeNode};
@@ -88,6 +92,16 @@ pub struct DirEntryInfo {
pub is_dir: bool,
}
/// Зарегистрированный Lua-обработчик диагностики.
///
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
#[derive(Clone)]
struct DiagnosticsHandler {
delay: Duration,
callback: mlua::Function,
}
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
@@ -111,6 +125,8 @@ pub struct AppApi {
bottom_panel: PanelHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
}
/// API одной панели окна.
@@ -183,6 +199,16 @@ impl TreeApi {
self.state.borrow_mut().remove(&id)
}
/// Удаляет все узлы дерева и сбрасывает выбор.
///
/// Обработчики `on_toggle` и `on_click` продолжают действовать: дерево
/// можно наполнить заново. Удобно для списков, которые перерисовываются
/// целиком, например для диагностики.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
/// Назначает обработчик раскрытия/сворачивания узла.
///
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
@@ -246,13 +272,18 @@ impl PanelApi {
/// Добавляет в панель блок дерева и возвращает его API.
///
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
/// сохраняют, но не рисуют.
///
/// ```lua
/// local tree = app.left_panel:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "")
///
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
/// ```
#[lua(infallible, name = "add_tree")]
pub fn add_tree(&self) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree())
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree(title))
}
}
@@ -269,6 +300,7 @@ impl AppApi {
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
@@ -324,10 +356,7 @@ impl AppApi {
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
icon: doc
.icon
.clone()
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
icon: doc.icon_name().to_owned(),
})
.collect()
}
@@ -345,10 +374,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn next_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut();
let next = (workspace.active_index() + 1) % workspace.documents().len();
workspace.set_active(next);
next
self.workspace.borrow_mut().cycle_active(1)
}
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
@@ -356,11 +382,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn previous_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut();
let count = workspace.documents().len();
let previous = (workspace.active_index() + count - 1) % count;
workspace.set_active(previous);
previous
self.workspace.borrow_mut().cycle_active(-1)
}
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
@@ -414,17 +436,41 @@ impl AppApi {
/// `keys` записывается в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt;
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Повторное назначение той же клавиши заменяет обработчик.
/// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки некорректна.
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> {
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?;
self.keymap.borrow_mut().bind(binding, callback);
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Global, &keys, handler)
}
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Completion, &keys, handler)
}
#[lua(skip)]
fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> {
let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?;
match parse_key_handler(context, handler)? {
Some(handler) => self.keymap.borrow_mut().set(context, binding, handler),
None => {
self.keymap.borrow_mut().unbind(context, &binding);
}
}
Ok(())
}
@@ -458,8 +504,34 @@ impl AppApi {
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
/// и Lua-форма `app:open(path, {...})`.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
self.open_impl(path, None, false)
}
/// Открывает файл и переводит фокус в редактор (Rust-версия
/// `app:open(path, position)`).
///
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
/// считается в UTF-16. `None` оставляет курсор там, где он был.
#[lua(skip)]
pub fn open_focused(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
) -> std::io::Result<usize> {
self.open_impl(path, position, true)
}
#[lua(skip)]
fn open_impl(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
focus_editor: bool,
) -> std::io::Result<usize> {
let path = path.as_ref();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
@@ -467,19 +539,39 @@ impl AppApi {
Err(err) => return Err(err),
};
let cursor = position.map(|(line, column)| {
position_to_offset(&content, lsp_types::Position::new(line, column))
});
let focus = if focus_editor {
FocusAfterSync::Editor(cursor)
} else {
FocusAfterSync::Keep
};
let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut();
Ok(workspace.open_file(name, content, language))
let index = workspace.open_file(name, content, language);
workspace.set_focus_after_sync(focus);
Ok(index)
}
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
///
/// Второй аргумент необязателен:
///
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String) -> mlua::Result<usize> {
self.open(&path)
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
match parse_open_focus(position)? {
OpenFocus::Keep => self.open(&path),
OpenFocus::Editor(position) => self.open_focused(&path, position),
}
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
@@ -490,7 +582,7 @@ impl AppApi {
#[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let root = raw.is_empty() || raw.as_str() == ".";
let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
@@ -502,7 +594,7 @@ impl AppApi {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if root {
let id = if relative_to_cwd {
name.clone()
} else {
Path::new(&raw)
@@ -583,14 +675,10 @@ impl AppApi {
/// `file` по умолчанию. `nil`, если вкладки нет.
#[lua(infallible)]
pub fn tab_icon(&self, index: usize) -> Option<String> {
let workspace = self.workspace.borrow();
workspace.document(index)?;
Some(
workspace
.tab_icon(index)
.unwrap_or(icons::DEFAULT_TAB_ICON)
.to_owned(),
)
self.workspace
.borrow()
.document(index)
.map(|doc| doc.icon_name().to_owned())
}
/// Включает подсветку синтаксиса для вкладки `index`.
@@ -746,6 +834,108 @@ impl AppApi {
pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name)
}
/// Регистрирует обработчик диагностики всех LSP-серверов.
///
/// `delay_ms` — пауза тишины в миллисекундах: обработчик вызывается после
/// последнего обновления диагностики. Если серверы присылают сообщения
/// чаще задержки, промежуточные вызовы пропускаются, а последний вызов
/// выполняется после того, как обновления прекратились.
///
/// Обработчик получает один аргумент — массив записей
/// `{ server, uri, path, diagnostics = { ... } }`. У сообщения есть
/// `line`, `character`, `end_line`, `end_character` (с нуля, как в LSP),
/// `severity` (1–4), `severity_name`, `message`, `source`, `code`, `tags`.
/// Передача `nil` вместо функции удаляет обработчик.
///
/// ```lua
/// app:on_lsp_diagnostics(1000, function(servers)
/// for _, entry in ipairs(servers) do
/// print(entry.server, entry.path, #entry.diagnostics)
/// end
/// end)
/// ```
#[lua(infallible, name = "on_lsp_diagnostics")]
pub fn on_lsp_diagnostics(&self, delay_ms: u64, callback: Option<mlua::Function>) {
let delay = Duration::from_millis(delay_ms);
*self.diagnostics_handler.borrow_mut() =
callback.map(|callback| DiagnosticsHandler { delay, callback });
// Обработчик должен получить уже накопленную диагностику, а не ждать
// следующего ответа LSP-сервера.
self.lsp.refresh_diagnostics();
}
/// Задержка зарегистрированного обработчика диагностики, если он есть.
#[lua(skip)]
pub fn diagnostics_delay(&self) -> Option<Duration> {
self.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.delay)
}
/// Вызывает Lua-обработчик диагностики со свежим снимком.
///
/// Возвращает `false`, если обработчик не зарегистрирован.
#[lua(skip)]
pub fn emit_diagnostics(&self, report: Vec<DocumentDiagnostics>) -> bool {
let Some(callback) = self
.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.callback.clone())
else {
return false;
};
let Some(lua) = callback.weak_lua().try_upgrade() else {
return false;
};
let value = match report.into_lua(&lua) {
Ok(value) => value,
Err(err) => {
eprintln!("Lua: не удалось передать диагностику: {err}");
return false;
}
};
if let Err(err) = callback.call::<()>(value) {
eprintln!("Lua: ошибка в обработчике диагностики: {err}");
}
true
}
}
/// Второй аргумент `app:open`.
enum OpenFocus {
/// Фокус не меняется.
Keep,
/// Фокус переходит в редактор; `None` сохраняет текущий курсор.
Editor(Option<(u32, u32)>),
}
/// Разбирает необязательный второй аргумент `app:open`.
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
let table = match value {
mlua::Value::Nil => return Ok(OpenFocus::Keep),
mlua::Value::Table(table) => table,
_ => {
return Err(mlua::Error::RuntimeError(
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
));
}
};
let line = table.get::<Option<i64>>(1)?;
let column = table.get::<Option<i64>>(2)?;
match (line, column) {
(None, None) => Ok(OpenFocus::Editor(None)),
(Some(line), Some(column)) if line >= 0 && column >= 0 => {
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
}
_ => Err(mlua::Error::RuntimeError(
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
)),
}
}
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
@@ -754,14 +944,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
.get("name")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
let command = ["cmd", "command", "path", "binary"]
.iter()
.find_map(|key| table.get::<String>(*key).ok())
.ok_or_else(|| {
mlua::Error::RuntimeError(
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
)
})?;
let command: String = table
.get("cmd")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".to_owned()))?;
let args = match table.get::<mlua::Table>("args") {
Ok(args) => args
@@ -777,32 +962,22 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
Err(_) => Vec::new(),
};
let root = table
.get::<String>("root")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let root = if root.is_absolute() {
root
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(root)
// Корень нормализуется в `LspManager::start`: относительный путь там
// отсчитывается от текущего каталога процесса.
let root = match table.get::<String>("root") {
Ok(root) => PathBuf::from(root),
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let root = std::fs::canonicalize(&root).unwrap_or(root);
let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(),
};
let initialization_options = ["initialization_options", "init_options"]
.iter()
.find_map(|key| match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)),
})
.transpose()?;
let initialization_options = match table.get::<mlua::Value>("initialization_options") {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)?),
};
Ok(LspConfig {
name,
@@ -817,15 +992,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
}
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(value),
}
});
let Some(value) = value else {
return Ok(None);
let value = match table.get::<mlua::Value>("watcher") {
Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
Ok(value) => value,
};
match value {
@@ -839,22 +1008,14 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
})),
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
mlua::Value::Table(config) => {
let patterns = ["pattern", "patterns"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let ignore = ["ignore", "ignored"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let patterns = match config.get::<mlua::Value>("pattern") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
let ignore = match config.get::<mlua::Value>("ignore") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
if patterns.is_empty() {
Ok(None)
@@ -868,6 +1029,47 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
}
}
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
///
/// `nil` означает «удалить привязку».
fn parse_key_handler(
context: KeyContext,
handler: mlua::Value,
) -> mlua::Result<Option<KeyHandler>> {
match handler {
mlua::Value::Nil => Ok(None),
mlua::Value::Function(callback) => Ok(Some(KeyHandler::Lua(callback))),
mlua::Value::String(name) => {
let name = name.to_string_lossy();
let parsed = match context {
KeyContext::Global => AppCommand::parse(&name).map(KeyHandler::App),
KeyContext::Completion => {
CompletionCommand::parse(&name).map(KeyHandler::Completion)
}
};
parsed.map(Some).ok_or_else(|| {
let available = match context {
KeyContext::Global => AppCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
KeyContext::Completion => CompletionCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
};
mlua::Error::RuntimeError(format!(
"неизвестная команда клавиши: {name}; доступны: {}",
available.join(", ")
))
})
}
_ => Err(mlua::Error::RuntimeError(
"обработчик должен быть функцией, именем команды или nil".to_owned(),
)),
}
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();