ElyMark: find and replace, list continuation, indent, table menu, fence colors (C14.9)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 06:20:25 -04:00
1 parent dbd841c786
commit d4a7d3ebd7
13 files changed
+477 -10

No files matched your search

+10 -2
View File
@@ -53,6 +53,8 @@ pub struct Desk {
asked: Option<u64>,
/// The shown file's fingerprint when Git was last asked.
asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
}
impl Host for Desk {
@@ -115,6 +117,9 @@ impl Host for Desk {
self.look(id, cx);
}
self.restore_shown(id, cx);
if self.finding.is_some() {
self.count(cx);
}
// A save changes what Git sees.
let print = self.active_tab().and_then(|tab| tab.session.fingerprint());
if print != self.asked_print {
@@ -196,6 +201,7 @@ pub(crate) fn open_in(
tracked: None,
asked: None,
asked_print: None,
finding: None,
}
});
let menus = crate::keys::menus(&look, cx);
@@ -413,6 +419,7 @@ impl Render for Desk {
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_find))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, window, cx| {
desk.switch(Mode::Visual, window, cx)
@@ -426,8 +433,9 @@ impl Render for Desk {
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.compare_dialog(cx));
Self::look_actions(root, cx)
.children(self.compare_dialog(cx))
.children(self.find_box(cx));
Self::table_actions(Self::look_actions(root, cx), cx)
}
}
+5 -1
View File
@@ -7,7 +7,10 @@ use super::{Desk, open_in};
use crate::view::Mode;
/// ElyMark over a fresh data folder, as `main` opens it.
pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity<Desk>, &'static mut VisualTestContext) {
pub(crate) fn start(
cx: &mut TestAppContext,
data: &Path,
) -> (Entity<Desk>, &'static mut VisualTestContext) {
// The file watcher runs on a real thread.
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
@@ -28,6 +31,7 @@ pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity<Desk>, &'st
.expect("a window")
});
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
let desk = held.take().expect("the desk");
(desk, cx)
+247
View File
@@ -0,0 +1,247 @@
//! Find and replace in the shown file (MARK-06).
use std::ops::Range;
use ely_gpui_component::{
editor::{FindOptions, FindWidget, find_all},
forms::{InputEvent, TextInput},
primitives::{Severity, raise},
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement, ParentElement as _, SharedString, Styled as _, Subscription, Window, div,
};
use crate::desk::Desk;
use crate::view::MarkView;
/// The find box over one tab, and what it found.
pub struct Find {
tab: u64,
query: Entity<TextInput>,
replace: Option<Entity<TextInput>>,
options: FindOptions,
hits: Vec<Range<usize>>,
current: Option<usize>,
/// The pattern did not compile; why.
error: Option<SharedString>,
_query: Subscription,
}
impl Desk {
pub fn on_find(
&mut self,
_: &ely_office::FindReplace,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab) = self.core.active else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
if let Some(find) = self.finding.as_ref().filter(|find| find.tab == tab) {
return window.focus(&find.query.focus_handle(cx), cx);
}
self.close_find(window, cx);
let query = cx.new(|cx| TextInput::new(window, cx));
let subscription =
cx.subscribe_in(&query, window, |desk, _, event, window, cx| match event {
InputEvent::Changed => desk.count(cx),
InputEvent::Submit => desk.step(true, window, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
log::info!("mark: find opens on tab {tab}");
self.finding = Some(Find {
tab,
query,
replace: None,
options: FindOptions::default(),
hits: Vec::new(),
current: None,
error: None,
_query: subscription,
});
cx.notify();
}
fn found_view(&self) -> Option<(Entity<MarkView>, &Find)> {
let find = self.finding.as_ref()?;
Some((self.tab(find.tab)?.view.clone(), find))
}
/// Matches again in the tab's text, marked in source.
pub fn count(&mut self, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let query = find.query.read(cx).text().to_string();
let options = find.options;
let document = view.read(cx).document().clone();
let found = find_all(&document.read(cx).text(cx), &query, options);
let Some(find) = self.finding.as_mut() else {
return;
};
(find.hits, find.error) = match found {
Ok(hits) => (hits, None),
Err(why) => (Vec::new(), Some(why.into())),
};
find.current = find.current.filter(|current| *current < find.hits.len());
let (hits, current) = (find.hits.clone(), find.current);
mark(&document, &hits, current, cx);
cx.notify();
}
fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let count = find.hits.len();
if count == 0 {
return;
}
let current = match (find.current, forward) {
(None, true) => 0,
(None, false) => count - 1,
(Some(at), true) => (at + 1) % count,
(Some(at), false) => (at + count - 1) % count,
};
let range = find.hits[current].clone();
let hits = find.hits.clone();
if let Some(find) = self.finding.as_mut() {
find.current = Some(current);
}
let document = view.read(cx).document().clone();
mark(&document, &hits, Some(current), cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
cx.notify();
}
fn replace(&mut self, every: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let with = find
.replace
.as_ref()
.map_or(String::new(), |field| field.read(cx).text().to_string());
let edits: Vec<(Range<usize>, String)> = match (every, find.current) {
(true, _) => find
.hits
.iter()
.map(|hit| (hit.clone(), with.clone()))
.collect(),
(false, Some(at)) => vec![(find.hits[at].clone(), with)],
(false, None) => return self.step(true, window, cx),
};
if edits.is_empty() {
return;
}
let document = view.read(cx).document().clone();
document.update(cx, |document, cx| document.replace(edits, cx));
self.count(cx);
// The next match takes the replaced one's place.
let next = self
.finding
.as_ref()
.and_then(|find| find.current.and_then(|at| find.hits.get(at)).cloned());
if let Some(range) = next.filter(|_| !every) {
document.update(cx, |document, cx| document.show_match(range, window, cx));
}
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(find) = self.finding.take() else {
return;
};
if let Some(tab) = self.tab(find.tab) {
let document = tab.view.read(cx).document().clone();
mark(&document, &[], None, cx);
window.focus(&tab.view.read(cx).focus.clone(), cx);
}
cx.notify();
}
/// The find box over the file, while open.
pub fn find_box(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let find = self
.finding
.as_ref()
.filter(|find| Some(find.tab) == self.core.active)?;
let this = cx.entity();
let mut widget = FindWidget::new("mark-find", &find.query, find.current, find.hits.len())
.options(find.options)
.on_options({
let this = this.clone();
move |options, _, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.options = options;
}
desk.count(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, window, cx| {
this.update(cx, |desk, cx| desk.step(forward, window, cx))
}
})
.on_replace({
let this = this.clone();
move |every, window, cx| this.update(cx, |desk, cx| desk.replace(every, window, cx))
})
.on_toggle_replace({
let this = this.clone();
move |show, window, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx)));
}
cx.notify();
})
}
})
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
if let Some(field) = &find.replace {
widget = widget.replace(field);
}
if let Some(error) = &find.error {
widget = widget.error(error.clone());
}
let place = div()
.absolute()
.top_2()
.right_4()
.block_mouse_except_scroll()
.child(widget);
Some(raise("mark-find-layer", place).into_any_element())
}
}
/// The matches marked in the source; the current one stronger.
fn mark(
document: &Entity<ely_gpui_component::documents::MarkdownDocument>,
hits: &[Range<usize>],
current: Option<usize>,
cx: &mut Context<Desk>,
) {
let tone = cx.theme().colors.warning;
let marks = hits
.iter()
.enumerate()
.map(|(at, hit)| {
let strength = if Some(at) == current { 0.45 } else { 0.2 };
(hit.clone(), tone.opacity(strength))
})
.collect();
let editor = document.read(cx).editor().clone();
editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx));
}
#[cfg(test)]
mod tests;
+120
View File
@@ -0,0 +1,120 @@
use ely_gpui_component::forms::TextInput;
use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
use crate::desk::{
Desk,
tests::{open, start},
};
fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
(desk, cx, [data, files])
}
fn query(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
cx.simulate_input(words);
cx.run_until_parked();
}
fn current_text(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).text(cx)
})
}
#[gpui::test]
fn every_match_is_counted_and_replaced_in_one_step(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a b a\n", cx);
query(&desk, "a", cx);
assert_eq!(
desk.read_with(cx, |desk, _| desk
.finding
.as_ref()
.map(|find| find.hits.len())),
Some(2)
);
desk.update_in(cx, |desk, window, cx| {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text("x", cx));
desk.finding.as_mut().expect("open").replace = Some(field);
desk.replace(true, window, cx);
});
cx.run_until_parked();
assert_eq!(current_text(&desk, cx), "x b x\n");
let focus = desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a tab").read(cx).focus.clone()
});
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
current_text(&desk, cx),
"a b a\n",
"one undo takes all back"
);
}
#[gpui::test]
fn a_bad_pattern_says_why(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
desk.update(cx, |desk, cx| {
desk.finding.as_mut().expect("open").options.regex = true;
desk.count(cx);
});
cx.simulate_input("(");
cx.run_until_parked();
let error = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.error.clone())
});
assert!(error.is_some());
}
#[gpui::test]
fn a_step_shows_the_next_match(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("one\n\ntwo one\n", cx);
query(&desk, "one", cx);
desk.update_in(cx, |desk, window, cx| {
desk.step(true, window, cx);
desk.step(true, window, cx);
});
let current = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.current)
});
assert_eq!(current, Some(1));
}
#[gpui::test]
fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
assert!(current_text(&desk, cx).contains("| --- | --- | --- |"));
cx.dispatch_action(crate::tools::DeleteColumn);
assert!(
current_text(&desk, cx).contains("| --- | --- |\n"),
"{}",
current_text(&desk, cx)
);
}
+17
View File
@@ -17,6 +17,7 @@ pub fn bind(cx: &mut App) {
KeyBinding::new("secondary-=", crate::look::Larger, at),
KeyBinding::new("secondary--", crate::look::Smaller, at),
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
]);
}
@@ -35,7 +36,23 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
disabled: false,
};
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
use crate::tools::{
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
};
vec![
Menu::new(text(cx, "menu.edit", &[]))
.items([item("office.find.open", Box::new(ely_office::FindReplace))]),
Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
]),
Menu::new(text(cx, "menu.view", &[])).items([
MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode),
+2
View File
@@ -5,6 +5,7 @@ mod autosave;
mod compare;
mod desk;
mod engine;
mod finding;
mod folder;
mod keys;
mod look;
@@ -14,6 +15,7 @@ mod rename;
mod restore;
mod side;
mod text;
mod tools;
mod vcs;
mod view;
+67
View File
@@ -0,0 +1,67 @@
//! The table menu's work on the shown file (MARK-06).
use ely_gpui_component::{documents::TableEdit, primitives::Severity};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{Context, InteractiveElement as _, actions};
use crate::desk::Desk;
actions!(
mark,
[
InsertTable,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRow,
DeleteColumn
]
);
impl Desk {
fn table(&mut self, edit: Option<TableEdit>, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
let document = view.read(cx).document().clone();
let done = document.update(cx, |document, cx| match edit {
Some(edit) => document.edit_table(edit, cx),
None => {
document.insert_table(cx);
true
}
});
if !done {
let said = text(cx, "mark.table.none", &[]);
self.toast(said, Severity::Info, cx);
}
}
pub fn table_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
element
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx)))
.on_action(
cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)),
)
.on_action(
cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)),
)
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| {
desk.table(Some(TableEdit::ColumnLeft), cx)
}))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| {
desk.table(Some(TableEdit::ColumnRight), cx)
}))
.on_action(
cx.listener(|desk, _: &DeleteRow, _, cx| {
desk.table(Some(TableEdit::DeleteRow), cx)
}),
)
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| {
desk.table(Some(TableEdit::DeleteColumn), cx)
}))
}
}
+4
View File
@@ -131,6 +131,10 @@ impl MarkView {
}
}
pub(crate) fn document(&self) -> &Entity<MarkdownDocument> {
&self.document
}
/// Every change is in the text at once.
pub fn settled(&self) -> bool {
true