use strop_core::Buffer;
pub(crate) mod remote;
pub mod surfaces;
pub use remote::{RemoteDirectory, RemoteDocument};
pub use surfaces::{DiffRow, DocumentSource, ReturnPoint, Surface};
use super::Editor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Indent {
pub style: crate::config::IndentStyle,
pub width: usize,
}
impl Default for Indent {
fn default() -> Self {
Self {
style: crate::config::IndentStyle::Spaces,
width: 4,
}
}
}
impl Indent {
pub fn unit(&self) -> String {
match self.style {
crate::config::IndentStyle::Spaces => " ".repeat(self.width),
crate::config::IndentStyle::Tabs => "\t".into(),
}
}
}
pub fn detect_indent(text: &ropey::Rope) -> Option<Indent> {
use crate::config::IndentStyle;
let mut tabs = 0usize;
let mut spaced = 0usize;
let mut hist = [0usize; 9]; for line in text.lines().take(1000) {
let mut bytes = line.bytes();
match bytes.next() {
Some(b'\t') => {
tabs += 1;
continue;
}
Some(b' ') => {}
_ => continue,
}
let spaces = 1 + bytes.take_while(|b| *b == b' ').count();
if line
.bytes()
.nth(spaces)
.is_some_and(|b| b != b'\n' && b != b'\r')
{
spaced += 1;
if spaces <= 8 {
hist[spaces] += 1;
}
}
}
if tabs == 0 && spaced == 0 {
return None;
}
if tabs > spaced {
return Some(Indent {
style: IndentStyle::Tabs,
width: 4, });
}
let (covered, unit) = [2usize, 3, 4, 8]
.into_iter()
.map(|unit| {
let covered: usize = (unit..=8).filter(|n| n % unit == 0).map(|n| hist[n]).sum();
(covered, unit)
})
.max()?;
(covered * 5 >= spaced * 3).then_some(Indent {
style: IndentStyle::Spaces,
width: unit,
})
}
pub struct Document {
pub buf: Buffer,
pub indent: Indent,
pub source: DocumentSource,
}
impl Document {
pub fn new(buf: Buffer) -> Self {
Self {
buf,
indent: Indent::default(),
source: DocumentSource::File,
}
}
pub fn scratch(buf: Buffer) -> Self {
Self {
buf,
indent: Indent::default(),
source: DocumentSource::Scratch,
}
}
pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
buf.readonly = true;
Self {
buf,
indent: Indent::default(),
source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
context,
content: surface,
})),
}
}
pub fn output(mut buf: Buffer) -> Self {
buf.readonly = true;
Self {
buf,
indent: Indent::default(),
source: DocumentSource::Output,
}
}
pub fn container_file(
mut buf: Buffer,
container: strop_workspace::ContainerId,
path: std::path::PathBuf,
) -> Self {
buf.readonly = true;
Self {
buf,
indent: Indent::default(),
source: DocumentSource::Container { container, path },
}
}
pub fn syntax_path(&self) -> Option<&std::path::Path> {
match &self.source {
DocumentSource::Remote(source) => Some(source.file.path()),
DocumentSource::Surface(source) => match &source.content {
Surface::Diff {
commit: Some(commit),
..
} => Some(&commit.current),
Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
_ => None,
},
DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
DocumentSource::Container { path, .. } => Some(path),
DocumentSource::RemoteDirectory(_) | DocumentSource::Output => None,
}
}
pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
match (&self.source, target) {
(DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
location.absolute_file() == Some(&source.file)
}
(
DocumentSource::RemoteDirectory(source),
crate::files::FileTarget::Remote(location),
) => location.absolute_file() == Some(&source.directory),
(DocumentSource::File, crate::files::FileTarget::Local(path)) => {
self.buf.path.as_ref() == Some(path)
|| self.buf.file_identity() == Some(path.as_path())
}
_ => false,
}
}
pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
use crate::files::FileTarget;
match &self.source {
DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
DocumentSource::RemoteDirectory(source) => {
Some(FileTarget::Remote(source.directory.clone().into()))
}
_ => self
.buf
.file_identity()
.or(self.buf.path.as_deref())
.map(|path| FileTarget::Local(cwd.join(path))),
}
}
pub fn surface_payload(&self) -> Option<&Surface> {
match &self.source {
DocumentSource::Surface(s) => Some(&s.content),
_ => None,
}
}
pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
match &mut self.source {
DocumentSource::Surface(s) => Some(&mut s.content),
_ => None,
}
}
pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
match &self.source {
DocumentSource::Surface(surface) => Some(&surface.context),
_ => None,
}
}
}
impl Editor {
pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
self.mru.retain(|&x| x != i);
self.mru.insert(0, i);
}
pub fn cur(&self) -> &Document {
self.docs
.get(self.current())
.expect("invariant: current document is live")
}
pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
self.doc_mut(self.current())
}
pub fn buf(&self) -> &Buffer {
&self.cur().buf
}
pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
super::transact::BufferEdit::new(self.cur_mut())
}
pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
self.docs.get(id).expect("stale document id")
}
pub(crate) fn doc_mut(
&mut self,
id: strop_core::id::DocumentId,
) -> super::transact::DocumentEdit<'_> {
super::transact::DocumentEdit::new(self, id)
}
#[cfg(any(test, feature = "test-support"))]
pub fn first_doc(&self) -> strop_core::id::DocumentId {
self.docs
.iter()
.next()
.map(|(id, _)| id)
.expect("test document")
}
pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
let scratch = self.docs.iter().find_map(|(id, d)| {
let b = &d.buf;
(b.path.is_none()
&& !b.dirty
&& b.len_bytes() == 0
&& b.name.is_none()
&& id != replacement)
.then_some(id)
});
let Some(scratch) = scratch else {
return;
};
if self
.pending
.prompt()
.is_some_and(|prompt| prompt.origin().pane.doc == scratch)
{
self.cancel_pending();
}
for pane in &mut self.panes {
if pane.doc == scratch {
pane.doc = replacement;
}
}
self.lsp_close_document(scratch);
self.docs.remove(scratch);
self.mru.retain(|&x| x != scratch);
if self.view().doc == scratch {
self.view_mut().doc = replacement;
}
}
#[inline]
pub fn current(&self) -> strop_core::id::DocumentId {
self.view().doc
}
pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
self.cancel_pending();
self.cancel_open(strop_core::worker::CancelReason::Superseded);
self.focus_epoch += 1;
self.view_mut().doc = id;
self.touch_mru(id);
}
#[inline]
pub fn sels(&self) -> &strop_core::selection::SelectionSet {
&self.view().sels
}
#[inline]
pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
&mut self.view_mut().sels
}
#[inline]
pub fn view_rows(&self) -> usize {
self.view_rows
}
pub fn view_top(&self) -> usize {
self.view().view_top
}
pub fn close_buffer(&mut self, force: bool) -> bool {
let view_only = self.collections.contains_key(&self.current());
if self.buf().dirty && !force && !view_only {
self.message = "unsaved changes — :q! to force".into();
return false;
}
self.cancel_pending();
self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
if self.docs.len() == 1 {
self.request_session_save();
}
let closed = self.current();
self.revoke_remote_write(closed);
self.analysis
.forget(super::analysis::AnalysisTarget::Document(closed));
self.stop_remote_follow(closed);
self.cancel_remote_filter(closed);
self.lsp_close_document(closed);
self.shell_document_closed(closed);
self.revoke_git_requests_for(closed);
self.blame_gutters.remove(&closed);
self.collections.remove(&closed);
self.containers.buffers.remove(&closed);
self.containers.entries.remove(&closed);
let return_to = self
.docs
.remove(closed)
.and_then(|document| document.return_point().cloned());
if self.docs.is_empty() {
self.panes.clear();
self.active_pane = 0;
self.should_quit = true;
} else {
self.mru.retain(|&x| x != closed);
self.generation += 1; let next = self.mru.first().copied().unwrap_or_else(|| {
self.docs
.iter()
.next()
.map(|(id, _)| id)
.expect("docs non-empty")
});
for pane in &mut self.panes {
if pane.doc == closed {
pane.doc = next;
pane.sels = Default::default();
pane.view_top = 0;
pane.hscroll = strop_core::id::DisplayColumn::new(0);
pane.desired_column = None;
}
}
self.switch_to(next);
self.set_head(0);
self.view_mut().view_top = 0;
self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
if let Some(ret) = return_to {
if self.docs.get(ret.buffer).is_some() {
if ret.buffer != self.current() {
self.view_mut().doc = ret.buffer;
self.touch_mru(ret.buffer);
}
self.set_head(ret.cursor.min(self.buf().len_bytes()));
self.view_mut().view_top = ret.view_top;
self.view_mut().hscroll = ret.hscroll;
}
}
}
self.lsp_retire_remote_servers();
true
}
pub fn any_dirty(&self) -> bool {
self.docs.iter().any(|(_, d)| d.buf.dirty)
}
pub fn ctrl_c_quit(&mut self) -> bool {
if self.ctrl_c_armed || !self.any_dirty() {
return true;
}
self.ctrl_c_armed = true;
self.message = "unsaved changes — ctrl-c again to force-quit".into();
false
}
#[cfg(any(test, feature = "test-support"))]
pub fn open_fixture(
&mut self,
path: &std::path::Path,
) -> Result<strop_core::id::DocumentId, String> {
self.request_open(
path.to_owned(),
super::io::OpenIntent::Switch { readonly: false },
);
self.wait_io()?;
Ok(self.current())
}
}
#[cfg(test)]
mod pathbuf_tests {
use super::*;
use std::os::unix::ffi::OsStrExt;
#[test]
fn non_utf8_filename_opens_highlights_and_saves() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
std::fs::write(&path, "fn main() {}\n").unwrap();
let mut e = Editor::new(Buffer::from_text("x\n"));
let id = e.open_fixture(&path).expect("opens by bytes");
e.switch_to(id);
assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
&& span.end == 2
&& span.class == strop_syntax::Class::Keyword));
e.feed_text("dd"); e.feed_text(":w\r");
e.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
}
}