598 lines
23 KiB
Rust
598 lines
23 KiB
Rust
//! Документы редактора и состояние вкладок.
|
||
//!
|
||
//! Это доменная модель приложения: текст, имена и языки вкладок живут здесь,
|
||
//! а виджет `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
|
||
}
|
||
}
|