Files
qode/src/editor.rs
T
2026-11-01 01:13:30 +03:00

598 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Документы редактора и состояние вкладок.
//!
//! Это доменная модель приложения: текст, имена и языки вкладок живут здесь,
//! а виджет `Editor` из gpui-component показывает активный документ. Такое
//! разделение позволяет Lua-сценариям менять документы без доступа к `Window`
//! и `App`: вид обновляет редактор, когда меняется [`Workspace::revision`].
use std::sync::atomic::{AtomicU64, Ordering};
use gpui_kit::base::input::Rope;
use crate::gutter::{GutterLineEdit, GutterShift, GutterState};
/// Счётчик идентификаторов документов.
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
/// Языки, для которых в редакторе включена подсветка tree-sitter.
///
/// Названия намеренно короткие (`js`, а не `javascript`): именно их возвращает
/// [`Language::name`] и показывает Lua-API. gpui-component принимает такие
/// псевдонимы и сам приводит их к имени грамматики.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Language {
Rust,
Go,
JavaScript,
Json,
Python,
Lua,
Bash,
Yaml,
Make,
Dockerfile,
Sql,
Markdown,
Css,
C,
Cpp,
Html,
Xml,
}
impl Language {
/// Разбирает название языка из API/сценария.
///
/// Основные имена — [`Self::name`]; короткие (`rs`, `golang`,
/// `javascript`, `py`, `sh`, `yml`, `makefile`, `md`, `c++`, …)
/// и регистр тоже принимаются.
pub fn from_name(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"rust" | "rs" => Some(Self::Rust),
"go" | "golang" => Some(Self::Go),
"js" | "javascript" | "node" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"python" | "py" => Some(Self::Python),
"lua" => Some(Self::Lua),
"bash" | "sh" | "shell" | "shellscript" => Some(Self::Bash),
"yaml" | "yml" => Some(Self::Yaml),
"make" | "makefile" | "mk" => Some(Self::Make),
"dockerfile" | "containerfile" | "docker" => Some(Self::Dockerfile),
"sql" => Some(Self::Sql),
"markdown" | "md" | "mdx" => Some(Self::Markdown),
"css" => Some(Self::Css),
"c" | "h" => Some(Self::C),
"cpp" | "c++" | "cxx" | "cc" | "hpp" | "hxx" | "hh" => Some(Self::Cpp),
"html" | "htm" => Some(Self::Html),
"xml" | "xsd" | "xsl" | "xslt" | "svg" => Some(Self::Xml),
_ => None,
}
}
/// Каноническое имя языка (то, что возвращает `AppApi::language`).
pub fn name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Go => "go",
Self::JavaScript => "js",
Self::Json => "json",
Self::Python => "python",
Self::Lua => "lua",
Self::Bash => "bash",
Self::Yaml => "yaml",
Self::Make => "make",
Self::Dockerfile => "dockerfile",
Self::Sql => "sql",
Self::Markdown => "markdown",
Self::Css => "css",
Self::C => "c",
Self::Cpp => "cpp",
Self::Html => "html",
Self::Xml => "xml",
}
}
/// Полное имя языка для tree-sitter и LSP.
///
/// Отличается от [`Self::name`] только для JavaScript: грамматика и
/// `languageId` используют `javascript`, а короткое `js` остаётся именем
/// в Lua-API.
pub fn syntax_name(self) -> &'static str {
match self {
Self::JavaScript => "javascript",
_ => self.name(),
}
}
/// Идентификатор языка для LSP.
///
/// Совпадает с [`Self::syntax_name`], кроме Bash и Make: грамматики
/// называются `bash` и `make`, а `languageId` в LSP — `shellscript` и
/// `makefile`.
pub fn lsp_language_id(self) -> &'static str {
match self {
Self::Bash => "shellscript",
Self::Make => "makefile",
_ => self.syntax_name(),
}
}
/// Определяет язык подсветки по имени файла.
///
/// Используется при открытии файла с диска (`api.editor:open(path)`), чтобы
/// подсветка включалась автоматически там, где для неё есть грамматика.
/// Кроме расширений учитываются файлы без расширения (`Makefile`,
/// `Dockerfile`, `Containerfile`).
pub fn from_path(path: &str) -> Option<Self> {
if let Some(language) = special_file_language(path) {
return Some(language);
}
let file_name = file_name(path);
let (_, extension) = file_name.rsplit_once('.')?;
Self::from_extension(extension)
}
/// Язык по расширению файла без учёта регистра.
fn from_extension(extension: &str) -> Option<Self> {
match extension.to_ascii_lowercase().as_str() {
"rs" => Some(Self::Rust),
"go" => Some(Self::Go),
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"py" => Some(Self::Python),
"lua" => Some(Self::Lua),
"sh" | "bash" => Some(Self::Bash),
"yaml" | "yml" => Some(Self::Yaml),
"mk" => Some(Self::Make),
"dockerfile" => Some(Self::Dockerfile),
"sql" => Some(Self::Sql),
"md" | "markdown" | "mdx" => Some(Self::Markdown),
"css" => Some(Self::Css),
"c" | "h" => Some(Self::C),
"cc" | "cpp" | "cxx" | "c++" | "hpp" | "hxx" | "hh" => Some(Self::Cpp),
"html" | "htm" => Some(Self::Html),
"xml" | "xsd" | "xsl" | "xslt" | "svg" => Some(Self::Xml),
_ => None,
}
}
}
/// Имя файла без каталогов.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
/// Языки, у которых имя файла не содержит расширения.
fn special_file_language(path: &str) -> Option<Language> {
match file_name(path).to_ascii_lowercase().as_str() {
"makefile" | "gnumakefile" | "bsdmakefile" => Some(Language::Make),
"dockerfile" | "containerfile" => Some(Language::Dockerfile),
_ => None,
}
}
/// Язык LSP по расширению имени файла.
///
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
/// выбирается и получает `languageId` независимо от tree-sitter.
pub fn language_id_from_path(name: &str) -> &'static str {
if let Some(language) = special_file_language(name) {
return language.lsp_language_id();
}
let extension = file_name(name)
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase());
match extension.as_deref() {
Some("rs") => "rust",
Some("go") => "go",
Some("js" | "mjs" | "cjs") => "javascript",
Some("jsx") => "javascriptreact",
Some("ts") => "typescript",
Some("tsx") => "typescriptreact",
Some("json") => "json",
Some("py") => "python",
Some("md" | "markdown" | "mdx") => "markdown",
Some("toml") => "toml",
Some("yaml" | "yml") => "yaml",
Some("lua") => "lua",
Some("c" | "h") => "c",
Some("cc" | "cpp" | "cxx" | "c++" | "hpp" | "hxx" | "hh") => "cpp",
Some("sh" | "bash") => "shellscript",
Some("mk") => "makefile",
Some("dockerfile") => "dockerfile",
Some("sql") => "sql",
Some("html" | "htm") => "html",
Some("css") => "css",
Some("xml" | "xsd" | "xsl" | "xslt" | "svg") => "xml",
_ => "plaintext",
}
}
/// Открытый в редакторе документ.
pub struct Document {
/// Стабильный идентификатор, пока документ открыт.
///
/// По нему вид отличает «ту же вкладку» от «другой вкладки с тем же
/// индексом» после закрытия соседей.
pub id: u64,
pub name: String,
/// Текст документа в виде rope: копия из редактора стоит O(1).
content: Rope,
/// Версия содержимого: увеличивается на каждую правку.
content_version: u64,
/// Есть несохранённые изменения.
dirty: bool,
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
///
/// `None` — показывается иконка по умолчанию. Назначается через
/// `EditorApi::set_tab_icon`.
pub icon: Option<String>,
/// Язык подсветки синтаксиса.
///
/// `None` — подсветка выключена (поведение по умолчанию). Включается
/// только явно через `EditorApi::set_language`, например из Lua-сценария.
pub language: Option<Language>,
/// Позиция курсора в байтах на момент ухода с вкладки.
pub cursor: usize,
/// Иконки на поле редактора для этой вкладки.
pub gutter: GutterState,
}
impl Document {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
name: name.into(),
content: Rope::from(content.into()),
content_version: 0,
dirty: false,
icon: None,
language: None,
cursor: 0,
gutter: GutterState::new(),
}
}
/// Текст документа.
pub fn content(&self) -> &Rope {
&self.content
}
/// Материализованный текст для границ (сохранение, LSP, Lua).
pub fn content_string(&self) -> String {
self.content.to_string()
}
/// Версия содержимого, увеличивается на каждую правку.
pub fn content_version(&self) -> u64 {
self.content_version
}
/// Есть ли изменения, которые ещё не сохранены.
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Заменяет текст и отмечает документ изменённым.
///
/// Это внешняя замена целого текста: иконки gutter привязаны к номерам
/// строк старого текста, поэтому они сбрасываются. Сценарий может
/// расставить их заново по новому тексту.
pub fn set_content(&mut self, content: Rope) {
if self.content == content {
return;
}
self.content = content;
self.content_version = self.content_version.wrapping_add(1);
self.dirty = true;
self.gutter.clear();
}
/// Обновляет текст после правки в редакторе: копия rope стоит O(1).
pub fn replace_content(&mut self, content: &Rope) {
self.content = content.clone();
self.content_version = self.content_version.wrapping_add(1);
self.dirty = true;
}
/// Идентификатор языка для LSP.
///
/// Если подсветка уже включена, берётся её язык. Иначе язык угадывается
/// по расширению имени вкладки — так LSP работает и без `set_language`.
pub fn lsp_language_id(&self) -> String {
if let Some(language) = self.language {
return language.lsp_language_id().to_owned();
}
language_id_from_path(&self.name).to_owned()
}
/// Имя иконки вкладки с учётом иконки по умолчанию.
pub fn icon_name(&self) -> &str {
self.icon
.as_deref()
.unwrap_or(crate::icons::DEFAULT_TAB_ICON)
}
/// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) {
self.dirty = false;
}
}
/// Что сделать с фокусом редактора после синхронизации модели с видом.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusAfterSync {
/// Оставить фокус там, где он был (`api.editor:open` без позиции).
Keep,
/// Перевести фокус в редактор и, если задано, поставить курсор.
///
/// Смещение задаётся в байтах; `None` — оставить текущую позицию.
Editor(Option<usize>),
}
/// Открытые документы и активная вкладка.
///
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
/// один документ. Это позволяет `active`/`active_mut` не возвращать `Option`.
pub struct Workspace {
documents: Vec<Document>,
active: usize,
next_untitled: u32,
/// Действие с фокусом, ожидающее ближайшей синхронизации вида.
focus_after_sync: Option<FocusAfterSync>,
/// Номер «поколения» состояния.
///
/// Вид перечитывает активный документ в редактор только когда ревизия
/// изменилась. Набор текста сам обновляет документ и ревизию не трогает,
/// иначе редактор переставлял бы курсор после каждого символа.
revision: u64,
}
impl Default for Workspace {
fn default() -> Self {
Self {
documents: vec![Document::new("untitled-1.txt", "")],
active: 0,
next_untitled: 1,
focus_after_sync: None,
revision: 0,
}
}
}
impl Workspace {
pub fn documents(&self) -> &[Document] {
&self.documents
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn active(&self) -> &Document {
&self.documents[self.active]
}
pub fn active_mut(&mut self) -> &mut Document {
&mut self.documents[self.active]
}
/// Документ по номеру вкладки (для API).
pub fn document(&self, index: usize) -> Option<&Document> {
self.documents.get(index)
}
/// Изменяемый документ по стабильному идентификатору.
pub fn document_mut_by_id(&mut self, id: u64) -> Option<&mut Document> {
self.documents.iter_mut().find(|doc| doc.id == id)
}
/// Текущая ревизия состояния.
pub fn revision(&self) -> u64 {
self.revision
}
/// Откладывает действие с фокусом до ближайшей синхронизации вида.
pub fn set_focus_after_sync(&mut self, focus: FocusAfterSync) {
self.focus_after_sync = Some(focus);
}
/// Забирает отложенное действие с фокусом.
pub fn take_focus_after_sync(&mut self) -> Option<FocusAfterSync> {
self.focus_after_sync.take()
}
/// Сообщает виду, что документы нужно перечитать.
fn bump(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
/// Делает вкладку активной, если индекс существует.
pub fn set_active(&mut self, index: usize) {
if index < self.documents.len() && index != self.active {
self.active = index;
self.bump();
}
}
/// Переключает активную вкладку на `delta` позиций по кругу.
///
/// Возвращает новый индекс. Инвариант «хотя бы один документ» делает
/// деление по модулю безопасным.
pub fn cycle_active(&mut self, delta: isize) -> usize {
let count = self.documents.len() as isize;
let next = (self.active as isize + delta).rem_euclid(count) as usize;
self.set_active(next);
next
}
/// Открывает пустую вкладку и делает её активной.
pub fn open_new(&mut self) {
self.next_untitled += 1;
self.documents.push(Document::new(
format!("untitled-{}.txt", self.next_untitled),
"",
));
self.active = self.documents.len() - 1;
self.bump();
}
/// Открывает файл с диска как вкладку и делает её активной.
///
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
/// изменений, её содержимое обновляется из файла — повторный `api.editor:open`
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
pub fn open_file(
&mut self,
name: impl Into<String>,
content: impl Into<String>,
language: Option<Language>,
) -> usize {
let name = name.into();
let content = content.into();
if let Some(index) = self
.documents
.iter()
.position(|document| document.name == name && !document.is_dirty())
{
let document = &mut self.documents[index];
document.content = Rope::from(content);
document.content_version = document.content_version.wrapping_add(1);
document.mark_saved();
document.language = language;
document.gutter.clear();
self.active = index;
self.bump();
return index;
}
let mut document = Document::new(name, content);
document.language = language;
self.documents.push(document);
self.active = self.documents.len() - 1;
self.bump();
self.active
}
/// Закрывает вкладку и выбирает соседнюю.
///
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
pub fn close(&mut self, index: usize) {
if index >= self.documents.len() {
return;
}
self.documents.remove(index);
if self.documents.is_empty() {
self.open_new();
return;
}
if index < self.active {
self.active -= 1;
} else if index == self.active {
self.active = self.active.min(self.documents.len() - 1);
}
self.bump();
}
/// Обновляет текст активного документа без изменения ревизии.
///
/// Используется видом после ввода в редакторе: сам виджет уже показывает
/// этот текст, поэтому повторная синхронизация только сбросила бы курсор.
/// Иконки gutter пересчитываются по точным правкам из `InputEvent::Change`.
///
/// Возвращает иконки, которые съехали.
pub fn replace_active_content(
&mut self,
content: &Rope,
line_edits: &[GutterLineEdit],
collect_shifts: bool,
) -> Vec<GutterShift> {
let document = self.active_mut();
if line_edits.is_empty() && document.content.eq(content) {
return Vec::new();
}
let shifts = if collect_shifts {
document.gutter.apply_line_edits(line_edits)
} else {
document.gutter.apply_line_edits_silently(line_edits);
Vec::new()
};
document.replace_content(content);
shifts
}
/// Заменяет текст активной вкладки извне (Lua, меню, тесты).
pub fn set_active_text(&mut self, text: impl Into<String>) {
self.active_mut().set_content(Rope::from(text.into()));
self.bump();
}
/// Включает подсветку для вкладки; возвращает `false`, если вкладки нет.
pub fn set_language(&mut self, index: usize, language: Language) -> bool {
let Some(doc) = self.documents.get_mut(index) else {
return false;
};
if doc.language != Some(language) {
doc.language = Some(language);
self.bump();
}
true
}
/// Выключает подсветку для вкладки; возвращает `false`, если вкладки нет.
pub fn clear_language(&mut self, index: usize) -> bool {
let Some(doc) = self.documents.get_mut(index) else {
return false;
};
if doc.language.take().is_some() {
self.bump();
}
true
}
/// Помечает активный документ сохранённым.
///
/// Ревизия меняется, чтобы индикатор несохранённых изменений на вкладке
/// перерисовался даже без отдельного уведомления вида.
pub fn mark_active_saved(&mut self) {
self.active_mut().mark_saved();
self.bump();
}
/// Назначает вкладке иконку из каталога; возвращает `false`, если вкладки нет.
pub fn set_tab_icon(&mut self, index: usize, icon: impl Into<String>) -> bool {
let icon = icon.into();
let changed = match self.documents.get_mut(index) {
Some(document) => {
let changed = document.icon.as_deref() != Some(icon.as_str());
if changed {
document.icon = Some(icon);
}
changed
}
None => return false,
};
if changed {
self.bump();
}
true
}
/// Возвращает вкладке иконку по умолчанию; `false`, если вкладки нет.
pub fn clear_tab_icon(&mut self, index: usize) -> bool {
let changed = match self.documents.get_mut(index) {
Some(document) => document.icon.take().is_some(),
None => return false,
};
if changed {
self.bump();
}
true
}
}