//! Документы редактора и состояние вкладок. //! //! Это доменная модель приложения: текст, имена и языки вкладок живут здесь, //! а виджет `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 { 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 { 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 { 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 { 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, /// Язык подсветки синтаксиса. /// /// `None` — подсветка выключена (поведение по умолчанию). Включается /// только явно через `EditorApi::set_language`, например из Lua-сценария. pub language: Option, /// Позиция курсора в байтах на момент ухода с вкладки. pub cursor: usize, /// Иконки на поле редактора для этой вкладки. pub gutter: GutterState, } impl Document { pub fn new(name: impl Into, content: impl Into) -> 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), } /// Открытые документы и активная вкладка. /// /// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы /// один документ. Это позволяет `active`/`active_mut` не возвращать `Option`. pub struct Workspace { documents: Vec, active: usize, next_untitled: u32, /// Действие с фокусом, ожидающее ближайшей синхронизации вида. focus_after_sync: Option, /// Номер «поколения» состояния. /// /// Вид перечитывает активный документ в редактор только когда ревизия /// изменилась. Набор текста сам обновляет документ и ревизию не трогает, /// иначе редактор переставлял бы курсор после каждого символа. 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 { 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, content: impl Into, language: Option, ) -> 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 { 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) { 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) -> 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 } }