use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use notify::{EventKind, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer};
use regex::Regex;
use std::time::SystemTime;
use crate::buffer::{Buffer, RenderSnapshot};
use crate::cursor::Selection;
use crate::diff::DiffState;
use crate::editor::{Direction, EditorState};
use crate::fold;
use crate::git::head_blob_text;
use crate::github::GitHubClient;
#[cfg(feature = "math")]
use crate::inline::detect_inline_math;
use crate::inline::{
GitHubContext, GitHubRef, MathSpan, NakedUrl, RawGitHubMatch, detect_github_references_in_line,
detect_naked_urls,
};
use crate::marker::MarkerKind;
use crate::paste::{PasteContext, transform_paste};
use crate::text_input::TextField;
use crate::validation::{GitHubValidationCache, IssueStatus};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AutocompleteTrigger {
Issue,
User,
}
#[derive(Clone)]
pub enum AutocompleteSuggestion {
IssueOrPr {
number: u64,
symbol: String,
status: IssueStatus,
title: String,
},
User {
login: String,
name: Option<String>,
},
}
#[derive(Clone)]
pub struct AutocompleteState {
pub trigger: AutocompleteTrigger,
pub trigger_offset: usize,
pub prefix: String,
pub suggestions: Vec<AutocompleteSuggestion>,
pub selected_index: usize,
pub loading: bool,
pub fetched_prefix: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FindMode {
Find,
Replace,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FieldFocus {
Search,
Replace,
}
pub struct FindState {
pub search: TextField,
pub replace: TextField,
pub mode: FindMode,
pub focus: FieldFocus,
pub focused: bool,
pub case_sensitive: bool,
pub regex: bool,
pub matches: Vec<Range<usize>>,
pub active: Option<usize>,
scanned: Option<(u64, String, bool, bool)>,
}
pub struct Editor {
pub state: EditorState,
file_path: Option<PathBuf>,
input_blocked: bool,
autosave: bool,
github_context: Option<GitHubContext>,
github_client: Option<GitHubClient>,
github_validation_cache: GitHubValidationCache,
naked_urls_by_line: HashMap<usize, Vec<NakedUrl>>,
github_refs_by_line: HashMap<usize, Vec<RawGitHubMatch>>,
math_spans_by_line: HashMap<usize, Vec<MathSpan>>,
detection_key: Option<(u64, Range<usize>)>,
autocomplete: Option<AutocompleteState>,
find: Option<FindState>,
outline_open: bool,
folded_headings: HashSet<usize>,
head_base: Option<(String, RenderSnapshot)>,
diff_state: Option<DiffState>,
file_watcher: Option<Debouncer<RecommendedWatcher, RecommendedCache>>,
file_watcher_rx: Option<mpsc::Receiver<()>>,
last_save_mtime: Option<SystemTime>,
}
impl Editor {
pub fn new(content: &str) -> Self {
Self {
state: EditorState::new(content),
file_path: None,
input_blocked: false,
autosave: false,
github_context: None,
github_client: None,
github_validation_cache: GitHubValidationCache::new(),
naked_urls_by_line: HashMap::new(),
github_refs_by_line: HashMap::new(),
math_spans_by_line: HashMap::new(),
detection_key: None,
autocomplete: None,
find: None,
outline_open: false,
folded_headings: HashSet::new(),
head_base: None,
diff_state: None,
file_watcher: None,
file_watcher_rx: None,
last_save_mtime: None,
}
}
pub fn open(path: &Path) -> Self {
let content = std::fs::read_to_string(path).unwrap_or_default();
let mut editor = Self::new(&content);
editor.file_path = Some(path.to_path_buf());
editor.refresh_git_base();
editor
}
pub fn file_path(&self) -> Option<&Path> {
self.file_path.as_deref()
}
pub fn set_file_path(&mut self, path: PathBuf) {
self.file_path = Some(path);
}
pub fn text(&self) -> String {
self.state.text()
}
pub fn len(&self) -> usize {
self.state.buffer.len_bytes()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn cursor_position(&self) -> usize {
self.state.cursor().offset
}
pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
let selection = &self.state.selection;
(!selection.is_collapsed()).then(|| selection.range())
}
pub fn selected_text(&self) -> Option<String> {
self.selection_range()
.map(|r| self.state.buffer.slice_cow(r).into_owned())
}
pub fn set_cursor(&mut self, offset: usize) {
self.state.set_cursor(offset);
}
pub fn line_of(&self, offset: usize) -> usize {
self.state.buffer.byte_to_line(offset)
}
pub fn is_dirty(&self) -> bool {
self.state.buffer.is_dirty()
}
pub fn mark_clean(&mut self) {
self.state.buffer.mark_clean();
}
pub fn set_input_blocked(&mut self, blocked: bool) {
self.input_blocked = blocked;
}
pub fn set_autosave(&mut self, autosave: bool) {
self.autosave = autosave;
}
fn maybe_autosave(&mut self) {
if self.autosave
&& self.state.buffer.is_dirty()
&& let Err(e) = self.save()
{
eprintln!("[writ] autosave failed: {e}");
}
}
pub fn input_blocked(&self) -> bool {
self.input_blocked
}
fn edit<R>(&mut self, f: impl FnOnce(&mut EditorState) -> R) -> R {
let before = (!self.folded_headings.is_empty()).then(|| self.state.buffer.text());
let result = f(&mut self.state);
if let Some(before) = before {
let after = self.state.buffer.text();
if before != after {
self.remap_folds(&before, &after);
}
}
self.recompute_diff();
self.maybe_autosave();
result
}
fn remap_folds(&mut self, before: &str, after: &str) {
let (bb, ab) = (before.as_bytes(), after.as_bytes());
let mut prefix = 0;
while prefix < bb.len() && prefix < ab.len() && bb[prefix] == ab[prefix] {
prefix += 1;
}
let mut suffix = 0;
while suffix < bb.len() - prefix
&& suffix < ab.len() - prefix
&& bb[bb.len() - 1 - suffix] == ab[ab.len() - 1 - suffix]
{
suffix += 1;
}
let old_end = bb.len() - suffix; let delta = ab.len() as isize - bb.len() as isize;
let old = std::mem::take(&mut self.folded_headings);
self.folded_headings = old
.into_iter()
.filter_map(|o| {
if o <= prefix {
Some(o)
} else if o >= old_end {
Some((o as isize + delta) as usize)
} else {
None }
})
.collect();
}
pub fn insert_str(&mut self, text: &str) {
self.edit(|s| s.insert_text(text));
}
pub fn paste(&mut self, text: &str) {
let ctx = PasteContext::from_buffer(&self.state.buffer, self.cursor_position());
let transformed = transform_paste(text, &ctx);
self.edit(|s| s.insert_text(&transformed));
}
pub fn type_char(&mut self, c: char) {
let mut buf = [0u8; 4];
self.insert_str(c.encode_utf8(&mut buf));
}
pub fn backspace(&mut self) {
self.edit(|s| s.delete_backward());
}
pub fn delete_forward(&mut self) {
self.edit(|s| s.delete_forward());
}
pub fn enter(&mut self) {
self.edit(|s| s.enter());
}
pub fn shift_enter(&mut self) {
self.edit(|s| s.shift_enter());
}
pub fn shift_alt_enter(&mut self) {
self.edit(|s| s.shift_alt_enter());
}
pub fn tab(&mut self) {
self.edit(|s| s.tab());
}
pub fn shift_tab(&mut self) {
self.edit(|s| s.shift_tab());
}
pub fn move_in_direction(&mut self, direction: Direction, extend: bool) {
let new_cursor = self.state.cursor_in_direction(direction);
if extend {
self.state.selection = self.state.selection.extend_to(new_cursor.offset);
} else {
self.state.selection = Selection::new(new_cursor.offset, new_cursor.offset);
}
}
pub fn select_all(&mut self) {
self.state.selection = Selection::select_all(&self.state.buffer);
}
pub fn cursor_in_code_block(&self) -> bool {
self.state.cursor_in_code_block()
}
pub fn try_insert_space(&mut self) -> bool {
self.edit(|s| s.try_insert_space())
}
pub fn maybe_complete_blockquote_marker(&mut self) {
self.edit(|s| s.maybe_complete_blockquote_marker());
}
pub fn maybe_complete_code_fence(&mut self) {
self.edit(|s| s.maybe_complete_code_fence());
}
pub fn click(&mut self, buffer_offset: usize, shift_held: bool, click_count: usize) {
self.state
.handle_click(buffer_offset, shift_held, click_count);
}
pub fn drag(&mut self, buffer_offset: usize) {
self.state.handle_drag(buffer_offset);
}
pub fn toggle_checkbox(&mut self, line_number: usize) {
self.edit(|s| s.toggle_checkbox(line_number));
}
pub fn checkbox_at(&self, offset: usize) -> Option<usize> {
let line = self.state.buffer.byte_to_line(offset);
let markers = self.state.buffer.line_markers(line);
markers
.markers
.iter()
.any(|m| matches!(m.kind, MarkerKind::Checkbox { .. }) && m.range.contains(&offset))
.then_some(line)
}
pub fn undo(&mut self) {
if let Some(cursor_pos) = self.state.buffer.undo() {
self.state.selection = Selection::new(cursor_pos, cursor_pos);
self.recompute_diff();
self.maybe_autosave();
}
}
pub fn redo(&mut self) {
if let Some(cursor_pos) = self.state.buffer.redo() {
self.state.selection = Selection::new(cursor_pos, cursor_pos);
self.recompute_diff();
self.maybe_autosave();
}
}
pub fn can_undo(&self) -> bool {
self.state.buffer.can_undo()
}
pub fn can_redo(&self) -> bool {
self.state.buffer.can_redo()
}
pub fn set_text(&mut self, content: &str) {
self.state.buffer = content.parse().unwrap_or_default();
self.state.selection = Selection::new(0, 0);
self.recompute_diff();
}
pub fn set_github_context(&mut self, context: GitHubContext) {
self.github_context = Some(context);
self.detection_key = None; }
pub fn set_github_client(&mut self, client: GitHubClient) {
self.github_client = Some(client);
}
pub fn github_client(&self) -> Option<&GitHubClient> {
self.github_client.as_ref()
}
pub fn github_context(&self) -> Option<&GitHubContext> {
self.github_context.as_ref()
}
pub fn link_at(&mut self, offset: usize) -> Option<String> {
let line = self.line_of(offset);
let raw = if let Some(m) = self
.github_refs_by_line
.get(&line)
.and_then(|refs| refs.iter().find(|m| m.byte_range.contains(&offset)))
{
m.reference.url()
} else if let Some(u) = self
.naked_urls_by_line
.get(&line)
.and_then(|urls| urls.iter().find(|u| u.byte_range.contains(&offset)))
{
u.url.clone()
} else {
self.state
.buffer
.render_snapshot()
.inline_styles_for_line(line)
.into_iter()
.find(|r| r.link_url.is_some() && r.full_range.contains(&offset))
.and_then(|r| r.link_url)?
};
Some(self.resolve_link_target(raw))
}
fn resolve_link_target(&self, url: String) -> String {
if url.contains("://") || url.starts_with("mailto:") || Path::new(&url).is_absolute() {
return url;
}
match self.file_path.as_ref().and_then(|p| p.parent()) {
Some(dir) => dir.join(&url).to_string_lossy().into_owned(),
None => url,
}
}
pub fn github_validation_cache(&self) -> &GitHubValidationCache {
&self.github_validation_cache
}
pub fn revalidate_github_refs(&self) {
self.github_validation_cache.clear();
if let Some(client) = self.github_client.as_ref() {
client.clear_autocomplete_cache();
client.clear_user_cache();
}
}
pub fn github_refs_by_line(&self) -> &HashMap<usize, Vec<RawGitHubMatch>> {
&self.github_refs_by_line
}
pub fn naked_urls_by_line(&self) -> &HashMap<usize, Vec<NakedUrl>> {
&self.naked_urls_by_line
}
pub fn detected_refs(&self) -> Vec<GitHubRef> {
let mut refs: Vec<GitHubRef> = Vec::new();
for m in self.github_refs_by_line().values().flatten() {
refs.push(m.reference.clone());
}
for u in self.naked_urls_by_line().values().flatten() {
if let Some(r) = &u.github_ref {
refs.push(r.clone());
}
}
refs
}
pub fn detected_refs_in_lines(&self, lines: Range<usize>) -> Vec<GitHubRef> {
let mut refs: Vec<GitHubRef> = Vec::new();
for (line, matches) in self.github_refs_by_line() {
if lines.contains(line) {
refs.extend(matches.iter().map(|m| m.reference.clone()));
}
}
for (line, urls) in self.naked_urls_by_line() {
if lines.contains(line) {
refs.extend(urls.iter().filter_map(|u| u.github_ref.clone()));
}
}
refs
}
pub fn detect_links(
&mut self,
start_line: usize,
end_line: usize,
) -> (
HashMap<usize, Vec<RawGitHubMatch>>,
HashMap<usize, Vec<NakedUrl>>,
) {
let snapshot = self.state.buffer.render_snapshot();
let mut github_matches_by_line = HashMap::new();
let mut urls_by_line = HashMap::new();
let styles_by_line = snapshot.inline_styles_by_line();
#[allow(clippy::needless_range_loop)]
for line_idx in start_line..end_line.min(snapshot.line_count()) {
let line = snapshot.line_markers(line_idx);
let line_range = line.range.clone();
let line_text = snapshot
.rope
.slice(
snapshot.rope.byte_to_char(line_range.start)
..snapshot.rope.byte_to_char(line_range.end),
)
.to_string();
let inline_styles = &styles_by_line[line_idx];
let code_ranges: Vec<_> = inline_styles
.iter()
.filter(|s| s.style.code)
.map(|s| s.full_range.clone())
.collect();
if let Some(github_context) = &self.github_context {
let matches = detect_github_references_in_line(
&line_text,
line_range.start,
Some(github_context),
&code_ranges,
);
if !matches.is_empty() {
github_matches_by_line.insert(line_idx, matches);
}
}
let link_ranges: Vec<_> = inline_styles
.iter()
.filter(|s| s.link_url.is_some())
.map(|s| s.full_range.clone())
.collect();
let urls = detect_naked_urls(&line_text, line_range.start, &code_ranges, &link_ranges);
if !urls.is_empty() {
urls_by_line.insert(line_idx, urls);
}
}
(github_matches_by_line, urls_by_line)
}
#[cfg(feature = "math")]
fn detect_math(&mut self, start: usize, end: usize) -> HashMap<usize, Vec<MathSpan>> {
let snapshot = self.state.buffer.render_snapshot();
let block_ranges: Vec<Range<usize>> = snapshot
.math_blocks()
.into_iter()
.map(|m| m.block)
.collect();
let mut by_line = HashMap::new();
for line_idx in start..end {
let range = snapshot.line_byte_range(line_idx);
let text = snapshot
.rope
.slice(
snapshot.rope.byte_to_char(range.start)..snapshot.rope.byte_to_char(range.end),
)
.to_string();
if !text.contains('$') {
continue;
}
let code_ranges: Vec<Range<usize>> = snapshot
.inline_styles_for_line(line_idx)
.iter()
.filter(|s| s.style.code)
.map(|s| s.full_range.clone())
.collect();
let spans = detect_inline_math(&text, range.start, &code_ranges, &block_ranges);
if !spans.is_empty() {
by_line.insert(line_idx, spans);
}
}
by_line
}
pub fn refresh_detection(&mut self, lines: Range<usize>) {
let version = self.state.buffer.version();
let n = self.state.buffer.line_count();
let lines = lines.start.min(n)..lines.end.min(n);
if self.detection_key.as_ref() == Some(&(version, lines.clone())) {
return;
}
let (refs, urls) = self.detect_links(lines.start, lines.end);
self.github_refs_by_line = refs;
self.naked_urls_by_line = urls;
#[cfg(feature = "math")]
{
self.math_spans_by_line = self.detect_math(lines.start, lines.end);
}
self.detection_key = Some((version, lines));
}
pub fn math_spans_by_line(&self) -> &HashMap<usize, Vec<MathSpan>> {
&self.math_spans_by_line
}
pub fn open_find(&mut self, replace: bool) {
let mode = if replace {
FindMode::Replace
} else {
FindMode::Find
};
match self.find.as_mut() {
Some(find) => {
find.mode = mode;
find.focus = FieldFocus::Search;
find.focused = true;
}
None => {
self.find = Some(FindState {
search: TextField::new(),
replace: TextField::new(),
mode,
focus: FieldFocus::Search,
focused: true,
case_sensitive: false,
regex: false,
matches: Vec::new(),
active: None,
scanned: None,
});
}
}
self.find_rescan();
}
pub fn close_find(&mut self) {
self.find = None;
}
pub fn find_state(&self) -> Option<&FindState> {
self.find.as_ref()
}
pub fn find_state_mut(&mut self) -> Option<&mut FindState> {
self.find.as_mut()
}
pub fn toggle_outline(&mut self) -> bool {
self.outline_open = !self.outline_open;
self.outline_open
}
pub fn outline_open(&self) -> bool {
self.outline_open
}
pub fn set_outline_open(&mut self, open: bool) {
self.outline_open = open;
}
pub fn line_count(&self) -> usize {
self.state.buffer.line_count()
}
pub fn hidden_line_ranges(&self) -> Vec<Range<usize>> {
fold::hidden_line_ranges(
self.state.buffer.headings(),
self.state.buffer.list_items(),
&self.folded_headings,
self.state.buffer.line_count(),
)
}
pub fn is_heading_folded(&self, byte_offset: usize) -> bool {
self.folded_headings.contains(&byte_offset)
}
pub fn is_list_fold_offset(&self, byte_offset: usize) -> bool {
self.state
.buffer
.list_items()
.iter()
.any(|i| i.byte_offset == byte_offset)
}
pub fn apply_fold_gesture(&mut self, byte_offset: usize, ctrl: bool, shift: bool) {
let is_list = self.is_list_fold_offset(byte_offset);
match (ctrl, shift) {
(true, true) if is_list => self.toggle_fold_list_level_deep_at(byte_offset),
(true, false) if is_list => self.toggle_fold_list_level_at(byte_offset),
(true, true) => self.toggle_fold_level_deep_at(byte_offset),
(true, false) => self.toggle_fold_level_at(byte_offset),
(false, true) => self.toggle_fold_recursive(byte_offset),
(false, false) => self.toggle_fold(byte_offset),
}
}
pub fn toggle_fold(&mut self, byte_offset: usize) {
if !self.folded_headings.remove(&byte_offset) {
self.folded_headings.insert(byte_offset);
}
self.clamp_cursor_to_visible();
}
pub fn toggle_fold_recursive(&mut self, byte_offset: usize) {
let offs = self.recursive_fold_set(byte_offset);
if !offs.is_empty() {
self.apply_group_fold(byte_offset, offs);
}
}
fn apply_group_fold(&mut self, byte_offset: usize, offs: Vec<usize>) {
let folding = !self.folded_headings.contains(&byte_offset);
for off in offs {
if folding {
self.folded_headings.insert(off);
} else {
self.folded_headings.remove(&off);
}
}
self.clamp_cursor_to_visible();
}
fn recursive_fold_set(&self, byte_offset: usize) -> Vec<usize> {
let line_count = self.state.buffer.line_count();
let headings = self.state.buffer.headings();
if let Some(idx) = headings.iter().position(|h| h.byte_offset == byte_offset) {
if !fold::heading_is_foldable(headings, idx, line_count) {
return Vec::new();
}
let extent = fold::heading_extent(headings, idx, line_count);
let mut offs = vec![byte_offset];
for (j, h) in headings.iter().enumerate().skip(idx + 1) {
if h.line >= extent.end {
break;
}
if fold::heading_is_foldable(headings, j, line_count) {
offs.push(h.byte_offset);
}
}
return offs;
}
let items = self.state.buffer.list_items();
if let Some(idx) = items.iter().position(|i| i.byte_offset == byte_offset) {
if !fold::list_item_is_foldable(items, idx) {
return Vec::new();
}
let extent = fold::list_item_extent(items, idx);
let mut offs = vec![byte_offset];
for (j, it) in items.iter().enumerate().skip(idx + 1) {
if it.line >= extent.end {
break;
}
if fold::list_item_is_foldable(items, j) {
offs.push(it.byte_offset);
}
}
return offs;
}
Vec::new()
}
pub fn toggle_fold_list_level_at(&mut self, byte_offset: usize) {
self.toggle_list_level(byte_offset, false);
}
pub fn toggle_fold_list_level_deep_at(&mut self, byte_offset: usize) {
self.toggle_list_level(byte_offset, true);
}
fn toggle_list_level(&mut self, byte_offset: usize, deep: bool) {
let items = self.state.buffer.list_items();
let Some(depth) = items
.iter()
.find(|i| i.byte_offset == byte_offset)
.map(|i| i.depth)
else {
return;
};
let offs: Vec<usize> = items
.iter()
.enumerate()
.filter(|(i, it)| {
fold::list_item_is_foldable(items, *i)
&& if deep {
it.depth >= depth
} else {
it.depth == depth
}
})
.map(|(_, it)| it.byte_offset)
.collect();
self.apply_group_fold(byte_offset, offs);
}
pub fn fold_at_cursor(&mut self) {
let line = self.line_of(self.cursor_position());
let line_count = self.state.buffer.line_count();
let items = self.state.buffer.list_items();
let list_off = items
.iter()
.enumerate()
.filter(|(i, it)| {
fold::list_item_is_foldable(items, *i)
&& (it.line..fold::list_item_extent(items, *i).end).contains(&line)
})
.max_by_key(|(_, it)| it.line)
.map(|(_, it)| it.byte_offset);
if let Some(off) = list_off {
self.folded_headings.insert(off);
self.clamp_cursor_to_visible();
return;
}
let headings = self.state.buffer.headings();
let Some(idx) = fold::section_heading(headings, line) else {
return;
};
if !fold::heading_is_foldable(headings, idx, line_count) {
return;
}
let off = headings[idx].byte_offset;
self.folded_headings.insert(off);
self.clamp_cursor_to_visible();
}
pub fn unfold_at_cursor(&mut self) {
let line = self.line_of(self.cursor_position());
let headings = self.state.buffer.headings();
let Some(idx) = fold::section_heading(headings, line) else {
return;
};
let off = headings[idx].byte_offset;
self.folded_headings.remove(&off);
}
pub fn fold_all_headings(&mut self) {
let line_count = self.state.buffer.line_count();
let headings = self.state.buffer.headings();
let offs: Vec<usize> = headings
.iter()
.enumerate()
.filter(|(i, _)| fold::heading_is_foldable(headings, *i, line_count))
.map(|(_, h)| h.byte_offset)
.collect();
self.folded_headings.extend(offs);
self.clamp_cursor_to_visible();
}
pub fn unfold_all(&mut self) {
self.folded_headings.clear();
}
pub fn toggle_fold_level_at(&mut self, byte_offset: usize) {
self.toggle_level(byte_offset, false);
}
pub fn toggle_fold_level_deep_at(&mut self, byte_offset: usize) {
self.toggle_level(byte_offset, true);
}
fn toggle_level(&mut self, byte_offset: usize, deep: bool) {
let level = self
.state
.buffer
.headings()
.iter()
.find(|h| h.byte_offset == byte_offset)
.map(|h| h.level);
let Some(level) = level else {
return;
};
if self.folded_headings.contains(&byte_offset) {
self.unfold_all();
} else if deep {
self.fold_to_level_deep(level);
} else {
self.fold_to_level(level);
}
}
fn fold_headings_where(&mut self, level_ok: impl Fn(u8) -> bool) {
let line_count = self.state.buffer.line_count();
let headings = self.state.buffer.headings();
self.folded_headings = headings
.iter()
.enumerate()
.filter(|(i, h)| {
level_ok(h.level) && fold::heading_is_foldable(headings, *i, line_count)
})
.map(|(_, h)| h.byte_offset)
.collect();
self.clamp_cursor_to_visible();
}
pub fn fold_to_level(&mut self, level: u8) {
self.fold_headings_where(|l| l == level);
}
pub fn fold_to_level_deep(&mut self, level: u8) {
self.fold_headings_where(|l| l >= level);
}
pub fn reveal_cursor(&mut self) -> bool {
if self.folded_headings.is_empty() {
return false;
}
let cursor_line = self.line_of(self.cursor_position());
let line_count = self.state.buffer.line_count();
let to_remove: Vec<usize> = {
let headings = self.state.buffer.headings();
let items = self.state.buffer.list_items();
self.folded_headings
.iter()
.copied()
.filter(
|&off| match fold::extent_for_offset(headings, items, off, line_count) {
Some(ext) => ext.contains(&cursor_line),
None => true, },
)
.collect()
};
for off in &to_remove {
self.folded_headings.remove(off);
}
!to_remove.is_empty()
}
fn clamp_cursor_to_visible(&mut self) {
let ranges = self.hidden_line_ranges();
if ranges.is_empty() {
return;
}
let cursor_line = self.line_of(self.cursor_position());
if let Some(r) = ranges.iter().find(|r| r.contains(&cursor_line)) {
let heading_line = r.start.saturating_sub(1);
let off = self.state.buffer.line_to_byte(heading_line);
self.state.selection = Selection::new(off, off);
}
}
pub fn find_rescan(&mut self) {
let Some(find) = self.find.as_ref() else {
return;
};
let query = find.search.text().to_string();
let case = find.case_sensitive;
let regex = find.regex;
let focused = find.focused;
let version = self.state.buffer.version();
if find.scanned.as_ref() == Some(&(version, query.clone(), case, regex)) {
return;
}
if query.is_empty() {
let find = self.find.as_mut().expect("find open");
find.matches.clear();
find.active = None;
find.scanned = Some((version, query, case, regex));
return;
}
let pattern = Self::build_find_pattern(&query, regex, case);
let matches: Vec<Range<usize>> = match Regex::new(&pattern) {
Ok(re) => {
let text = self.state.buffer.text();
re.find_iter(&text).map(|m| m.start()..m.end()).collect()
}
Err(_) => Vec::new(),
};
let cursor = self.cursor_position();
let active = (!matches.is_empty())
.then(|| matches.iter().position(|m| m.start >= cursor).unwrap_or(0));
if let Some(idx) = active
&& focused
{
let r = matches[idx].clone();
self.state.selection = Selection::new(r.start, r.end);
}
let find = self.find.as_mut().expect("find open");
find.matches = matches;
find.active = active;
find.scanned = Some((version, query, case, regex));
}
pub fn find_next(&mut self) -> Option<Range<usize>> {
self.find_step(true)
}
pub fn find_prev(&mut self) -> Option<Range<usize>> {
self.find_step(false)
}
fn find_step(&mut self, forward: bool) -> Option<Range<usize>> {
let find = self.find.as_mut()?;
let n = find.matches.len();
if n == 0 {
return None;
}
let cur = find.active.unwrap_or(0);
let next = if forward {
(cur + 1) % n
} else {
(cur + n - 1) % n
};
find.active = Some(next);
let r = find.matches[next].clone();
self.state.selection = Selection::new(r.start, r.end);
Some(r)
}
pub fn find_toggle_case(&mut self) {
if let Some(find) = self.find.as_mut() {
find.case_sensitive = !find.case_sensitive;
}
self.find_rescan();
}
pub fn find_toggle_regex(&mut self) {
if let Some(find) = self.find.as_mut() {
find.regex = !find.regex;
}
self.find_rescan();
}
pub fn find_toggle_field(&mut self) {
if let Some(find) = self.find.as_mut()
&& find.mode == FindMode::Replace
{
find.focus = match find.focus {
FieldFocus::Search => FieldFocus::Replace,
FieldFocus::Replace => FieldFocus::Search,
};
}
}
fn build_find_pattern(query: &str, regex: bool, case_sensitive: bool) -> String {
let escaped;
let base = if regex {
query
} else {
escaped = regex::escape(query);
escaped.as_str()
};
if case_sensitive {
base.to_string()
} else {
format!("(?i){base}")
}
}
fn find_regex(&self) -> Option<Regex> {
let find = self.find.as_ref()?;
let query = find.search.text();
if query.is_empty() {
return None;
}
let pattern = Self::build_find_pattern(query, find.regex, find.case_sensitive);
Regex::new(&pattern).ok()
}
pub fn find_replace_current(&mut self) {
let Some(find) = self.find.as_ref() else {
return;
};
let Some(active) = find.active else {
return;
};
let range = find.matches[active].clone();
let replacement = find.replace.text().to_string();
let regex_mode = find.regex;
let expanded = if regex_mode {
match self.find_regex() {
Some(re) => {
let text = self.state.buffer.text();
re.replace(&text[range.clone()], replacement.as_str())
.into_owned()
}
None => replacement,
}
} else {
replacement
};
self.edit(|s| {
let head = s.buffer.undo_head();
let text_before = s.buffer.text();
let cursor_before = s.cursor().offset;
s.buffer.replace(range.clone(), &expanded, cursor_before);
let text_after = s.buffer.text();
let cursor_after = (range.start + expanded.len()).min(text_after.len());
s.buffer
.coalesce_since(head, &text_before, &text_after, cursor_before, cursor_after);
s.selection = Selection::new(cursor_after, cursor_after);
});
self.find_rescan();
}
pub fn find_replace_all(&mut self) {
let Some(find) = self.find.as_ref() else {
return;
};
if find.matches.is_empty() {
return;
}
let matches = find.matches.clone();
let replacement = find.replace.text().to_string();
let re = self.find_regex();
let regex_mode = find.regex;
self.edit(|s| {
let head = s.buffer.undo_head();
let text_before = s.buffer.text();
let cursor_before = s.cursor().offset;
for range in matches.iter().rev() {
let expanded = match (regex_mode, &re) {
(true, Some(re)) => re
.replace(&text_before[range.clone()], replacement.as_str())
.into_owned(),
_ => replacement.clone(),
};
s.buffer.replace(range.clone(), &expanded, cursor_before);
}
let text_after = s.buffer.text();
let cursor_after = cursor_before.min(text_after.len());
s.buffer
.coalesce_since(head, &text_before, &text_after, cursor_before, cursor_after);
s.selection = Selection::new(cursor_after, cursor_after);
});
self.find_rescan();
}
pub fn autocomplete(&self) -> Option<&AutocompleteState> {
self.autocomplete.as_ref()
}
pub fn close_autocomplete(&mut self) {
self.autocomplete = None;
}
pub fn autocomplete_move(&mut self, forward: bool) {
if let Some(ac) = &mut self.autocomplete
&& !ac.suggestions.is_empty()
{
let n = ac.suggestions.len();
ac.selected_index = if forward {
(ac.selected_index + 1) % n
} else {
(ac.selected_index + n - 1) % n
};
}
}
pub fn autocomplete_select(&mut self, index: usize) {
if let Some(ac) = &mut self.autocomplete
&& index < ac.suggestions.len()
{
ac.selected_index = index;
}
}
pub fn update_autocomplete_from_cursor(&mut self) -> bool {
if self.github_context.is_none() || self.github_client.is_none() {
self.autocomplete = None;
return false;
}
let cursor = self.state.cursor().offset;
let cursor_line = self.state.buffer.byte_to_line(cursor);
if let Some(refs) = self.github_refs_by_line.get(&cursor_line) {
for github_match in refs {
if cursor >= github_match.byte_range.start
&& cursor <= github_match.byte_range.end
&& let GitHubRef::Issue { number, .. } = &github_match.reference
{
let prefix = number.to_string();
let trigger_offset = github_match.byte_range.start;
return self.set_autocomplete_state(
AutocompleteTrigger::Issue,
trigger_offset,
prefix,
);
}
}
}
if cursor > 0 {
let line_start = self.state.buffer.line_to_byte(cursor_line);
let line_text = self.state.buffer.slice_cow(line_start..cursor).into_owned();
if let Some((trigger, trigger_offset, prefix)) =
Self::detect_autocomplete_trigger(&line_text, line_start)
{
return self.set_autocomplete_state(trigger, trigger_offset, prefix);
}
}
self.autocomplete = None;
false
}
fn detect_autocomplete_trigger(
line_text: &str,
line_start: usize,
) -> Option<(AutocompleteTrigger, usize, String)> {
let triggers = [
('#', AutocompleteTrigger::Issue),
('@', AutocompleteTrigger::User),
];
let mut best: Option<(AutocompleteTrigger, usize, String)> = None;
for (trigger_char, trigger_type) in triggers {
let Some(pos) = line_text.rfind(trigger_char) else {
continue;
};
let at_boundary = pos == 0
|| line_text
.as_bytes()
.get(pos - 1)
.is_none_or(|&b| b == b' ' || b == b'\t' || b == b'\n');
if !at_boundary {
continue;
}
let prefix = line_text[pos + 1..].to_string();
let valid = match trigger_type {
AutocompleteTrigger::Issue => !prefix.starts_with([' ', '\t']),
AutocompleteTrigger::User => {
prefix.is_empty()
|| (prefix.chars().all(|c| c.is_alphanumeric() || c == '-')
&& !prefix.starts_with('-'))
}
};
if !valid {
continue;
}
let trigger_offset = line_start + pos;
if best
.as_ref()
.is_none_or(|(_, off, _)| trigger_offset > *off)
{
best = Some((trigger_type, trigger_offset, prefix));
}
}
best
}
fn set_autocomplete_state(
&mut self,
trigger: AutocompleteTrigger,
trigger_offset: usize,
prefix: String,
) -> bool {
let changed = self
.autocomplete
.as_ref()
.map(|ac| ac.trigger != trigger || ac.prefix != prefix)
.unwrap_or(true);
if !changed {
return false;
}
let old = self.autocomplete.take();
let same_trigger = old
.as_ref()
.map(|ac| ac.trigger == trigger)
.unwrap_or(false);
let should_fetch = match trigger {
AutocompleteTrigger::Issue => {
let already = old
.as_ref()
.filter(|_| same_trigger)
.and_then(|ac| ac.fetched_prefix.as_ref())
== Some(&prefix);
!already
}
AutocompleteTrigger::User => true,
};
let (suggestions, selected_index, fetched_prefix) = match old.filter(|_| same_trigger) {
Some(ac) => (ac.suggestions, ac.selected_index, ac.fetched_prefix),
None => (Vec::new(), 0, None),
};
self.autocomplete = Some(AutocompleteState {
trigger,
trigger_offset,
prefix,
suggestions,
selected_index,
loading: false,
fetched_prefix,
});
should_fetch
}
pub fn begin_autocomplete_fetch(&mut self) -> Option<(AutocompleteTrigger, String)> {
let ac = self.autocomplete.as_mut()?;
ac.loading = true;
ac.fetched_prefix = Some(ac.prefix.clone());
Some((ac.trigger, ac.prefix.clone()))
}
pub fn apply_autocomplete_suggestions(
&mut self,
trigger: AutocompleteTrigger,
prefix: &str,
suggestions: Vec<AutocompleteSuggestion>,
) {
if let Some(ac) = &mut self.autocomplete
&& ac.trigger == trigger
&& ac.prefix == prefix
{
ac.suggestions = suggestions;
ac.loading = false;
ac.selected_index = 0;
}
}
pub fn accept_autocomplete_suggestion(&mut self) -> bool {
let Some(ac) = self.autocomplete.take() else {
return false;
};
if ac.suggestions.is_empty() {
return false;
}
let replacement = match &ac.suggestions[ac.selected_index] {
AutocompleteSuggestion::IssueOrPr { number, .. } => format!("#{number}"),
AutocompleteSuggestion::User { login, .. } => format!("@{login}"),
};
let cursor = self.state.cursor().offset;
self.state.selection = Selection::new(ac.trigger_offset, cursor);
self.insert_str(&replacement);
true
}
pub fn diff_state(&self) -> Option<&DiffState> {
self.diff_state.as_ref()
}
pub fn recompute_diff(&mut self) {
self.diff_state = self
.head_base
.as_ref()
.and_then(|(base_text, base_snapshot)| {
let current = self.state.buffer.text();
let state = DiffState::compute(base_snapshot.clone(), base_text, ¤t);
state.has_hunks().then_some(state)
});
}
pub fn refresh_git_base(&mut self) {
self.head_base = self
.file_path
.as_ref()
.and_then(|path| head_blob_text(path))
.map(|text| {
let mut base: Buffer = text.parse().expect("Buffer parsing is infallible");
let snapshot = base.render_snapshot();
(text, snapshot)
});
self.recompute_diff();
}
pub fn set_head_base(&mut self, base_text: &str) {
let mut base: Buffer = base_text.parse().expect("Buffer parsing is infallible");
let snapshot = base.render_snapshot();
self.head_base = Some((base_text.to_string(), snapshot));
self.recompute_diff();
}
pub fn save(&mut self) -> std::io::Result<()> {
let Some(path) = self.file_path.clone() else {
return Ok(());
};
let file = std::fs::File::create(&path)?;
self.state
.buffer
.rope()
.write_to(std::io::BufWriter::new(file))?;
if let Ok(metadata) = std::fs::metadata(&path) {
self.last_save_mtime = metadata.modified().ok();
}
self.state.buffer.mark_clean();
Ok(())
}
pub fn take_file_watch_rx(&mut self) -> Option<mpsc::Receiver<()>> {
self.file_watcher_rx.take()
}
pub fn last_save_mtime(&self) -> Option<SystemTime> {
self.last_save_mtime
}
pub fn read_reload(
path: &Path,
last_save_mtime: Option<SystemTime>,
) -> Option<(String, Option<String>)> {
if let Some(last) = last_save_mtime
&& let Ok(meta) = std::fs::metadata(path)
&& let Ok(mtime) = meta.modified()
&& mtime == last
{
return None; }
let content = std::fs::read_to_string(path).ok()?;
Some((content, head_blob_text(path)))
}
pub fn apply_reload(&mut self, content: String, base_text: Option<String>) {
if !self.state.buffer.content_eq(&content) {
let before = (!self.folded_headings.is_empty()).then(|| self.state.buffer.text());
let cursor_line = self.state.buffer.byte_to_line(self.state.selection.head);
self.set_text(&content);
if let Some(before) = before {
self.remap_folds(&before, &content);
}
let line = cursor_line.min(self.state.buffer.line_count().saturating_sub(1));
let offset = self.state.buffer.line_to_byte(line);
self.state.selection = Selection::new(offset, offset);
}
match base_text {
Some(text) => {
let mut base: Buffer = text.parse().expect("Buffer parsing is infallible");
let snapshot = base.render_snapshot();
self.head_base = Some((text, snapshot));
}
None => self.head_base = None,
}
self.recompute_diff();
}
pub fn watch_file(&mut self) -> notify::Result<()> {
let Some(path) = self.file_path.clone() else {
return Ok(());
};
let (tx, rx) = mpsc::channel();
let mut debouncer = new_debouncer(
Duration::from_millis(150),
None,
move |result: DebounceEventResult| {
if let Ok(events) = result
&& events
.iter()
.any(|e| matches!(e.kind, EventKind::Modify(_) | EventKind::Create(_)))
{
let _ = tx.send(());
}
},
)?;
debouncer.watch(&path, RecursiveMode::NonRecursive)?;
self.file_watcher = Some(debouncer);
self.file_watcher_rx = Some(rx);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
#[ignore = "manual measurement; prints timing, no assertions"]
fn find_scan_cost_on_large_document() {
let unit = "\
# Heading with a searchable word target here
Some prose paragraph mentioning target and other words in a sentence.
- a list item with target inside it
- another item, plainer
```
let code = target(); // fenced block line
```
> a blockquote line about targets and things
";
let reps = 10_000 / unit.lines().count();
let mut src = String::with_capacity(unit.len() * reps);
for _ in 0..reps {
src.push_str(unit);
}
let mut buffer = Buffer::new();
buffer.insert(0, &src, 0);
let line_count = buffer.text().lines().count();
for query in ["target", "the", "nonexistent_zzz"] {
let t = Instant::now();
let text = buffer.text();
let count = text.match_indices(query).count();
let ms = t.elapsed().as_secs_f64() * 1000.0;
println!(
"scan {line_count} lines for {query:?}: {count} hits in {ms:.3} ms (text()+match_indices)",
);
}
}
#[test]
fn autosave_writes_on_every_edit() {
let dir = std::env::temp_dir();
let path = dir.join(format!("writ_autosave_test_{}.md", std::process::id()));
std::fs::write(&path, "start\n").unwrap();
let mut e = Editor::open(&path);
e.set_autosave(true);
e.set_cursor(e.len());
e.insert_str("X");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "start\nX");
e.backspace();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "start\n");
e.set_autosave(false);
e.insert_str("Y");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "start\n");
std::fs::remove_file(&path).ok();
}
#[test]
fn link_at_resolves_links() {
let mut e = Editor::new("See [docs](https://example.com/docs) and https://plain.url/x\n");
let md = e.text().find("docs]").unwrap();
assert_eq!(
e.link_at(md).as_deref(),
Some("https://example.com/docs"),
"markdown link target"
);
e.refresh_detection(0..usize::MAX);
let naked = e.text().find("plain.url").unwrap();
assert_eq!(
e.link_at(naked).as_deref(),
Some("https://plain.url/x"),
"naked URL"
);
assert_eq!(e.link_at(0), None);
}
#[test]
fn link_at_resolves_relative_against_doc_dir() {
let mut e = Editor::new(" and [w](https://x.com)\n");
e.set_file_path(std::path::PathBuf::from("/home/u/notes/doc.md"));
let rel = e.text().find("assets").unwrap();
assert_eq!(
e.link_at(rel).as_deref(),
Some("/home/u/notes/assets/p.png"),
"relative image path resolves against the doc dir"
);
let web = e.text().find("x.com").unwrap();
assert_eq!(
e.link_at(web).as_deref(),
Some("https://x.com"),
"web URL as-is"
);
}
#[test]
fn restored_editor_input_behaviors() {
let mut e = Editor::new("hello\nworld two\n");
e.set_cursor(3);
e.move_in_direction(Direction::LineEnd, false);
assert_eq!(e.cursor_position(), 5, "End → end of line");
e.move_in_direction(Direction::LineStart, false);
assert_eq!(e.cursor_position(), 0, "Home → line start");
e.move_in_direction(Direction::DocEnd, false);
assert_eq!(e.cursor_position(), e.len(), "Ctrl+End → doc end");
e.move_in_direction(Direction::DocStart, true);
assert_eq!(
e.selection_range(),
Some(0..e.len()),
"Shift+Ctrl+Home extends"
);
e.select_all();
assert_eq!(e.selection_range(), Some(0..e.len()), "Ctrl+A selects all");
let mut e = Editor::new("hello\n");
e.set_cursor(0);
assert!(!e.try_insert_space(), "space suppressed at line start");
assert_eq!(e.text(), "hello\n");
e.set_cursor(3);
assert!(e.try_insert_space(), "space inserted mid-line");
assert_eq!(e.text(), "hel lo\n");
let mut e = Editor::new("");
e.insert_str(">");
e.maybe_complete_blockquote_marker();
assert_eq!(e.text(), "> ", "`>` completes to `> `");
let mut e = Editor::new("");
for _ in 0..3 {
e.insert_str("`");
e.maybe_complete_code_fence();
}
assert_eq!(
e.text(),
"```\n```",
"triple backtick auto-closes the fence"
);
assert!(
e.cursor_in_code_block(),
"cursor sits inside the new code block"
);
if e.cursor_in_code_block() {
e.insert_str(" ");
}
assert_eq!(
e.text(),
"``` \n```",
"Tab in code block inserts 4 spaces"
);
let mut e = Editor::new("> ");
e.set_cursor(e.len());
e.paste("line 1\r\nline 2 \u{201C}quoted\u{201D}");
assert_eq!(
e.text(),
"> line 1\n> line 2 \"quoted\"",
"paste normalizes CRLF, quotes, and continues the blockquote"
);
}
#[test]
fn headless_edit_checkbox_github_diff() {
let mut editor = Editor::new("hello");
editor.set_cursor(editor.len());
editor.type_char('!');
assert_eq!(editor.text(), "hello!");
editor.backspace();
assert_eq!(editor.text(), "hello");
let mut editor = Editor::new("- [ ] task\n");
editor.toggle_checkbox(0);
assert!(
editor.text().starts_with("- [x]"),
"checkbox should be checked: {:?}",
editor.text()
);
let mut editor = Editor::new("See #123 for details\n");
editor.set_github_context(GitHubContext {
owner: "wilfreddenton".into(),
repo: "writ".into(),
});
editor.refresh_detection(0..usize::MAX);
assert!(
editor.github_refs_by_line().contains_key(&0),
"should detect #123 on line 0"
);
let mut editor = Editor::new("line1\nline2\n");
assert!(editor.diff_state().is_none(), "no base yet");
editor.set_head_base("line1\n");
assert!(
editor.diff_state().is_some(),
"adding line2 vs HEAD should produce diff hunks"
);
editor.set_text("line1\n");
assert!(editor.diff_state().is_none(), "matching HEAD => no hunks");
}
fn ctx() -> GitHubContext {
GitHubContext {
owner: "rust-lang".into(),
repo: "rust".into(),
}
}
fn issue_suggestion(number: u64, title: &str) -> AutocompleteSuggestion {
AutocompleteSuggestion::IssueOrPr {
number,
symbol: "●".into(),
status: IssueStatus::Open,
title: title.into(),
}
}
#[test]
fn checkbox_at_detects_box_not_content() {
let editor = Editor::new("- [ ] task\n");
let text = editor.text();
let box_off = text.find('[').unwrap();
assert_eq!(editor.checkbox_at(box_off), Some(0));
assert_eq!(editor.checkbox_at(box_off + 1), Some(0));
assert_eq!(editor.checkbox_at(text.find("task").unwrap()), None);
let plain = Editor::new("just a paragraph\n");
assert_eq!(plain.checkbox_at(3), None);
}
#[test]
fn autocomplete_issue_trigger_and_accept() {
let mut editor = Editor::new("Working on #12\n");
editor.set_github_context(ctx());
editor.set_github_client(GitHubClient::new("dummy".into()));
editor.refresh_detection(0..usize::MAX);
editor.set_cursor(14);
assert!(editor.update_autocomplete_from_cursor());
let ac = editor.autocomplete().expect("popup open");
assert_eq!(ac.trigger, AutocompleteTrigger::Issue);
assert_eq!(ac.prefix, "12");
editor.begin_autocomplete_fetch();
editor.apply_autocomplete_suggestions(
AutocompleteTrigger::Issue,
"12",
vec![issue_suggestion(999, "some issue")],
);
assert!(editor.accept_autocomplete_suggestion());
assert_eq!(editor.text(), "Working on #999\n");
assert!(editor.autocomplete().is_none(), "popup closes on accept");
}
#[test]
fn detected_refs_in_lines_is_viewport_bounded() {
let mut editor = Editor::new("See #1 here\n\n\nAnd #2 there\n");
editor.set_github_context(ctx());
editor.set_github_client(GitHubClient::new("dummy".into()));
editor.refresh_detection(0..usize::MAX);
let numbers = |refs: Vec<GitHubRef>| {
refs.iter()
.filter_map(|r| match r {
GitHubRef::Issue { number, .. } => Some(*number),
_ => None,
})
.collect::<Vec<_>>()
};
let line0 = numbers(editor.detected_refs_in_lines(0..1));
assert!(line0.contains(&1), "line 0 range should include #1");
assert!(!line0.contains(&2), "line 0 range should exclude line-3 #2");
let all = numbers(editor.detected_refs_in_lines(0..10));
assert!(
all.contains(&1) && all.contains(&2),
"wide range should include both refs"
);
}
#[test]
fn folding_collapses_reveals_and_survives_edits() {
let mut editor = Editor::new("# A\nbody1\nbody2\n## B\nsub\n# C\ntail\n");
editor.set_cursor(editor.state.buffer.line_to_byte(1));
editor.fold_at_cursor();
assert_eq!(editor.hidden_line_ranges(), vec![1..5]);
assert_eq!(editor.line_of(editor.cursor_position()), 0);
editor.set_cursor(editor.state.buffer.line_to_byte(2));
assert!(editor.reveal_cursor());
assert!(editor.hidden_line_ranges().is_empty());
let c_off = editor.state.buffer.headings()[2].byte_offset;
editor.toggle_fold(c_off);
assert!(!editor.hidden_line_ranges().is_empty());
editor.set_cursor(editor.state.buffer.line_to_byte(1));
editor.insert_str("x");
let c_off2 = editor.state.buffer.headings()[2].byte_offset;
assert_eq!(c_off2, c_off + 1);
assert!(editor.is_heading_folded(c_off2), "fold survived the edit");
editor.fold_all_headings();
assert!(!editor.hidden_line_ranges().is_empty());
editor.unfold_all();
assert!(editor.hidden_line_ranges().is_empty());
}
#[test]
fn fold_to_level_collapses_that_depth() {
let mut editor = Editor::new("# A\na\n## B\nb\n# C\nc\n");
editor.fold_to_level(1);
assert_eq!(editor.hidden_line_ranges(), vec![1..4, 5..7]);
editor.fold_to_level(2);
assert_eq!(editor.hidden_line_ranges(), vec![3..4]);
editor.fold_to_level(4);
assert!(editor.hidden_line_ranges().is_empty());
}
#[test]
fn recursive_fold_pre_folds_descendants() {
let mut editor = Editor::new("# A\na\n## B\nb\n### C\nc\n# D\nd\n");
let a = editor.state.buffer.headings()[0].byte_offset;
let b = editor.state.buffer.headings()[1].byte_offset;
let c = editor.state.buffer.headings()[2].byte_offset;
editor.toggle_fold_recursive(a);
assert!(editor.is_heading_folded(a));
assert!(editor.is_heading_folded(b));
assert!(editor.is_heading_folded(c));
editor.toggle_fold(a);
assert!(!editor.is_heading_folded(a));
assert!(editor.is_heading_folded(b), "descendant stays folded");
assert_eq!(editor.hidden_line_ranges(), vec![3..6]);
editor.toggle_fold_recursive(a);
assert!(editor.is_heading_folded(a) && editor.is_heading_folded(c));
}
#[test]
fn list_folding_task_list_children_survive_checkbox_and_reveal() {
let mut editor =
Editor::new("- [ ] parent\n - [ ] child1\n - [ ] child2\n- [ ] sibling\n");
let items = editor.state.buffer.list_items().to_vec();
let parent = items
.iter()
.find(|i| i.line == 0)
.expect("parent")
.byte_offset;
let sibling = items
.iter()
.find(|i| i.line == 3)
.expect("sibling")
.byte_offset;
editor.toggle_fold(parent);
assert_eq!(editor.hidden_line_ranges(), vec![1..3]);
assert!(editor.is_list_fold_offset(parent) && !editor.is_list_fold_offset(999));
assert!(!editor.is_heading_folded(sibling));
editor.toggle_checkbox(0);
assert_eq!(editor.text().lines().next(), Some("- [x] ~~parent~~"));
assert_eq!(
editor.hidden_line_ranges(),
vec![1..3],
"fold survived the toggle"
);
editor.set_cursor(editor.state.buffer.line_to_byte(1));
assert!(editor.reveal_cursor());
assert!(editor.hidden_line_ranges().is_empty());
}
#[test]
fn list_folding_only_items_with_sublists_are_foldable() {
let mut editor = Editor::new("- a\n- b\n- c\n\n- p\n - c1\n - c2\n- q\n");
let items = editor.state.buffer.list_items().to_vec();
let foldable = |line: usize| -> bool {
let idx = items.iter().position(|i| i.line == line).unwrap();
crate::fold::list_item_is_foldable(&items, idx)
};
for leaf in [0, 1, 2, 5, 6, 7] {
assert!(!foldable(leaf), "line {leaf} is a leaf, must not fold");
}
assert!(foldable(4), "`- p` has a sublist, must fold");
let p = items.iter().find(|i| i.line == 4).unwrap().byte_offset;
editor.toggle_fold(p);
assert_eq!(editor.hidden_line_ranges(), vec![5..7]);
}
#[test]
fn click_snaps_out_of_list_marker_prefix() {
let mut editor = Editor::new("- p\n - [ ] child\n");
editor.click(5, false, 1);
assert_eq!(editor.cursor_position(), 4);
editor.click(7, false, 1);
assert_eq!(editor.cursor_position(), 8);
editor.click(4, false, 1);
assert_eq!(editor.cursor_position(), 4);
editor.click(8, false, 1);
assert_eq!(editor.cursor_position(), 8);
let mut para = Editor::new("hello world\n");
para.click(3, false, 1);
assert_eq!(para.cursor_position(), 3);
}
#[test]
fn list_folding_ctrl_click_folds_all_at_depth() {
let mut editor = Editor::new("- p\n - a\n - x\n - b\n- q\n - c\n");
let items = editor.state.buffer.list_items().to_vec();
let off = |line: usize| items.iter().find(|i| i.line == line).unwrap().byte_offset;
editor.toggle_fold_list_level_at(off(0));
assert!(editor.is_heading_folded(off(0)) && editor.is_heading_folded(off(4)));
assert!(
!editor.is_heading_folded(off(1)),
"depth-2 item `- a` untouched"
);
editor.toggle_fold_list_level_at(off(0));
assert!(editor.hidden_line_ranges().is_empty());
editor.toggle_fold_list_level_deep_at(off(0));
assert!(
editor.is_heading_folded(off(0))
&& editor.is_heading_folded(off(4))
&& editor.is_heading_folded(off(1))
);
}
#[test]
fn list_folding_recursive_and_survives_edit() {
let mut editor = Editor::new("- a\n - b\n - c\n- d\n");
let items = editor.state.buffer.list_items().to_vec();
let a = items.iter().find(|i| i.line == 0).unwrap().byte_offset;
let b = items.iter().find(|i| i.line == 1).unwrap().byte_offset;
editor.toggle_fold_recursive(a);
assert!(editor.is_heading_folded(a) && editor.is_heading_folded(b));
editor.toggle_fold(a);
assert!(editor.is_heading_folded(b));
assert_eq!(editor.hidden_line_ranges(), vec![2..3]);
editor.toggle_fold_recursive(a);
editor.set_cursor(0);
editor.insert_str("- z\n");
let b2 = editor
.state
.buffer
.list_items()
.iter()
.find(|i| i.line == 2)
.unwrap()
.byte_offset;
assert!(
editor.is_heading_folded(b2),
"fold tracked the shifted item"
);
}
#[test]
fn ctrl_click_folds_all_at_level() {
let mut editor = Editor::new("# A\na\n## B\nb\n# C\nc\n## D\nd\n");
let a = editor.state.buffer.headings()[0].byte_offset;
let b = editor.state.buffer.headings()[1].byte_offset;
let c = editor.state.buffer.headings()[2].byte_offset;
let d = editor.state.buffer.headings()[3].byte_offset;
editor.toggle_fold_level_at(b);
assert!(editor.is_heading_folded(b) && editor.is_heading_folded(d));
assert!(!editor.is_heading_folded(a) && !editor.is_heading_folded(c));
editor.toggle_fold_level_at(b);
assert!(editor.hidden_line_ranges().is_empty());
}
#[test]
fn ctrl_shift_click_folds_level_and_deeper() {
let mut editor = Editor::new("# A\n## B\n### C\nc\n# D\n");
let a = editor.state.buffer.headings()[0].byte_offset;
let b = editor.state.buffer.headings()[1].byte_offset;
let c = editor.state.buffer.headings()[2].byte_offset;
editor.toggle_fold_level_deep_at(b);
assert!(editor.is_heading_folded(b) && editor.is_heading_folded(c));
assert!(!editor.is_heading_folded(a));
editor.toggle_fold_level_deep_at(b);
assert!(editor.hidden_line_ranges().is_empty());
}
#[test]
fn autocomplete_user_trigger() {
let mut editor = Editor::new("cc @tor\n");
editor.set_github_context(ctx());
editor.set_github_client(GitHubClient::new("dummy".into()));
editor.refresh_detection(0..usize::MAX);
editor.set_cursor(7);
assert!(editor.update_autocomplete_from_cursor());
let ac = editor.autocomplete().expect("popup open");
assert_eq!(ac.trigger, AutocompleteTrigger::User);
assert_eq!(ac.prefix, "tor");
}
#[test]
fn autocomplete_needs_client_and_context() {
let mut editor = Editor::new("# heading\n");
editor.set_github_context(ctx());
editor.set_cursor(2);
assert!(!editor.update_autocomplete_from_cursor());
assert!(editor.autocomplete().is_none());
}
#[test]
fn save_and_reload_roundtrip() {
let dir = std::env::temp_dir().join(format!("writ-core-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.md");
std::fs::write(&path, "original\n").unwrap();
let mut editor = Editor::open(&path);
assert_eq!(editor.text(), "original\n");
editor.set_cursor(editor.len());
editor.insert_str("more\n");
editor.save().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "original\nmore\n");
assert!(!editor.is_dirty(), "saved buffer is clean");
std::fs::remove_dir_all(&dir).ok();
}
fn set_query(editor: &mut Editor, query: &str) {
editor.find_state_mut().unwrap().search.set_text(query);
editor.find_rescan();
}
#[test]
fn find_literal_and_case_toggle() {
let mut e = Editor::new("Cat cat CAT cot\n");
e.set_cursor(0);
e.open_find(false);
set_query(&mut e, "cat");
assert_eq!(e.find_state().unwrap().matches.len(), 3);
e.find_toggle_case();
assert_eq!(e.find_state().unwrap().matches.len(), 1);
}
#[test]
fn find_regex_mode_and_invalid_pattern() {
let mut e = Editor::new("a table, a tible, a topple\n");
e.set_cursor(0);
e.open_find(false);
e.find_toggle_regex();
set_query(&mut e, "t.ble");
assert_eq!(
e.find_state().unwrap().matches.len(),
2,
"t.ble matches table and tible"
);
set_query(&mut e, "(");
assert!(e.find_state().unwrap().matches.is_empty());
assert!(e.find_state().unwrap().active.is_none());
}
#[test]
fn find_adjacent_matches_are_non_overlapping() {
let mut e = Editor::new("aaaa\n");
e.set_cursor(0);
e.open_find(false);
set_query(&mut e, "aa");
assert_eq!(e.find_state().unwrap().matches.len(), 2);
}
#[test]
fn find_next_cycles_and_sets_selection() {
let mut e = Editor::new("x . x . x\n");
e.set_cursor(0);
e.open_find(false);
set_query(&mut e, "x");
let m = &e.find_state().unwrap().matches;
assert_eq!(m.len(), 3);
let ranges: Vec<_> = m.clone();
assert_eq!(e.find_state().unwrap().active, Some(0));
assert_eq!(e.selection_range(), Some(ranges[0].clone()));
assert_eq!(e.find_next(), Some(ranges[1].clone()));
assert_eq!(e.find_state().unwrap().active, Some(1));
assert_eq!(e.selection_range(), Some(ranges[1].clone()));
assert_eq!(e.find_next(), Some(ranges[2].clone()));
assert_eq!(e.find_next(), Some(ranges[0].clone()), "wraps to start");
assert_eq!(e.find_state().unwrap().active, Some(0));
assert_eq!(e.find_prev(), Some(ranges[2].clone()), "prev wraps back");
}
#[test]
fn find_active_picks_match_at_or_after_caret() {
let mut e = Editor::new("x . x . x\n");
let third = e.text().rfind('x').unwrap();
e.set_cursor(e.text()[..third].rfind('x').unwrap() + 1);
e.open_find(false);
set_query(&mut e, "x");
assert_eq!(e.find_state().unwrap().active, Some(2));
let mut e = Editor::new("x . x . x\n");
e.set_cursor(e.len());
e.open_find(false);
set_query(&mut e, "x");
assert_eq!(
e.find_state().unwrap().active,
Some(0),
"wraps to first match"
);
}
fn set_replace(editor: &mut Editor, replacement: &str) {
editor
.find_state_mut()
.unwrap()
.replace
.set_text(replacement);
}
#[test]
fn find_replace_current_literal_advances() {
let mut e = Editor::new("cat cat cat\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "cat");
set_replace(&mut e, "dog");
assert_eq!(e.find_state().unwrap().active, Some(0));
e.find_replace_current();
assert_eq!(e.text(), "dog cat cat\n");
assert_eq!(e.find_state().unwrap().matches.len(), 2);
assert_eq!(e.find_state().unwrap().active, Some(0));
assert_eq!(e.selection_range(), Some(4..7));
}
#[test]
fn find_replace_current_noop_without_active() {
let mut e = Editor::new("hello\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "zzz");
set_replace(&mut e, "x");
assert!(e.find_state().unwrap().active.is_none());
e.find_replace_current();
assert_eq!(e.text(), "hello\n");
}
#[test]
fn find_replace_all_literal_longer_and_shorter() {
let mut e = Editor::new("a a a\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "a");
set_replace(&mut e, "bb");
e.find_replace_all();
assert_eq!(e.text(), "bb bb bb\n");
assert!(e.find_state().unwrap().matches.is_empty());
let mut e = Editor::new("aa aa\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "aa");
set_replace(&mut e, "x");
e.find_replace_all();
assert_eq!(e.text(), "x x\n");
}
#[test]
fn find_replace_current_regex_expands_capture() {
let mut e = Editor::new("user_id and role_id\n");
e.set_cursor(0);
e.open_find(true);
e.find_toggle_regex();
set_query(&mut e, r"(\w+)_id");
set_replace(&mut e, "${1}Id");
e.find_replace_current();
assert_eq!(e.text(), "userId and role_id\n");
}
#[test]
fn find_replace_all_regex_expands_every_capture() {
let mut e = Editor::new("user_id and role_id\n");
e.set_cursor(0);
e.open_find(true);
e.find_toggle_regex();
set_query(&mut e, r"(\w+)_id");
set_replace(&mut e, "${1}Id");
e.find_replace_all();
assert_eq!(e.text(), "userId and roleId\n");
}
#[test]
fn find_replace_current_replacement_containing_query_no_loop() {
let mut e = Editor::new("cat cat\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "cat");
set_replace(&mut e, "cat!");
e.find_replace_current();
assert_eq!(e.text(), "cat! cat\n");
assert_eq!(e.find_state().unwrap().matches.len(), 2);
assert_eq!(e.find_state().unwrap().active, Some(1));
assert_eq!(e.selection_range(), Some(5..8));
}
#[test]
fn find_replace_all_is_single_undo_step() {
let mut e = Editor::new("a a a\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "a");
set_replace(&mut e, "bb");
e.find_replace_all();
assert_eq!(e.text(), "bb bb bb\n");
e.undo();
assert_eq!(
e.text(),
"a a a\n",
"one undo reverts the whole replace-all"
);
}
#[test]
fn folds_persist_and_remap_across_reload() {
let mut e = Editor::new("# One\nbody\n## Two\nmore\n");
let two = e.text().find("## Two").unwrap();
e.toggle_fold(two);
assert!(e.folded_headings.contains(&two));
let prefix = "intro line\n";
e.apply_reload(format!("{prefix}# One\nbody\n## Two\nmore\n"), None);
let two_after = e.text().find("## Two").unwrap();
assert_eq!(two_after, two + prefix.len());
assert!(
e.folded_headings.contains(&two_after),
"fold should remap to the heading's new offset after reload"
);
}
#[test]
fn find_replace_current_is_single_undo_step() {
let mut e = Editor::new("a a a\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "a");
set_replace(&mut e, "bb");
e.find_replace_current();
assert_eq!(e.text(), "bb a a\n");
e.undo();
assert_eq!(e.text(), "a a a\n", "one undo reverts a single replace");
}
#[test]
fn open_find_focuses_the_bar() {
let mut e = Editor::new("hi\n");
e.open_find(false);
assert!(e.find_state().unwrap().focused, "opens focused");
e.find_state_mut().unwrap().focused = false;
e.open_find(false);
assert!(e.find_state().unwrap().focused, "re-opening refocuses");
}
#[test]
fn rescan_while_unfocused_does_not_move_selection() {
let mut e = Editor::new("cat cat cat\n");
e.set_cursor(0);
e.open_find(false);
set_query(&mut e, "cat");
assert_eq!(e.selection_range(), Some(0..3));
e.find_state_mut().unwrap().focused = false;
e.set_cursor(9);
e.insert_str("X");
e.find_rescan();
assert_eq!(e.find_state().unwrap().matches.len(), 2);
assert!(
e.selection_range().is_none(),
"unfocused rescan leaves the doc caret alone"
);
}
#[test]
fn undo_of_replace_restores_text_and_rescan_refreshes_matches() {
let mut e = Editor::new("cat cat cat\n");
e.set_cursor(0);
e.open_find(true);
set_query(&mut e, "cat");
set_replace(&mut e, "dog");
e.find_replace_all();
assert_eq!(e.text(), "dog dog dog\n");
assert!(e.find_state().unwrap().matches.is_empty());
e.undo();
e.find_rescan();
assert_eq!(e.text(), "cat cat cat\n");
assert_eq!(
e.find_state().unwrap().matches.len(),
3,
"rescan after undo restores the full match set"
);
}
}