Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,377 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user