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
+400 -28
View File
@@ -5,14 +5,16 @@
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp;
use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
use crate::panel::PanelBlock;
use crate::script::ScriptEngine;
use crate::tree::TreeNode;
@@ -55,17 +57,17 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
#[test]
fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default();
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active().name, "main.rs");
assert_eq!(state.documents().len(), 1);
assert_eq!(state.active().name, "untitled-1.txt");
// Открытие и закрытие новых вкладок.
state.open_new();
assert_eq!(state.documents().len(), 3);
assert_eq!(state.active_index(), 2);
state.close(2);
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active_index(), 1);
state.close(1);
assert_eq!(state.active_index(), 0);
// Редактор не должен оставаться пустым.
while state.documents().len() > 1 {
state.close(0);
@@ -83,7 +85,7 @@ fn closing_tab_before_active_keeps_the_same_document_active() {
state.close(0);
assert_eq!(state.active().id, id);
assert_eq!(state.active_index(), 1);
assert_eq!(state.active_index(), 0);
}
#[test]
@@ -112,6 +114,8 @@ fn revision_changes_only_for_external_edits() {
assert_ne!(state.revision(), revision);
// Переключение вкладки — тоже.
state.open_new();
state.set_active(0);
let before_switch = state.revision();
state.set_active(1);
assert_ne!(state.revision(), before_switch);
@@ -127,13 +131,15 @@ fn api_lists_tabs_and_closes_them_by_number() {
tabs.iter()
.map(|tab| (tab.index, tab.title.as_str()))
.collect::<Vec<_>>(),
vec![(0, "main.rs"), (1, "notes.md")]
vec![(0, "untitled-1.txt")]
);
// Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 2);
assert_eq!(api.tabs().len(), 1);
api.open_tab();
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
assert_eq!(workspace.borrow().documents().len(), 1);
@@ -180,6 +186,7 @@ fn lua_script_can_set_tab_icons() {
assert(app:tab_icon(0) == "file")
assert(app:set_tab_icon(0, "book-open"))
assert(app:tab_icon(0) == "book-open")
app:open_tab()
assert(app:tabs()[2].icon == "file")
assert(not app:set_tab_icon(0, "no-such-icon"))
assert(app:clear_tab_icon(0))
@@ -200,7 +207,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
let workspace = Rc::new(RefCell::new(Workspace::default()));
let path = std::env::temp_dir().join(format!(
"gpui-editor-test-save-{}-{}.rs",
"qode-save-{}-{}.rs",
std::process::id(),
workspace.borrow().active().id
));
@@ -231,6 +238,8 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
api.open_tab();
api.set_active_tab(0);
assert_eq!(api.active_tab_index(), 0);
assert_eq!(api.next_tab(), 1);
@@ -392,7 +401,8 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let panel = api.left_panel_handle();
let panel = panel.borrow();
let PanelBlock::Tree(tree) = &panel.blocks()[0];
let PanelBlock::Tree { tree, title } = &panel.blocks()[0];
assert_eq!(title, "Дерево");
let rows = tree.borrow().rows();
assert_eq!(
rows.iter()
@@ -414,6 +424,124 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let bottom = api.bottom_panel_handle();
let dir = temp_script_dir("bottom-tree");
let script = dir.join("bottom.lua");
std::fs::write(
&script,
r#"
diag_tree = app.bottom_panel:add_tree("Диагностика")
diag_tree:append({
{ id = "a", label = "a", expandable = true, expanded = true },
}, "")
diag_tree:append({
{ id = "a:1", label = "ошибка" },
}, "a")
"#,
)
.expect("bottom.lua");
let clear = dir.join("clear.lua");
std::fs::write(&clear, "diag_tree:clear()\n").expect("clear.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("блок нижней панели");
{
let panel = bottom.borrow();
assert_eq!(panel.blocks().len(), 1);
assert_eq!(panel.active(), 0);
assert_eq!(panel.blocks()[0].title(), "Диагностика");
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert_eq!(tree.borrow().rows().len(), 2);
}
engine.run_file(&clear).expect("tree:clear");
let panel = bottom.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert!(tree.borrow().rows().is_empty());
drop(panel);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_diagnostics_handler_receives_every_server_and_file() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("diagnostics-handler");
let script = dir.join("diagnostics.lua");
std::fs::write(
&script,
r#"
app:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers)
local entry = servers[1]
assert(entry.server == "test")
assert(entry.path ~= nil)
assert(#entry.diagnostics == 2, #entry.diagnostics)
local first = entry.diagnostics[1]
assert(first.line == 2)
assert(first.character == 3)
assert(first.end_line == 2)
assert(first.end_character == 5)
assert(first.severity == 1)
assert(first.severity_name == "error")
assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning")
app:open_tab()
end)
"#,
)
.expect("diagnostics.lua");
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("регистрация обработчика");
assert_eq!(api.diagnostics_delay(), Some(std::time::Duration::ZERO));
let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
let diagnostics = vec![
Diagnostic {
range: Range::new(Position::new(2, 3), Position::new(2, 5)),
severity: Some(DiagnosticSeverity::ERROR),
message: "плохо".to_owned(),
..Default::default()
},
Diagnostic {
range: Range::new(Position::new(4, 0), Position::new(4, 1)),
severity: Some(DiagnosticSeverity::WARNING),
message: "осторожно".to_owned(),
..Default::default()
},
];
api.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let report = api.lsp_handle().diagnostics_snapshot();
assert_eq!(report.len(), 1);
assert_eq!(report[0].server, "test");
assert_eq!(report[0].path.as_deref(), Some("/tmp/qode-diagnostics.go"));
assert_eq!(report[0].diagnostics.len(), 2);
assert!(api.emit_diagnostics(report));
assert_eq!(
workspace.borrow().documents().len(),
2,
"обработчик должен был открыть новую вкладку"
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_list_dir_returns_sorted_entries_with_paths() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -455,7 +583,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
/// Временный каталог с уникальным именем для Lua-сценариев.
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"gpui-editor-test-{prefix}-{}-{}",
"qode-{prefix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -482,7 +610,7 @@ fn lua_script_can_include_files_and_control_the_app() {
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp:next_tab()\napp:set_language(1, \"python\")\n",
"include({:?})\nlua_config()\napp:open_tab()\napp:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
@@ -500,7 +628,7 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!(
state.active_index(),
1,
"app:next_tab() не переключил вкладку"
"app:open_tab() не переключил вкладку"
);
assert_eq!(
state.documents()[1].language,
@@ -538,6 +666,55 @@ fn api_opens_file_from_disk_and_detects_language() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_open_controls_focus_and_cursor() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("lua-open-focus");
let path = dir.join("main.go");
std::fs::write(&path, "package main\n\nfunc main() {}\n").unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
// Без второго аргумента фокус остаётся прежним.
let plain = dir.join("plain.lua");
std::fs::write(&plain, format!("app:open({:?})\n", path.to_string_lossy())).unwrap();
engine.run_file(&plain).expect("app:open без позиции");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
);
// Пустая таблица — фокус в редактор, курсор на месте.
let focus = dir.join("focus.lua");
std::fs::write(
&focus,
format!("app:open({:?}, {{}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&focus).expect("app:open с пустой таблицей");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
);
// Строка 2, столбец 5 — байтовое смещение 19.
let at = dir.join("at.lua");
std::fs::write(
&at,
format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&at).expect("app:open с позицией");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19)))
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_open_missing_file_creates_an_empty_buffer() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -647,8 +824,13 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
key_char: None,
};
let fire = |keystroke: &Keystroke, pressed: bool| {
for callback in keymap.borrow().callbacks_for(keystroke, pressed) {
callback.call::<()>(()).expect("Lua-обработчик");
for handler in keymap
.borrow()
.handlers_for(KeyContext::Global, keystroke, pressed)
{
if let KeyHandler::Lua(callback) = handler {
callback.call::<()>(()).expect("Lua-обработчик");
}
}
};
@@ -713,6 +895,81 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_keymap_accepts_command_names_and_nil() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, keymap) = install_api(workspace);
let dir = temp_script_dir("key-commands");
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("<C-n>", "next_tab")
app:map_completion_key("<C-n>", "completion_next")
local ok, err = pcall(function()
app:map_key("<C-x>", "no_such_command")
end)
assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err))
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("команды должны назначиться");
let stroke = Keystroke {
modifiers: Modifiers {
control: true,
..Default::default()
},
key: "n".to_owned(),
key_char: None,
};
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.as_slice(),
[KeyHandler::App(crate::keymap::AppCommand::NextTab)]
));
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.as_slice(),
[KeyHandler::Completion(
crate::keymap::CompletionCommand::Next
)]
));
// nil удаляет привязку, не оставляя заглушку.
std::fs::write(
&script,
"app:map_key(\"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
)
.expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться");
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.is_empty()
);
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.is_empty()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn language_names_cover_all_supported_grammars() {
for (name, expected) in [
@@ -737,6 +994,19 @@ fn language_names_cover_all_supported_grammars() {
assert_eq!(Language::Rust.name(), "rust");
assert_eq!(Language::JavaScript.name(), "js");
assert_eq!(Language::Python.name(), "python");
// Каждый язык перечитывается по имени и по полному имени для
// tree-sitter/LSP.
for language in [
Language::Rust,
Language::Go,
Language::JavaScript,
Language::Json,
Language::Python,
] {
assert_eq!(Language::from_name(language.name()), Some(language));
assert_eq!(Language::from_name(language.syntax_name()), Some(language));
}
}
#[test]
@@ -782,15 +1052,18 @@ fn keymap_bindings_are_parsed_and_replaced() {
.load("return function() end")
.eval::<mlua::Function>()
.unwrap();
keymap.bind(plain.clone(), first);
keymap.bind(plain, second);
keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first));
keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second));
let stroke = Keystroke {
modifiers: Modifiers::default(),
key: "a".to_owned(),
key_char: None,
};
assert_eq!(keymap.callbacks_for(&stroke, true).len(), 1);
assert_eq!(
keymap.handlers_for(KeyContext::Global, &stroke, true).len(),
1
);
}
#[gpui_kit::test]
@@ -838,7 +1111,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
api.left_panel().open();
@@ -859,10 +1132,10 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree();
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut()
.append(
"",
@@ -898,14 +1171,54 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().rows().len(), 2);
}
#[gpui_kit::test]
fn bottom_panel_renders_blocks_as_tabs(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let panel = api.bottom_panel_handle();
panel
.borrow_mut()
.add_tree(Some("Диагностика".to_owned()))
.borrow_mut()
.append(
"",
vec![TreeNode {
id: "a".to_owned(),
label: "a".to_owned(),
icon: Some("file".to_owned()),
expandable: false,
expanded: false,
}],
)
.unwrap();
panel.borrow_mut().add_tree(Some("Тесты".to_owned()));
assert_eq!(panel.borrow().active(), 1);
assert_eq!(panel.borrow().blocks()[0].title(), "Диагностика");
assert_eq!(panel.borrow().blocks()[1].title(), "Тесты");
assert!(panel.borrow_mut().set_active(0));
assert!(!panel.borrow_mut().set_active(2));
api.bottom_panel().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
assert!(api.bottom_panel_handle().borrow().is_open());
}
#[gpui_kit::test]
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree();
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut()
.append(
"",
@@ -953,6 +1266,62 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().selected(), Some("readme"));
}
#[gpui_kit::test]
fn lsp_diagnostics_are_underlined_in_editor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let uri = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle().update(cx, |state, cx| {
state.set_value("abc\n", window, cx);
});
let name = this.workspace_handle().borrow().active().name.clone();
crate::lsp::document_uri(&name).expect("у документа есть URI")
})
});
let diagnostic = |range: lsp_types::Range, severity| lsp_types::Diagnostic {
range,
severity: Some(severity),
message: "сообщение".to_owned(),
..Default::default()
};
let diagnostics = vec![
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 0),
lsp_types::Position::new(0, 2),
),
lsp_types::DiagnosticSeverity::ERROR,
),
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 2),
lsp_types::Position::new(0, 3),
),
lsp_types::DiagnosticSeverity::WARNING,
),
];
AppApi::global()
.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let ranges = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
this.refresh_diagnostics(cx);
this.diagnostic_ranges(cx)
})
});
visual.run_until_parked();
assert_eq!(
ranges,
vec![0..2, 2..3],
"ошибка и предупреждение должны стать подчёркнутыми диапазонами"
);
}
#[gpui_kit::test]
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
@@ -1117,8 +1486,11 @@ fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.editor_handle().update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(0..0, cx);
state.focus(window, cx);
});
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})