ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
378 lines
14 KiB
Rust
378 lines
14 KiB
Rust
//! Files, outline and search beside the document (MARK-05, MARK-10).
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use ely_gpui_component::{
|
|
buttons::{Button, SegmentedControl},
|
|
feedback::EmptyState,
|
|
forms::{InputEvent, SearchInput, TextInput},
|
|
lists::{FileTree, ListItem, Outline, SelectableList},
|
|
primitives::IconName,
|
|
theme::ControlSize,
|
|
};
|
|
use ely_office::prelude::*;
|
|
use ely_ui::{commands, i18n::text};
|
|
use gpui::{
|
|
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
SharedString, Styled as _, WeakEntity, Window, div,
|
|
};
|
|
use serde_json::json;
|
|
|
|
use crate::desk::Desk;
|
|
use crate::folder::{Hit, MOST_HITS, lines_with};
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum Pane {
|
|
Files,
|
|
Outline,
|
|
Search,
|
|
}
|
|
|
|
pub struct Side {
|
|
desk: WeakEntity<Desk>,
|
|
pane: Pane,
|
|
query: Entity<TextInput>,
|
|
/// Search the whole folder, else the shown file.
|
|
wide: bool,
|
|
/// Each hit, with its tab when the file is open.
|
|
hits: Vec<(Option<u64>, Hit)>,
|
|
/// Searches started; a late answer is dropped.
|
|
runs: u64,
|
|
/// The words searched, and the tab when one file was.
|
|
searched: String,
|
|
tab: Option<u64>,
|
|
/// The folder listing searched.
|
|
listing: u64,
|
|
/// Files that could not be read, and why.
|
|
missed: Vec<(PathBuf, String)>,
|
|
}
|
|
|
|
impl Side {
|
|
pub fn new(desk: &Entity<Desk>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
// A new shown file is searched again.
|
|
cx.observe(desk, |side, desk, cx| {
|
|
let (active, listing) = (desk.read(cx).core.active, desk.read(cx).listings);
|
|
let shown = !side.wide && side.tab != active;
|
|
let listed = side.wide && side.listing != listing;
|
|
if !side.searched.is_empty() && (shown || listed) {
|
|
side.search(cx);
|
|
}
|
|
cx.notify();
|
|
})
|
|
.detach();
|
|
let query = cx.new(|cx| TextInput::new(window, cx));
|
|
cx.subscribe(&query, |side, _, event: &InputEvent, cx| {
|
|
if *event == InputEvent::Submit {
|
|
side.search(cx);
|
|
}
|
|
})
|
|
.detach();
|
|
Side {
|
|
desk: desk.downgrade(),
|
|
pane: Pane::Files,
|
|
query,
|
|
wide: false,
|
|
hits: Vec::new(),
|
|
runs: 0,
|
|
searched: String::new(),
|
|
tab: None,
|
|
listing: 0,
|
|
missed: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Lines holding the query, off the window.
|
|
fn search(&mut self, cx: &mut Context<Self>) {
|
|
let query = self.query.read(cx).text().trim().to_owned();
|
|
let Some(desk) = self.desk.upgrade() else {
|
|
return;
|
|
};
|
|
self.runs += 1;
|
|
let run = self.runs;
|
|
let desk = desk.read(cx);
|
|
(self.tab, self.listing) = (desk.core.active, desk.listings);
|
|
self.searched = query.clone();
|
|
if query.is_empty() {
|
|
(self.hits, self.missed) = (Vec::new(), Vec::new());
|
|
return cx.notify();
|
|
}
|
|
// Open files search as they are now, saved or not.
|
|
let open: Vec<(u64, PathBuf, String)> = desk
|
|
.core
|
|
.tabs
|
|
.iter()
|
|
.filter_map(|tab| {
|
|
let name = PathBuf::from(tab.name.as_ref());
|
|
let path = tab.session.path().map_or(name, PathBuf::from);
|
|
Some((tab.id, path, desk.text_of(tab.id)?))
|
|
})
|
|
.collect();
|
|
// Open files by real path, as the folder lists them.
|
|
let reals: Vec<(u64, PathBuf)> = open
|
|
.iter()
|
|
.filter_map(|(id, path, _)| Some((*id, std::fs::canonicalize(path).ok()?)))
|
|
.collect();
|
|
let sources: Vec<(Option<u64>, PathBuf)> = match (self.wide, &desk.folder) {
|
|
(true, Some(folder)) => folder
|
|
.files
|
|
.iter()
|
|
.map(|file| folder.root.join(file))
|
|
.map(|path| {
|
|
let held = reals
|
|
.iter()
|
|
.find(|(_, real)| *real == path)
|
|
.map(|(id, _)| *id);
|
|
(held, path)
|
|
})
|
|
.collect(),
|
|
_ => desk
|
|
.active_tab()
|
|
.map(|tab| (Some(tab.id), PathBuf::new()))
|
|
.into_iter()
|
|
.collect(),
|
|
};
|
|
log::info!("mark: searches {} files", sources.len());
|
|
cx.spawn(async move |side, cx| {
|
|
let hits = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
let (mut hits, mut missed) = (Vec::new(), Vec::new());
|
|
for (tab, path) in sources {
|
|
let held = open.iter().find(|(id, ..)| Some(*id) == tab);
|
|
let (path, text) = match held {
|
|
Some((_, path, text)) => (path.clone(), text.clone()),
|
|
None => {
|
|
match std::fs::read(&path).and_then(crate::text::Source::decode) {
|
|
Ok(source) => (path, source.text),
|
|
Err(error) => {
|
|
log::warn!(
|
|
"mark: {} was not searched: {error}",
|
|
path.display()
|
|
);
|
|
missed.push((path, error.to_string()));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let mut found = Vec::new();
|
|
lines_with(&path, &text, &query, &mut found);
|
|
hits.extend(found.into_iter().map(|hit| (tab, hit)));
|
|
if hits.len() >= MOST_HITS {
|
|
hits.truncate(MOST_HITS);
|
|
break;
|
|
}
|
|
}
|
|
(hits, missed)
|
|
})
|
|
.await;
|
|
side.update(cx, |side, cx| {
|
|
if side.runs == run {
|
|
(side.hits, side.missed) = hits;
|
|
cx.notify();
|
|
}
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn files(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
|
let read = desk.read(cx);
|
|
if let Some(folder) = &read.folder {
|
|
let (root, opener) = (folder.root.clone(), desk.downgrade());
|
|
return FileTree::new("mark-files", folder.files.clone())
|
|
.on_open(move |path, _, cx| {
|
|
let path = root.join(path.as_ref());
|
|
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
|
})
|
|
.size_full()
|
|
.into_any_element();
|
|
}
|
|
if read.recent.is_empty() {
|
|
let open = Button::new("mark-open", text(cx, "command.file.open", &[]))
|
|
.on_click(|_, window, cx| window.dispatch_action(Box::new(commands::Open), cx));
|
|
return EmptyState::new(
|
|
"mark-no-recent",
|
|
IconName::Folder,
|
|
text(cx, "mark.recent.none", &[]),
|
|
)
|
|
.action(open)
|
|
.into_any_element();
|
|
}
|
|
let opener = desk.downgrade();
|
|
let rows = read.recent.iter().map(|path| {
|
|
let name = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let key: SharedString = path.to_string_lossy().into_owned().into();
|
|
let item = ListItem::new(key.clone(), name).description(
|
|
path.parent()
|
|
.map(|dir| dir.display().to_string())
|
|
.unwrap_or_default(),
|
|
);
|
|
(key, item)
|
|
});
|
|
rows.fold(
|
|
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
|
|list, (key, item)| list.row(key, item),
|
|
)
|
|
.on_activate(move |key, _, cx| {
|
|
let path = PathBuf::from(key.as_ref());
|
|
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
|
let Some(view) = desk.read(cx).active_view() else {
|
|
return EmptyState::new(
|
|
"mark-no-outline",
|
|
IconName::File,
|
|
text(cx, "command.blocked.no_document", &[]),
|
|
)
|
|
.into_any_element();
|
|
};
|
|
let headings = view.read(cx).outline.clone();
|
|
if headings.is_empty() {
|
|
return EmptyState::new(
|
|
"mark-no-headings",
|
|
IconName::File,
|
|
text(cx, "mark.outline.none", &[]),
|
|
)
|
|
.into_any_element();
|
|
}
|
|
let outline = headings.iter().enumerate().fold(
|
|
Outline::new("mark-outline"),
|
|
|outline, (at, heading)| {
|
|
outline.item(at.to_string(), heading.title.clone(), heading.level)
|
|
},
|
|
);
|
|
outline
|
|
.on_select(move |key, window, cx| {
|
|
if let Ok(at) = key.parse::<usize>() {
|
|
view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx));
|
|
// Keys go to the document, at the heading.
|
|
let focus = view.read(cx).focus.clone();
|
|
window.focus(&focus, cx);
|
|
}
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn search_pane(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
|
let this = cx.entity();
|
|
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
|
|
.size(ControlSize::Sm)
|
|
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
|
|
let scope = match desk.read(cx).folder {
|
|
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
|
|
None => scope,
|
|
};
|
|
let scope = scope.on_change(move |key, _, cx| {
|
|
this.update(cx, |side, cx| {
|
|
side.wide = key == "folder";
|
|
side.search(cx);
|
|
})
|
|
});
|
|
let opener = desk.downgrade();
|
|
let (hits, query) = (self.hits.clone(), self.searched.clone());
|
|
// Files not read come first: the results lack them.
|
|
let missed = self.missed.iter().enumerate().map(|(at, (path, why))| {
|
|
let name = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let title = text(cx, "mark.search.missed", &[("name", &name)]);
|
|
(
|
|
SharedString::from(format!("missed-{at}")),
|
|
ListItem::new(format!("missed-{at}"), title).description(why.clone()),
|
|
)
|
|
});
|
|
let rows = self.hits.iter().enumerate().map(|(at, (_, hit))| {
|
|
let name = hit
|
|
.file
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let item = ListItem::new(at.to_string(), hit.text.clone())
|
|
.description(format!("{name}:{}", hit.line));
|
|
(SharedString::from(at.to_string()), item)
|
|
});
|
|
let list = missed
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.chain(rows)
|
|
.fold(
|
|
SelectableList::new("mark-hits").label(text(cx, "mark.search", &[])),
|
|
|list, (key, item)| list.row(key, item),
|
|
)
|
|
.on_activate(move |key, window, cx| {
|
|
let Some((tab, hit)) = key.parse::<usize>().ok().and_then(|at| hits.get(at)) else {
|
|
return;
|
|
};
|
|
let (tab, file, line) = (*tab, hit.file.clone(), hit.line);
|
|
let place = json!({ "line": line, "query": query });
|
|
opener
|
|
.update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx))
|
|
.ok();
|
|
});
|
|
let more = (self.hits.len() == MOST_HITS)
|
|
.then(|| text(cx, "mark.search.more", &[("count", &MOST_HITS.to_string())]));
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_2()
|
|
.size_full()
|
|
.child(SearchInput::new("mark-search", &self.query))
|
|
.child(scope)
|
|
.child(list)
|
|
.children(more)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
impl Render for Side {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let Some(desk) = self.desk.upgrade() else {
|
|
return div().into_any_element();
|
|
};
|
|
let this = cx.entity();
|
|
let key = match self.pane {
|
|
Pane::Files => "files",
|
|
Pane::Outline => "outline",
|
|
Pane::Search => "search",
|
|
};
|
|
let panes = SegmentedControl::new("mark-pane", key)
|
|
.size(ControlSize::Sm)
|
|
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
|
|
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
|
|
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
|
|
.on_change(move |key, _, cx| {
|
|
this.update(cx, |side, cx| {
|
|
side.pane = match key.as_ref() {
|
|
"outline" => Pane::Outline,
|
|
"search" => Pane::Search,
|
|
_ => Pane::Files,
|
|
};
|
|
cx.notify();
|
|
})
|
|
});
|
|
let body = match self.pane {
|
|
Pane::Files => self.files(&desk, cx),
|
|
Pane::Outline => self.outline(&desk, cx),
|
|
Pane::Search => self.search_pane(&desk, cx),
|
|
};
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_2()
|
|
.p_2()
|
|
.size_full()
|
|
.child(panes)
|
|
.child(div().flex_1().min_h_0().child(body))
|
|
.into_any_element()
|
|
}
|
|
}
|