use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
use iced::widget::{Space, button, column, container, row, scrollable, text, text_input};
use iced::{
Alignment, Element, Length, Subscription, Task,
keyboard::{self},
widget::Id,
};
use iced_fonts::lucide;
use fff_search::grep::{GrepMode, GrepSearchOptions};
use fff_search::parse_grep_query;
use super::context_menu::{ContextMenu, MenuItem};
use crate::git_commands::{is_git_repo, run_git_check_ignore, run_git_output, run_git_status};
use crate::util::unquote_c_style;
use super::common::{UndoSnapshot, UndoStack};
use super::editor_widget::{LineEnding, detect_line_ending};
use crate::tools::MAX_FILE_SIZE_BYTES as MAX_FILE_SIZE;
use super::editor_widget::{EditorBuffer, byte_offset_to_line_col};
use super::theme;
use super::widget_helpers;
use super::widgets::{self, FileTree, TreeNavDirection};
mod editor_dialog;
const TAB_CHAR_ADVANCE: f32 = 7.2;
const TAB_FIXED_CHROME: f32 = 16.0 + 2.0 + 12.0;
const TAB_DIRTY_EXTRA: f32 = 8.0 + 2.0;
#[expect(clippy::cast_precision_loss)]
fn estimate_tab_width(tab: &Tab) -> f32 {
let name_w = tab.file_name.chars().count() as f32 * TAB_CHAR_ADVANCE;
let dirty = if tab.is_dirty { TAB_DIRTY_EXTRA } else { 0.0 };
name_w + TAB_FIXED_CHROME + dirty
}
fn estimated_tab_left(tabs: &[Tab], idx: usize) -> f32 {
tabs.iter().take(idx).map(estimate_tab_width).sum()
}
fn estimated_content_width(tabs: &[Tab]) -> f32 {
tabs.iter().map(estimate_tab_width).sum()
}
const TICK_INTERVAL_SECS: u64 = 5;
const DIR_REFRESH_INTERVAL_SECS: u64 = 30;
const EDITOR_FONT_SIZE: f32 = 13.0;
const FIND_SEARCH_ID: &str = "find_search_input";
const FIND_REPLACE_ID: &str = "find_replace_input";
const GLOBAL_SEARCH_INPUT_ID: &str = "global_search_input";
const GOTO_LINE_INPUT_ID: &str = "goto_line_input";
const QUICK_OPEN_INPUT_ID: &str = "quick_open_input";
const NEW_ITEM_INPUT_ID: &str = "new_item_input";
const MAX_GLOBAL_SEARCH_RESULTS: usize = 200;
const GLOBAL_SEARCH_MATCHES_PER_FILE: usize = 20;
const GLOBAL_SEARCH_DEBOUNCE_MS: u64 = 300;
#[must_use]
fn is_os_file(name: &str) -> bool {
name.eq_ignore_ascii_case(".ds_store")
|| name.eq_ignore_ascii_case("thumbs.db")
|| name.eq_ignore_ascii_case("desktop.ini")
}
fn empty_placeholder(
text: iced::widget::Text<'_, iced::Theme, iced::Renderer>,
) -> Element<'_, EditorMessage> {
container(text)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
#[derive(Debug, Clone)]
pub struct FsEntry {
pub name: String,
pub full_path: String,
pub is_dir: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitFileStatus {
Modified,
Added,
}
#[derive(Debug, Clone)]
struct Tab {
path: String,
file_name: String,
is_dirty: bool,
line_ending: LineEnding,
}
struct TabData {
content: super::editor_widget::EditorBuffer,
undo_stack: RefCell<UndoStack>,
find_replace_state: Option<FindReplaceState>,
saved_text_hash: u64,
}
fn hash_text(text: &str) -> u64 {
use std::hash::Hasher;
let mut h = std::collections::hash_map::DefaultHasher::new();
h.write(text.as_bytes());
h.finish()
}
fn make_tab_and_data(
path: &str,
text: &str,
line_ending: LineEnding,
is_dirty: bool,
saved_text_hash: u64,
) -> (Tab, TabData, Option<SystemTime>) {
let content = EditorBuffer::from_file(text, path);
let file_name = Path::new(path)
.file_name()
.map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
let tab = Tab {
path: path.to_string(),
file_name,
is_dirty,
line_ending,
};
let tab_data = TabData {
content,
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: None,
saved_text_hash,
};
let mtime = std::fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok());
(tab, tab_data, mtime)
}
#[derive(Debug, Clone)]
struct FindReplaceState {
query: String,
replace: String,
matches: Vec<std::ops::Range<usize>>,
current_match_idx: usize,
case_sensitive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GlobalSearchStatus {
Idle,
Searching,
Done,
NoResults,
Error(String),
}
#[derive(Debug, Clone)]
pub struct OwnedGrepMatch {
abs_path: String,
rel_path: String,
line_number: u64,
line_content: String,
match_byte_offsets: Vec<(u32, u32)>,
}
#[derive(Debug, Clone)]
struct GlobalSearchState {
query: String,
results: Vec<OwnedGrepMatch>,
selected_index: usize,
status: GlobalSearchStatus,
}
use std::ops::Range;
#[must_use]
fn compute_text_matches(text: &str, query: &str, case_sensitive: bool) -> Vec<Range<usize>> {
if query.len() < 2 {
return Vec::new();
}
let mut matches = Vec::new();
if case_sensitive {
for (start, _) in text.match_indices(query) {
matches.push(start..start + query.len());
}
} else {
let text_lower = text.to_ascii_lowercase();
let query_lower = query.to_ascii_lowercase();
for (start, _) in text_lower.match_indices(&query_lower) {
matches.push(start..start + query.len());
}
}
matches
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FindDirection {
Next,
Prev,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TabDirection {
Next,
Prev,
}
#[derive(Debug, Clone)]
pub struct FileLoadData {
path: String,
text: String,
line_ending: LineEnding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseAction {
Save,
Discard,
Cancel,
}
#[derive(Debug, Clone)]
enum PendingCloseAction {
CloseTab(usize),
CloseOthers {
keep_idx: usize,
remaining_dirty: Vec<usize>,
},
}
#[derive(Debug, Clone)]
pub struct SavedTabData {
file_path: String,
text: String,
was_dirty: bool,
line_ending: LineEnding,
is_active: bool,
}
#[derive(Debug, Clone)]
pub enum EditorMessage {
WorkspaceSelected(String, Option<String>),
DirExpanded {
dir_path: String,
r#gen: u64,
entries: Result<Vec<FsEntry>, ReadDirError>,
quiet: bool,
},
DirDeleted {
dir_path: String,
workspace_path: String,
},
ToggleDir(String),
SelectFile(String),
TreeFocusToggled,
TreeNavUp,
TreeNavDown,
TreeNavEnter,
TreeNavLeft,
TreeNavRight,
TreeScrolled(f32, f32),
TabBarScrolled(f32, f32),
Escape,
FileLoaded {
path: String,
r#gen: u64,
result: Result<FileLoadData, String>,
},
SavedTabsLoaded {
tabs_data: Vec<SavedTabData>,
r#gen: u64,
},
TabSelected(usize),
TabClosed(usize),
EditorAction(super::editor_widget::EditorAction),
SaveActiveTab,
SaveResult {
path: String,
result: Result<(), String>,
saved_hash: u64,
},
CloseDialog {
tab_index: usize,
action: CloseAction,
},
CloseOthersDialog {
keep_idx: usize,
action: CloseAction,
},
Tick,
GitStatusLoaded {
r#gen: u64,
result: Result<HashMap<String, GitFileStatus>, String>,
},
GitIgnoredLoaded {
r#gen: u64,
result: Result<HashSet<String>, String>,
},
Toast(super::ToastMessage),
Undo,
Redo,
FindToggle,
FindQueryInput(String),
FindReplaceInput(String),
FindNext,
FindPrev,
FindReplace,
FindReplaceAll,
FindToggleCaseSensitivity,
RefreshFileTree,
CloseOtherTabs(usize),
CheckFileChanges,
FileReloaded {
path: String,
result: Result<String, String>,
cursor_line: usize,
cursor_col: usize,
},
GoToLineToggle,
GoToLineInput(String),
GoToLineGo,
QuickOpenToggle,
QuickOpenInput(String),
QuickOpenSelect(usize),
TabSwitchNext,
TabSwitchPrev,
CloseActiveTab,
GlobalSearchToggle,
GlobalSearchInput(String),
GlobalSearchResults {
r#gen: u64,
results: Vec<OwnedGrepMatch>,
error: Option<String>,
},
GlobalSearchSelect(usize),
DeleteFileRequested(String),
DeleteDirectoryRequested(String),
NewFileRequested(String),
NewDirectoryRequested(String),
RevealInFinder(String),
CopyRelativePath(String),
CopyAbsolutePath(String),
ConfirmDelete,
CancelDelete,
NewItemSubmit(String),
NewItemInput(String),
RenameRequested(String),
RenameInput(String),
RenameSubmit,
RenameCancel,
RenameCompleted {
old_path: String,
new_path: String,
is_dir: bool,
result: Result<(), String>,
dir_entries: Result<Vec<FsEntry>, ReadDirError>,
rename_gen: u64,
},
}
#[derive(Debug, Clone)]
struct DeleteConfirmTarget {
path: String,
is_dir: bool,
dirty_tab_count: usize,
abs_path: String,
}
#[derive(Debug, Clone)]
struct NewItemTarget {
parent_dir: String,
is_dir: bool,
abs_parent: String,
ws_root: String,
input_text: String,
}
#[derive(Debug, Clone)]
struct RenameTarget {
path: String,
abs_path: String,
is_dir: bool,
ws_root: String,
input_text: String,
error: Option<String>,
}
#[must_use]
fn rename_input_style(_theme: &iced::Theme, _status: text_input::Status) -> text_input::Style {
text_input::Style {
background: iced::Background::Color(iced::Color::TRANSPARENT),
border: iced::Border {
radius: 0.0.into(),
width: 0.0,
color: iced::Color::TRANSPARENT,
},
icon: theme::TEXT_MUTED,
placeholder: theme::TEXT_MUTED,
value: theme::TEXT_PRIMARY,
selection: theme::ACCENT_DIM,
}
}
#[must_use]
fn validate_item_name(name: &str) -> Option<&'static str> {
if name.is_empty() {
return Some("Name cannot be empty");
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Some("Name cannot contain path separators");
}
if name == "." || name == ".." {
return Some("Invalid name");
}
#[cfg(target_os = "windows")]
{
let reserved = [
"con", "nul", "prn", "aux", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
"com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
let stem = name.split('.').next().unwrap_or(name);
if reserved.contains(&stem.to_lowercase().as_str()) {
return Some("Name is reserved by the operating system");
}
}
None
}
fn rekey_compute_new_key(rest: &str, new_prefix: &str) -> String {
if rest.is_empty() {
new_prefix.to_string()
} else {
format!("{new_prefix}/{rest}")
}
}
fn rekey_keys(
old_prefix: &str,
new_prefix: &str,
keys: impl IntoIterator<Item = String>,
) -> Vec<(String, String)> {
keys.into_iter()
.filter_map(|k| {
let rest = k.strip_prefix(old_prefix)?;
let new_key = rekey_compute_new_key(rest, new_prefix);
Some((k, new_key))
})
.collect()
}
fn rekey_map_prefix<V>(
map: &mut HashMap<String, V>,
old_prefix: &str,
new_prefix: &str,
modify: impl Fn(&mut V),
) {
let key_pairs = rekey_keys(old_prefix, new_prefix, map.keys().cloned());
for (old_key, new_key) in key_pairs {
if let Some(mut v) = map.remove(&old_key) {
modify(&mut v);
map.insert(new_key, v);
}
}
}
fn rekey_set_prefix(set: &mut HashSet<String>, old_prefix: &str, new_prefix: &str) {
let key_pairs = rekey_keys(old_prefix, new_prefix, set.iter().cloned());
for (old_key, new_key) in key_pairs {
set.remove(&old_key);
set.insert(new_key);
}
}
fn update_entry_path(entry: &mut FsEntry, old_prefix: &str, new_prefix: &str) {
if let Some(rest) = entry.full_path.strip_prefix(old_prefix) {
entry.full_path = rekey_compute_new_key(rest, new_prefix);
}
}
#[derive(Debug, Clone)]
pub enum ReadDirError {
NotFound,
Other(String),
}
fn classify_read_dir_error(e: &std::io::Error) -> ReadDirError {
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) {
ReadDirError::NotFound
} else {
ReadDirError::Other(format!("{e}"))
}
}
async fn read_directory_entries(root: &str, rel_path: &str) -> Result<Vec<FsEntry>, ReadDirError> {
let dir_path = if rel_path.is_empty() {
root.to_string()
} else {
let p = Path::new(root).join(rel_path);
p.to_string_lossy().to_string()
};
let mut entries = match tokio::fs::read_dir(&dir_path).await {
Ok(rd) => rd,
Err(e) => return Err(classify_read_dir_error(&e)),
};
let mut result: Vec<FsEntry> = Vec::new();
let mut dirs: Vec<FsEntry> = Vec::new();
let mut files: Vec<FsEntry> = Vec::new();
loop {
let entry = match entries.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(e) => return Err(classify_read_dir_error(&e)),
};
let name = entry.file_name().to_string_lossy().to_string();
if name == ".git" {
continue;
}
if is_os_file(&name) {
continue;
}
let full_path = if rel_path.is_empty() {
name.clone()
} else {
format!("{rel_path}/{name}")
};
let abs_path = Path::new(&dir_path).join(&name);
let (is_dir, err) = match tokio::fs::metadata(&abs_path).await {
Ok(m) => (m.is_dir(), None),
Err(e) => (false, Some(format!("{e}"))),
};
let fs_entry = FsEntry {
name,
full_path,
is_dir: is_dir && err.is_none(),
error: err,
};
if fs_entry.is_dir {
dirs.push(fs_entry);
} else {
files.push(fs_entry);
}
}
dirs.sort_by_key(|e| e.name.to_lowercase());
files.sort_by_key(|e| e.name.to_lowercase());
result.extend(dirs);
result.extend(files);
Ok(result)
}
async fn dir_expanded_msg(
ws_path: String,
dir_path: String,
r#gen: u64,
quiet: bool,
) -> EditorMessage {
let entries = read_directory_entries(&ws_path, &dir_path).await;
EditorMessage::DirExpanded {
dir_path,
r#gen,
entries,
quiet,
}
}
fn dir_expanded_task(
ws_path: String,
dir_path: String,
r#gen: u64,
quiet: bool,
) -> Task<EditorMessage> {
Task::perform(dir_expanded_msg(ws_path, dir_path, r#gen, quiet), |msg| msg)
}
fn validate_file_content(bytes: &[u8]) -> Result<(), String> {
if (bytes.len() as u64) > MAX_FILE_SIZE {
return Err(format!(
"File too large ({} bytes, max {MAX_FILE_SIZE})",
bytes.len()
));
}
if bytes.contains(&0) {
return Err("Binary file detected (contains null bytes)".to_string());
}
Ok(())
}
fn file_loaded_msg(
path: String,
r#gen: u64,
result: Result<FileLoadData, String>,
) -> EditorMessage {
EditorMessage::FileLoaded {
path,
r#gen,
result,
}
}
async fn load_file_data(full_path: String, r#gen: u64) -> EditorMessage {
let bytes = match tokio::fs::read(&full_path).await {
Ok(b) => b,
Err(e) => {
return file_loaded_msg(full_path, r#gen, Err(format!("Cannot read file: {e}")));
}
};
if let Err(e) = validate_file_content(&bytes) {
return file_loaded_msg(full_path, r#gen, Err(e));
}
let Ok(text) = String::from_utf8(bytes) else {
return file_loaded_msg(
full_path,
r#gen,
Err("Binary file detected (invalid UTF-8)".to_string()),
);
};
let data = FileLoadData {
path: full_path,
line_ending: detect_line_ending(&text),
text,
};
file_loaded_msg(data.path.clone(), r#gen, Ok(data))
}
fn spawn_file_load(abs_path: String, file_gen: u64) -> Task<EditorMessage> {
Task::perform(load_file_data(abs_path, file_gen), |msg| msg)
}
#[must_use]
fn build_tab_records(
tabs: &[Tab],
active_index: usize,
tab_contents: &HashMap<String, TabData>,
) -> Vec<crate::workspace::EditorTabRecord> {
tabs.iter()
.enumerate()
.map(|(i, t)| crate::workspace::EditorTabRecord {
file_path: t.path.clone(),
tab_order: i,
is_active: i == active_index,
is_dirty: t.is_dirty,
dirty_content: if t.is_dirty {
tab_contents.get(&t.path).map(|d| d.content.text())
} else {
None
},
})
.collect()
}
fn save_tabs_to_db(
workspace_name: String,
records: Vec<crate::workspace::EditorTabRecord>,
save_gen: u64,
gen_counter: Arc<AtomicU64>,
) -> Task<EditorMessage> {
tokio::spawn(async move {
if gen_counter.load(Ordering::Acquire) != save_gen {
return;
}
let store = crate::workspace::store();
if let Err(e) = store.save_editor_tabs(&workspace_name, &records).await {
tracing::warn!("Failed to save editor tabs: {e}");
}
});
Task::none()
}
async fn save_file_to_disk(
path: String,
content: String,
line_ending: LineEnding,
) -> Result<(), String> {
let lf = content.replace("\r\n", "\n");
let normalized = if line_ending == LineEnding::Crlf {
lf.replace('\n', "\r\n")
} else {
lf
};
tokio::fs::write(&path, &normalized)
.await
.map_err(|e| format!("Failed to write file: {e}"))
}
fn build_save_task(
tabs: &[Tab],
tab_contents: &HashMap<String, TabData>,
idx: usize,
) -> Task<EditorMessage> {
debug_assert!(
idx < tabs.len(),
"idx {idx} out of bounds (len {})",
tabs.len()
);
let path = tabs[idx].path.clone();
let line_ending = tabs[idx].line_ending;
let Some(content) = tab_contents.get(&path).map(|d| d.content.text()) else {
tracing::error!(
?path,
idx,
"build_save_task: path not found in tab_contents — invariant violation"
);
return Task::perform(
async move {
EditorMessage::SaveResult {
path,
result: Err("Internal error: file content not found for save".into()),
saved_hash: 0,
}
},
|msg| msg,
);
};
let saved_hash = hash_text(&content);
Task::perform(
async move {
let result = save_file_to_disk(path.clone(), content, line_ending).await;
EditorMessage::SaveResult {
path,
result,
saved_hash,
}
},
|msg| msg,
)
}
fn update_dirty_flag(
tabs: &mut [Tab],
tab_contents: &HashMap<String, TabData>,
idx: usize,
path: &str,
) {
if let (Some(tab), Some(tab_data)) = (tabs.get_mut(idx), tab_contents.get(path)) {
let current_hash = hash_text(&tab_data.content.text());
tab.is_dirty = current_hash != tab_data.saved_text_hash;
}
}
fn build_hierarchical_tree(
dir_entries: &HashMap<String, Vec<FsEntry>>,
expanded_dirs: &HashSet<String>,
parent_path: &str,
) -> Vec<widgets::TreeNode> {
let Some(entries) = dir_entries.get(parent_path) else {
return Vec::new();
};
let mut nodes: Vec<widgets::TreeNode> = entries
.iter()
.map(|entry| {
let mut node = widgets::TreeNode {
name: entry.name.clone(),
full_path: entry.full_path.clone(),
is_dir: entry.is_dir,
children: Vec::new(),
error: entry.error.clone(),
};
if node.is_dir && expanded_dirs.contains(&node.full_path) {
node.children =
build_hierarchical_tree(dir_entries, expanded_dirs, &node.full_path);
}
node
})
.collect();
FileTree::sort_nodes(&mut nodes);
nodes
}
fn parse_git_status_porcelain(output: &str) -> HashMap<String, GitFileStatus> {
let mut map: HashMap<String, GitFileStatus> = HashMap::new();
for line in output.lines() {
let trimmed = line.trim_end();
if trimmed.len() < 2 {
continue;
}
let chars: Vec<char> = trimmed.chars().take(2).collect();
if chars.len() < 2 {
continue;
}
let ix = chars[0];
let wt = chars[1];
if ix == 'D' || wt == 'D' {
continue;
}
if ix == 'R' {
let rest = &trimmed[2..];
let rest = rest.trim_start();
let new_path: String = if rest.starts_with('"') {
if let Some((_, tail)) = rest.rsplit_once("\" -> \"") {
format!("\"{tail}")
} else {
continue;
}
} else {
if let Some((_, tail)) = rest.rsplit_once(" -> ") {
tail.to_string()
} else {
continue;
}
};
if let Some(unquoted) = unquote_c_style(&new_path) {
map.insert(unquoted, GitFileStatus::Modified);
}
continue;
}
let path = trimmed[2..].trim_start();
if path.is_empty() {
continue;
}
let status = if ix == 'M' || wt == 'M' {
GitFileStatus::Modified
} else if ix == 'A' || wt == 'A' || (ix == '?' && wt == '?') {
GitFileStatus::Added
} else {
continue;
};
let Some(path) = unquote_c_style(path) else {
continue;
};
let path = path.strip_suffix('/').unwrap_or(&path).to_string();
let entry = map.entry(path).or_insert(status);
if status == GitFileStatus::Modified && *entry == GitFileStatus::Added {
*entry = GitFileStatus::Modified;
}
}
map
}
async fn load_git_status(workspace_path: String) -> Result<HashMap<String, GitFileStatus>, String> {
let ws_path = Path::new(&workspace_path);
if !is_git_repo(ws_path) {
tracing::debug!("Workspace '{workspace_path}' is not a git repo — skipping git status");
return Ok(HashMap::new());
}
let output = run_git_status(ws_path).await.map_err(|e| e.to_string())?;
Ok(parse_git_status_porcelain(&output))
}
fn collect_tree_paths(nodes: &[widgets::TreeNode]) -> Vec<String> {
let mut paths = Vec::new();
for node in nodes {
paths.push(node.full_path.clone());
if node.is_dir {
paths.extend(collect_tree_paths(&node.children));
}
}
paths
}
async fn load_git_ignore(
workspace_path: String,
tree_paths: Vec<String>,
) -> Result<HashSet<String>, String> {
if tree_paths.is_empty() {
return Ok(HashSet::new());
}
let ws_path = Path::new(&workspace_path);
let output = run_git_output(ws_path, &["rev-parse", "--show-toplevel"])
.await
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
if !output.status.success() {
tracing::debug!(
"Workspace '{workspace_path}' is not in a git repo — skipping git ignore check"
);
return Ok(HashSet::new());
}
let repo_root = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = Path::new(&repo_root);
let ws_canonical = ws_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize workspace path: {e}"))?;
let prefix = ws_canonical
.strip_prefix(repo_path)
.map_err(|e| format!("Workspace is not inside git repo: {e}"))?;
let prefix_empty = prefix.as_os_str().is_empty();
let adjusted_paths: Vec<String> = if prefix_empty {
tree_paths
} else {
let prefix_str = prefix.to_string_lossy();
tree_paths
.iter()
.map(|p| format!("{prefix_str}/{p}"))
.collect()
};
let raw_ignored = run_git_check_ignore(repo_path, &adjusted_paths)
.await
.map_err(|e| e.to_string())?;
let ignored: HashSet<String> = if prefix_empty {
raw_ignored
} else {
let prefix_str = prefix.to_string_lossy();
let prefix_slash = format!("{prefix_str}/");
raw_ignored
.into_iter()
.filter_map(|p| {
p.strip_prefix(&prefix_slash)
.or_else(|| (p == prefix_str).then_some(""))
.map(ToString::to_string)
})
.collect()
};
Ok(ignored)
}
fn finish_git_load<T: Default>(
loading: &mut bool,
current_gen: u64,
r#gen: u64,
result: Result<T, String>,
cache: &mut T,
label: &str,
) -> Task<EditorMessage> {
*loading = false;
if r#gen != current_gen {
return Task::none();
}
match result {
Ok(value) => *cache = value,
Err(e) => {
tracing::warn!("Failed to load {label}: {e}");
*cache = T::default();
}
}
Task::none()
}
async fn run_global_search(
ws_path: String,
ws_name: String,
query: String,
gs_gen: u64,
) -> EditorMessage {
tokio::time::sleep(Duration::from_millis(GLOBAL_SEARCH_DEBOUNCE_MS)).await;
let entry = match crate::search_engine::resolve_engine(
&ws_name,
&ws_path,
"Search engine not ready: ",
false,
)
.await
{
Ok(e) => e,
Err(e) => {
return EditorMessage::GlobalSearchResults {
r#gen: gs_gen,
results: Vec::new(),
error: Some(e),
};
}
};
let entry_for_blocking = Arc::clone(&entry);
let query_for_blocking = query.clone();
let base_path = ws_path.clone();
let result = tokio::task::spawn_blocking(move || {
let guard = entry_for_blocking.picker.read().unwrap();
let Some(picker) = guard.as_ref() else {
return (
Vec::new(),
Some("Search engine not initialized.".to_string()),
);
};
let fff_query = parse_grep_query(&query_for_blocking);
let grep_opts = GrepSearchOptions {
mode: GrepMode::PlainText,
smart_case: true,
max_file_size: MAX_FILE_SIZE,
max_matches_per_file: GLOBAL_SEARCH_MATCHES_PER_FILE,
file_offset: 0,
page_limit: MAX_GLOBAL_SEARCH_RESULTS,
time_budget_ms: 3_000,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: true,
abort_signal: None,
};
let grep_result = picker.grep(&fff_query, &grep_opts);
if grep_result.matches.is_empty() {
return (Vec::new(), None);
}
let base = Path::new(&base_path);
let mut owned: Vec<OwnedGrepMatch> = Vec::with_capacity(grep_result.matches.len());
for m in &grep_result.matches {
let file = grep_result.files[m.file_index];
let rel_path = file.relative_path(picker);
let abs_path = base.join(&rel_path).to_string_lossy().to_string();
let offsets: Vec<(u32, u32)> =
m.match_byte_offsets.iter().map(|&(s, e)| (s, e)).collect();
owned.push(OwnedGrepMatch {
abs_path,
rel_path,
line_number: m.line_number,
line_content: m.line_content.clone(),
match_byte_offsets: offsets,
});
}
(owned, None)
})
.await
.unwrap_or((Vec::new(), Some("spawn_blocking join error".to_string())));
let (owned_matches, error) = result;
EditorMessage::GlobalSearchResults {
r#gen: gs_gen,
results: owned_matches,
error,
}
}
pub struct EditorState {
selected_workspace_name: Option<String>,
selected_workspace_path: Option<String>,
generation: u64,
saved_tabs_gen: u64,
dir_generations: HashMap<String, u64>,
file_generations: HashMap<String, u64>,
loading_dirs: HashSet<String>,
dir_entries: HashMap<String, Vec<FsEntry>>,
file_tree: FileTree,
selected_file: Option<String>,
tabs: Vec<Tab>,
active_tab_index: usize,
tab_contents: HashMap<String, TabData>,
tab_scroll_id: Id,
tab_scroll_x: f32,
tab_viewport_w: Option<f32>,
pending_close: Option<PendingCloseAction>,
session_initialized: bool,
pending_enter_dir: Option<String>,
git_status_cache: HashMap<String, GitFileStatus>,
git_status_loading: bool,
git_ignore_cache: HashSet<String>,
git_ignore_loading: bool,
tab_save_counter: Arc<AtomicU64>,
file_mtimes: HashMap<String, SystemTime>,
deleted_file_toasted: HashSet<String>,
active_modal: Option<ModalKind>,
all_workspace_files: Vec<String>,
git_status_gen: u64,
global_search_gen: u64,
pending_goto: Option<(String, usize, u64)>,
}
#[derive(Debug, Clone)]
enum ModalKind {
GlobalSearch(GlobalSearchState),
GotoLine(String),
QuickOpen(QuickOpenState),
Rename(RenameTarget),
NewItem(NewItemTarget),
DeleteConfirm(DeleteConfirmTarget),
CloseDialog(usize),
CloseOthers(usize),
}
#[derive(Debug, Clone)]
struct QuickOpenState {
filter: String,
selected_index: usize,
results: Vec<String>,
}
impl EditorState {
#[must_use]
pub fn new() -> Self {
Self {
selected_workspace_name: None,
selected_workspace_path: None,
generation: 0,
saved_tabs_gen: 0,
dir_generations: HashMap::new(),
file_generations: HashMap::new(),
loading_dirs: HashSet::new(),
dir_entries: HashMap::new(),
file_tree: FileTree::new(Id::new("editor_tree_panel")),
selected_file: None,
tabs: Vec::new(),
active_tab_index: 0,
tab_contents: HashMap::new(),
tab_scroll_id: Id::new("editor_tabs_bar"),
tab_scroll_x: 0.0,
tab_viewport_w: None,
pending_close: None,
session_initialized: false,
pending_enter_dir: None,
git_status_cache: HashMap::new(),
git_status_loading: false,
git_ignore_cache: HashSet::new(),
git_ignore_loading: false,
tab_save_counter: Arc::new(AtomicU64::new(0)),
file_mtimes: HashMap::new(),
deleted_file_toasted: HashSet::new(),
active_modal: None,
all_workspace_files: Vec::new(),
global_search_gen: 0,
git_status_gen: 0,
pending_goto: None,
}
}
#[inline]
fn workspace_root(&self) -> Option<&str> {
self.selected_workspace_path.as_deref()
}
fn abs_path(&self, rel_path: &str) -> Option<String> {
self.selected_workspace_path
.as_ref()
.map(|ws| Path::new(ws).join(rel_path).to_string_lossy().to_string())
}
fn rebuild_tree(&mut self) {
self.file_tree.nodes =
build_hierarchical_tree(&self.dir_entries, &self.file_tree.expanded_dirs, "");
self.file_tree.rebuild_visible();
}
fn forget_directory(&mut self, dir_path: &str) {
let is_root = dir_path.is_empty();
let rel_prefix = format!("{dir_path}/");
let within_rel = |p: &str| is_root || p == dir_path || p.starts_with(&rel_prefix);
if !is_root {
if let Some(parent) = Path::new(dir_path).parent() {
let parent = parent.to_string_lossy();
if let Some(parent_entries) = self.dir_entries.get_mut(parent.as_ref()) {
parent_entries.retain(|e| !within_rel(&e.full_path));
}
}
}
self.file_tree.expanded_dirs.retain(|p| !within_rel(p));
self.dir_entries.retain(|p, _| !within_rel(p));
self.loading_dirs.retain(|p| !within_rel(p));
self.dir_generations.retain(|p, _| !within_rel(p));
self.all_workspace_files.retain(|p| !within_rel(p));
let abs_prefix = if is_root {
self.selected_workspace_path
.as_ref()
.map(|ws| format!("{ws}/"))
} else {
self.abs_path(dir_path).map(|p| format!("{p}/"))
};
let within_abs = |p: &str| abs_prefix.as_deref().is_some_and(|pfx| p.starts_with(pfx));
self.file_generations.retain(|p, _| !within_abs(p));
self.file_mtimes.retain(|p, _| !within_abs(p));
self.deleted_file_toasted.retain(|p| !within_abs(p));
if let Some(ref sel) = self.selected_file {
if within_rel(sel) || within_abs(sel) {
self.selected_file = None;
}
}
if self.pending_enter_dir.as_deref().is_some_and(within_rel) {
self.pending_enter_dir = None;
}
}
fn bump_generation(&mut self) -> u64 {
let g = self.generation.wrapping_add(1);
self.generation = g;
g
}
fn load_dir_async(&mut self, dir_path: &str, label: &str) -> Option<Task<EditorMessage>> {
debug_assert!(
!self.dir_entries.contains_key(dir_path),
"load_dir_async: caller must check !dir_entries.contains_key(dir_path) first"
);
let ws_path = if let Some(p) = self.selected_workspace_path.as_ref() {
p.clone()
} else {
tracing::error!("{label} without workspace selected");
return None;
};
let dir_gen = self.bump_generation();
self.dir_generations.insert(dir_path.to_string(), dir_gen);
self.loading_dirs.insert(dir_path.to_string());
let d_path = dir_path.to_string();
Some(dir_expanded_task(ws_path, d_path, dir_gen, false))
}
fn expand_dir_and_focus(&mut self, path: &str, label: &str) -> Task<EditorMessage> {
self.selected_file = None;
self.file_tree.expanded_dirs.insert(path.to_string());
let needs_async_load = !self.dir_entries.contains_key(path);
if needs_async_load {
let Some(task) = self.load_dir_async(path, label) else {
return Task::none();
};
self.pending_enter_dir = Some(path.to_string());
self.rebuild_tree();
return task;
}
self.rebuild_tree();
self.file_tree
.expand_dir_and_focus_first_child::<EditorMessage>(path)
}
fn collapse_dir(&mut self, path: &str) -> Task<EditorMessage> {
self.file_tree.expanded_dirs.remove(path);
self.file_tree.nodes =
build_hierarchical_tree(&self.dir_entries, &self.file_tree.expanded_dirs, "");
self.file_tree
.collapse_dir_and_keep_focus::<EditorMessage>(path)
}
fn build_rename_input<'a>(
&'a self,
node: &'a widgets::TreeNode,
) -> Option<Element<'a, EditorMessage>> {
let ModalKind::Rename(rt) = self.active_modal.as_ref()? else {
return None;
};
if rt.path != node.full_path {
return None;
}
let input: Element<'a, EditorMessage> = text_input("", &rt.input_text)
.id(Id::from(format!("rename_input_{}", node.full_path)))
.on_input(EditorMessage::RenameInput)
.on_submit(EditorMessage::RenameSubmit)
.size(12)
.padding([0, 2])
.style(rename_input_style)
.into();
if let Some(ref err) = rt.error {
Some(
column![input, text(err).size(10).color(theme::STATUS_ERROR)]
.spacing(0)
.into(),
)
} else {
Some(input)
}
}
pub fn subscription(&self) -> Subscription<EditorMessage> {
let mut subs: Vec<Subscription<EditorMessage>> = Vec::new();
if self.selected_workspace_name.is_some() {
subs.push(
iced::time::every(Duration::from_secs(TICK_INTERVAL_SECS))
.map(|_| EditorMessage::Tick),
);
subs.push(
iced::time::every(Duration::from_secs(DIR_REFRESH_INTERVAL_SECS))
.map(|_| EditorMessage::RefreshFileTree),
);
subs.push(
iced::time::every(Duration::from_millis(300))
.map(|_| EditorMessage::CheckFileChanges),
);
}
subs.push(keyboard::listen().filter_map(map_editor_shortcut));
Subscription::batch(subs)
}
const fn active_tab_idx(&self) -> Option<usize> {
let idx = self.active_tab_index;
if idx >= self.tabs.len() {
None
} else {
Some(idx)
}
}
fn active_tab(&self) -> Option<(usize, String)> {
let idx = self.active_tab_idx()?;
Some((idx, self.tabs[idx].path.clone()))
}
fn is_find_bar_open(&self) -> bool {
self.active_tab_idx()
.and_then(|idx| self.tabs.get(idx))
.and_then(|tab| self.tab_contents.get(&tab.path))
.and_then(|data| data.find_replace_state.as_ref())
.is_some()
}
pub(crate) fn try_save_current_tabs(&self) -> Option<Task<EditorMessage>> {
if !self.session_initialized {
return None;
}
let workspace_name = self.selected_workspace_name.as_ref()?;
let save_gen = self
.tab_save_counter
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
let records = build_tab_records(&self.tabs, self.active_tab_index, &self.tab_contents);
Some(save_tabs_to_db(
workspace_name.clone(),
records,
save_gen,
self.tab_save_counter.clone(),
))
}
pub(crate) fn save_current_tabs(&mut self) -> Task<EditorMessage> {
self.try_save_current_tabs().unwrap_or(Task::none())
}
fn scroll_to_active_tab(&mut self) -> Task<EditorMessage> {
if self.tabs.is_empty() {
return Task::none();
}
let idx = self.active_tab_index.min(self.tabs.len() - 1);
let left = estimated_tab_left(&self.tabs, idx);
let Some(viewport_w) = self.tab_viewport_w else {
self.tab_scroll_x = left;
return iced::widget::operation::scroll_to(
self.tab_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset { x: left, y: 0.0 },
);
};
if estimated_content_width(&self.tabs) <= viewport_w {
return Task::none();
}
let right = left + estimate_tab_width(&self.tabs[idx]);
let viewport_right = self.tab_scroll_x + viewport_w;
if left < self.tab_scroll_x {
let delta = self.tab_scroll_x - left;
self.tab_scroll_x = left;
iced::widget::operation::scroll_by(
self.tab_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset { x: -delta, y: 0.0 },
)
} else if right > viewport_right {
let delta = right - viewport_right;
let max_scroll = (estimated_content_width(&self.tabs) - viewport_w).max(0.0);
self.tab_scroll_x = (self.tab_scroll_x + delta).clamp(0.0, max_scroll);
iced::widget::operation::scroll_by(
self.tab_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset { x: delta, y: 0.0 },
)
} else {
Task::none()
}
}
fn scroll_to_tab(&mut self, new_idx: usize) -> Task<EditorMessage> {
if new_idx >= self.tabs.len() {
return Task::none();
}
self.active_tab_index = new_idx;
self.scroll_to_active_tab()
}
fn switch_to_tab(&mut self, idx: usize) -> Task<EditorMessage> {
if idx >= self.tabs.len() {
return Task::none();
}
self.active_tab_index = idx;
Task::batch(vec![self.scroll_to_active_tab(), self.save_current_tabs()])
}
fn switch_tab_relative(&mut self, direction: TabDirection) -> Task<EditorMessage> {
if self.active_modal.is_some() || self.tabs.len() <= 1 {
return Task::none();
}
let len = self.tabs.len();
let new_idx = match direction {
TabDirection::Next => (self.active_tab_index + 1) % len,
TabDirection::Prev => (self.active_tab_index + len - 1) % len,
};
self.scroll_to_tab(new_idx)
}
fn scan_all_workspace_files(&mut self) {
self.all_workspace_files.clear();
let mut paths: Vec<String> = Vec::new();
for entries in self.dir_entries.values() {
for entry in entries {
if !entry.is_dir {
paths.push(entry.full_path.clone());
}
}
}
paths.sort();
self.all_workspace_files = paths;
}
fn filter_workspace_files(&self, filter: &str) -> Vec<String> {
if filter.is_empty() {
return self.all_workspace_files.iter().take(200).cloned().collect();
}
let lower_filter = filter.to_ascii_lowercase();
let mut scored: Vec<(usize, &String)> = self
.all_workspace_files
.iter()
.filter(|path| path.to_ascii_lowercase().contains(&lower_filter))
.map(|path| {
let name = path.rsplit('/').next().unwrap_or(path);
let name_lower = name.to_ascii_lowercase();
let name_score = if name_lower.starts_with(&lower_filter) {
0 } else if name_lower.contains(&lower_filter) {
1 } else {
2 };
(name_score, path)
})
.collect();
scored.sort_by_key(|(score, _)| *score);
scored
.into_iter()
.take(200)
.map(|(_, p)| p.clone())
.collect()
}
fn open_file_in_editor(&mut self, path: &str) -> Task<EditorMessage> {
let Some(ws) = self.workspace_root() else {
return Task::none();
};
let abs_path = if path.starts_with('/') {
path.to_string()
} else {
std::path::Path::new(&ws)
.join(path)
.to_string_lossy()
.to_string()
};
if let Some(existing_idx) = self.tabs.iter().position(|t| t.path == abs_path) {
return self.scroll_to_tab(existing_idx);
}
self.file_tree.tree_focused = false;
self.pending_enter_dir = None;
let file_path = abs_path;
let file_gen = self.bump_generation();
self.file_generations.insert(file_path.clone(), file_gen);
self.selected_file = Some(file_path.clone());
spawn_file_load(file_path, file_gen)
}
fn remove_tab_at(&mut self, idx: usize) {
let closed_path = self.tabs[idx].path.clone();
self.tab_contents.remove(&closed_path);
self.tabs.remove(idx);
let len = self.tabs.len();
if len == 0 {
self.active_tab_index = 0;
} else if idx < self.active_tab_index {
self.active_tab_index = self.active_tab_index.saturating_sub(1);
} else {
self.active_tab_index = self.active_tab_index.min(len.saturating_sub(1));
}
}
fn remove_all_tabs_except(&mut self, keep_idx: usize) {
let mut to_remove: Vec<usize> = (0..self.tabs.len()).collect();
to_remove.retain(|&i| i != keep_idx);
to_remove.sort_unstable_by(|a, b| b.cmp(a));
for i in to_remove {
self.remove_tab_at(i);
}
}
fn close_tab_at(&mut self, idx: usize) -> Task<EditorMessage> {
if idx >= self.tabs.len() {
return Task::none();
}
if self.tabs[idx].is_dirty {
self.active_modal = Some(ModalKind::CloseDialog(idx));
return Task::none();
}
self.active_modal = None;
self.remove_tab_at(idx);
self.save_current_tabs()
}
fn apply_undo_snapshot(&mut self, idx: usize, snapshot: Option<UndoSnapshot>) {
let Some(snapshot) = snapshot else {
return;
};
let path = self.tabs[idx].path.clone();
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data.find_replace_state = None;
tab_data.content = EditorBuffer::from_file(&snapshot.text, &path);
tab_data.content.move_to(
snapshot.cursor.position.line,
snapshot.cursor.position.column,
);
}
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
}
fn apply_undo_or_redo(&mut self, is_redo: bool) -> Task<EditorMessage> {
let Some((idx, path)) = self.active_tab() else {
return Task::none();
};
let snapshot = self.tab_contents.get_mut(&path).and_then(|tab_data| {
let mut stack = tab_data.undo_stack.borrow_mut();
if is_redo {
stack.redo(&tab_data.content)
} else {
stack.undo(&tab_data.content)
}
});
self.apply_undo_snapshot(idx, snapshot);
Task::none()
}
fn handle_undo_or_redo(&mut self, is_redo: bool) -> Task<EditorMessage> {
if self.is_find_bar_open() || self.active_modal.is_some() {
Task::none()
} else {
self.apply_undo_or_redo(is_redo)
}
}
fn clear_workspace_editor_state(&mut self) {
self.file_tree.nodes.clear();
self.file_tree.expanded_dirs.clear();
self.selected_file = None;
self.tabs.clear();
self.tab_contents.clear();
self.active_tab_index = 0;
self.dir_entries.clear();
self.loading_dirs.clear();
self.dir_generations.clear();
self.file_generations.clear();
self.git_status_cache.clear();
self.git_status_loading = false;
self.git_ignore_cache.clear();
self.git_ignore_loading = false;
self.session_initialized = false;
self.active_modal = None;
self.pending_close = None;
self.file_tree.visible_tree_nodes.clear();
self.file_tree.tree_focused = false;
self.file_tree.tree_focus_index = 0;
self.pending_enter_dir = None;
self.tab_save_counter.store(0, Ordering::Release);
self.file_mtimes.clear();
self.deleted_file_toasted.clear();
self.all_workspace_files.clear();
self.global_search_gen = 0;
self.pending_goto = None;
self.tab_scroll_x = 0.0;
self.tab_viewport_w = None;
}
fn start_new_item_creation(&mut self, parent_dir: String, is_dir: bool) -> Task<EditorMessage> {
let Some(ref ws) = self.selected_workspace_path else {
return Task::none();
};
let abs_parent = if parent_dir.is_empty() {
ws.clone()
} else {
Path::new(ws)
.join(&parent_dir)
.to_string_lossy()
.to_string()
};
self.active_modal = Some(ModalKind::NewItem(NewItemTarget {
parent_dir,
is_dir,
abs_parent,
ws_root: ws.clone(),
input_text: String::new(),
}));
iced::widget::operation::focus::<EditorMessage>(Id::new(NEW_ITEM_INPUT_ID))
}
#[expect(clippy::too_many_lines)]
pub fn update(&mut self, msg: EditorMessage) -> Task<EditorMessage> {
match msg {
EditorMessage::WorkspaceSelected(ref name, ref path) => {
self.workspace_selected(name, path.as_deref())
}
EditorMessage::SavedTabsLoaded { tabs_data, r#gen } => {
self.saved_tabs_loaded(tabs_data, r#gen)
}
EditorMessage::DirExpanded {
dir_path,
r#gen,
entries,
quiet,
} => self.dir_expanded(&dir_path, r#gen, entries, quiet),
EditorMessage::DirDeleted {
dir_path,
workspace_path,
} => self.dir_deleted(&dir_path, &workspace_path),
EditorMessage::ToggleDir(dir_path) => self.toggle_dir(&dir_path),
EditorMessage::SelectFile(path) => self.select_file(&path),
EditorMessage::FileLoaded {
path,
r#gen,
result,
} => self.file_loaded(&path, r#gen, result),
EditorMessage::TabSelected(idx) => self.switch_to_tab(idx),
EditorMessage::TabClosed(idx) => self.close_tab_at(idx),
EditorMessage::EditorAction(action) => self.editor_action(action),
EditorMessage::SaveActiveTab => self.save_active_tab(),
EditorMessage::SaveResult {
path,
result,
saved_hash,
} => self.save_result(&path, result, saved_hash),
EditorMessage::CloseDialog { tab_index, action } => {
self.close_dialog(tab_index, action)
}
EditorMessage::CloseOthersDialog { keep_idx, action } => {
self.close_others_dialog(keep_idx, action)
}
EditorMessage::CloseOtherTabs(idx) => self.close_other_tabs(idx),
EditorMessage::Escape => self.escape(),
EditorMessage::GoToLineToggle => self.go_to_line_toggle(),
EditorMessage::GoToLineInput(input) => self.go_to_line_input(&input),
EditorMessage::GoToLineGo => self.go_to_line_go(),
EditorMessage::GlobalSearchToggle => self.global_search_toggle(),
EditorMessage::GlobalSearchInput(query) => self.global_search_input(query),
EditorMessage::GlobalSearchResults {
r#gen,
results,
error,
} => self.global_search_results(r#gen, results, error),
EditorMessage::GlobalSearchSelect(idx) => self.global_search_select(idx),
EditorMessage::DeleteFileRequested(path) => self.delete_file_requested(path),
EditorMessage::DeleteDirectoryRequested(path) => self.delete_directory_requested(path),
EditorMessage::NewFileRequested(parent_dir) => {
self.start_new_item_creation(parent_dir, false)
}
EditorMessage::NewDirectoryRequested(parent_dir) => {
self.start_new_item_creation(parent_dir, true)
}
EditorMessage::RevealInFinder(path) => Self::perform_reveal_in_finder(path),
EditorMessage::CopyRelativePath(path) | EditorMessage::CopyAbsolutePath(path) => {
iced::clipboard::write(path)
}
EditorMessage::ConfirmDelete => self.confirm_delete(),
EditorMessage::CancelDelete => {
self.active_modal = None;
Task::none()
}
EditorMessage::NewItemSubmit(name) => self.new_item_submit(&name),
EditorMessage::NewItemInput(new_text) => self.new_item_input(new_text),
EditorMessage::RenameRequested(path) => self.rename_requested(&path),
EditorMessage::RenameInput(new_text) => self.rename_input(new_text),
EditorMessage::RenameSubmit => self.rename_submit(),
EditorMessage::RenameCancel => self.rename_cancel(),
EditorMessage::RenameCompleted {
old_path,
new_path,
is_dir,
result,
dir_entries,
rename_gen,
} => self.rename_completed(
&old_path,
&new_path,
is_dir,
result,
dir_entries,
rename_gen,
),
EditorMessage::QuickOpenToggle => self.quick_open_toggle(),
EditorMessage::QuickOpenInput(filter) => self.quick_open_input(filter),
EditorMessage::QuickOpenSelect(idx) => self.quick_open_select(idx),
EditorMessage::TabSwitchNext => self.switch_tab_relative(TabDirection::Next),
EditorMessage::TabSwitchPrev => self.switch_tab_relative(TabDirection::Prev),
EditorMessage::CloseActiveTab => self.close_active_tab(),
EditorMessage::TreeFocusToggled => self.tree_focus_toggled(),
EditorMessage::TreeScrolled(scroll_y, viewport_h) => {
self.tree_scrolled(scroll_y, viewport_h)
}
EditorMessage::TabBarScrolled(scroll_x, viewport_w) => {
self.tab_bar_scrolled(scroll_x, viewport_w)
}
EditorMessage::TreeNavUp => self.navigate_tree_vertical(TreeNavDirection::Up),
EditorMessage::TreeNavDown => self.navigate_tree_vertical(TreeNavDirection::Down),
EditorMessage::TreeNavEnter => self.tree_nav_enter(),
EditorMessage::TreeNavLeft => self.tree_nav_left(),
EditorMessage::TreeNavRight => self.tree_nav_right(),
EditorMessage::Undo => self.handle_undo_or_redo(false),
EditorMessage::Redo => self.handle_undo_or_redo(true),
EditorMessage::FindToggle => self.find_toggle(),
EditorMessage::FindQueryInput(query) => self.find_query_input(query),
EditorMessage::FindReplaceInput(replace) => self.find_replace_input(replace),
EditorMessage::FindNext => self.navigate_find_match(FindDirection::Next),
EditorMessage::FindPrev => self.navigate_find_match(FindDirection::Prev),
EditorMessage::FindReplace => self.find_replace(),
EditorMessage::FindReplaceAll => self.find_replace_all(),
EditorMessage::FindToggleCaseSensitivity => self.find_toggle_case_sensitivity(),
EditorMessage::RefreshFileTree => self.refresh_file_tree(),
EditorMessage::Tick => self.tick(),
EditorMessage::Toast(_) => Task::none(),
EditorMessage::GitStatusLoaded { r#gen, result } => finish_git_load(
&mut self.git_status_loading,
self.git_status_gen,
r#gen,
result,
&mut self.git_status_cache,
"git status",
),
EditorMessage::GitIgnoredLoaded { r#gen, result } => finish_git_load(
&mut self.git_ignore_loading,
self.git_status_gen,
r#gen,
result,
&mut self.git_ignore_cache,
"git ignore status",
),
EditorMessage::CheckFileChanges => self.check_file_changes(),
EditorMessage::FileReloaded {
path,
result,
cursor_line,
cursor_col,
} => self.file_reloaded(path, result, cursor_line, cursor_col),
}
}
fn workspace_selected(&mut self, name: &str, path: Option<&str>) -> Task<EditorMessage> {
if name.is_empty() && path.is_none() {
self.selected_workspace_name = None;
self.selected_workspace_path = None;
self.clear_workspace_editor_state();
return Task::none();
}
let mut tasks: Vec<Task<EditorMessage>> = Vec::new();
self.selected_workspace_name = Some(name.to_string());
self.selected_workspace_path = path.map(std::string::ToString::to_string);
let r#gen = self.bump_generation();
let saved_gen = self.saved_tabs_gen.wrapping_add(1);
self.saved_tabs_gen = saved_gen;
let git_gen = self.git_status_gen.wrapping_add(1);
self.git_status_gen = git_gen;
self.clear_workspace_editor_state();
self.dir_generations.insert(String::new(), r#gen);
tasks.push(dir_expanded_task(
path.unwrap_or_default().to_string(),
String::new(),
r#gen,
false,
));
let tab_ws = name.to_string();
let tab_path = path.unwrap_or_default().to_string();
let tab_gen = saved_gen;
let load_tabs_task = Task::perform(
async move {
let store = crate::workspace::store();
let records = store.load_editor_tabs(&tab_ws).await.unwrap_or_else(|e| {
tracing::warn!(?e, workspace = %tab_ws, "Failed to load editor tabs");
Vec::new()
});
let ws_path = tab_path;
let mut loaded: Vec<SavedTabData> = Vec::new();
for record in &records {
if record.file_path.is_empty() || record.file_path.trim().is_empty() {
tracing::warn!(
workspace = %tab_ws,
tab_order = record.tab_order,
"Skipping editor tab with empty file_path in GUI loader"
);
continue;
}
let file_path = if ws_path.is_empty() {
record.file_path.clone()
} else {
Path::new(&ws_path)
.join(&record.file_path)
.to_string_lossy()
.to_string()
};
let loaded_text = if let Some(dirty) = record.dirty_content.clone() {
Some(dirty)
} else if let Ok(bytes) = tokio::fs::read(&file_path).await {
if validate_file_content(&bytes).is_ok() {
String::from_utf8(bytes).ok()
} else {
None
}
} else {
None
};
if let Some(text) = loaded_text {
let line_ending = detect_line_ending(&text);
loaded.push(SavedTabData {
file_path,
text,
was_dirty: record.is_dirty,
line_ending,
is_active: record.is_active,
});
}
}
EditorMessage::SavedTabsLoaded {
tabs_data: loaded,
r#gen: tab_gen,
}
},
|msg| msg,
);
tasks.push(load_tabs_task);
self.git_status_loading = true;
let git_path = path.unwrap_or_default().to_string();
let git_task = Task::perform(
async move { load_git_status(git_path).await },
move |result| EditorMessage::GitStatusLoaded {
r#gen: git_gen,
result,
},
);
tasks.push(git_task);
Task::batch(tasks)
}
fn insert_tab(&mut self, path: String, tab: Tab, tab_data: TabData, mtime: Option<SystemTime>) {
self.tabs.push(tab);
self.tab_contents.insert(path.clone(), tab_data);
if let Some(mtime) = mtime {
self.file_mtimes.insert(path, mtime);
}
}
fn saved_tabs_loaded(
&mut self,
tabs_data: Vec<SavedTabData>,
r#gen: u64,
) -> Task<EditorMessage> {
if r#gen != self.saved_tabs_gen {
return Task::none();
}
let mut active_idx = 0;
for (i, saved) in tabs_data.into_iter().enumerate() {
if saved.is_active {
active_idx = i;
}
let saved_hash = if saved.was_dirty {
std::fs::read_to_string(&saved.file_path)
.as_ref()
.map_or_else(|_| hash_text(&saved.text), |disk| hash_text(disk))
} else {
hash_text(&saved.text)
};
let (tab, td, mtime) = make_tab_and_data(
&saved.file_path,
&saved.text,
saved.line_ending,
saved.was_dirty,
saved_hash,
);
self.insert_tab(saved.file_path, tab, td, mtime);
}
if !self.tabs.is_empty() {
self.active_tab_index = active_idx.min(self.tabs.len().saturating_sub(1));
}
self.session_initialized = true;
if !self.tabs.is_empty() {
self.scroll_to_active_tab()
} else {
Task::none()
}
}
fn dir_expanded(
&mut self,
dir_path: &str,
r#gen: u64,
entries: Result<Vec<FsEntry>, ReadDirError>,
quiet: bool,
) -> Task<EditorMessage> {
if self.dir_generations.get(dir_path) != Some(&r#gen) {
return Task::none();
}
self.dir_generations.remove(dir_path);
self.loading_dirs.remove(dir_path);
match entries {
Ok(entries) => {
self.dir_entries.insert(dir_path.to_string(), entries);
self.rebuild_tree();
if self.pending_enter_dir.as_deref() == Some(dir_path) {
self.pending_enter_dir = None;
return self
.file_tree
.expand_dir_and_focus_first_child::<EditorMessage>(dir_path);
}
}
Err(ReadDirError::NotFound) => {
self.forget_directory(dir_path);
self.rebuild_tree();
if !dir_path.is_empty() {
tracing::debug!(
"File tree: forgotten directory that no longer exists: {dir_path}"
);
}
}
Err(ReadDirError::Other(err)) => {
if quiet {
tracing::warn!("Failed to read directory '{dir_path}' (refresh): {err}");
return Task::none();
}
return Task::done(EditorMessage::Toast(super::ToastMessage::Warning(format!(
"Failed to read directory '{dir_path}': {err}"
))));
}
}
Task::none()
}
fn dir_deleted(&mut self, dir_path: &str, workspace_path: &str) -> Task<EditorMessage> {
if self.selected_workspace_path.as_deref() != Some(workspace_path) {
return Task::none();
}
self.forget_directory(dir_path);
self.rebuild_tree();
let Some(ws_path) = self.selected_workspace_path.clone() else {
return Task::none();
};
let parent_dir = Path::new(dir_path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let r#gen = self.bump_generation();
self.dir_generations.insert(parent_dir.clone(), r#gen);
dir_expanded_task(ws_path, parent_dir, r#gen, false)
}
fn file_loaded(
&mut self,
path: &str,
r#gen: u64,
result: Result<FileLoadData, String>,
) -> Task<EditorMessage> {
if self.file_generations.get(path).copied() != Some(r#gen) {
return Task::none();
}
self.file_generations.remove(path);
match result {
Ok(data) => {
let saved_hash = hash_text(&data.text);
let (tab, tab_data, mtime) =
make_tab_and_data(&data.path, &data.text, data.line_ending, false, saved_hash);
self.insert_tab(data.path, tab, tab_data, mtime);
self.active_tab_index = self.tabs.len().saturating_sub(1);
self.session_initialized = true;
if self
.pending_goto
.as_ref()
.is_some_and(|(gp, _, gg)| *gp == path && *gg == r#gen)
{
if let Some((_, goto_line_1based, _)) = self.pending_goto.take() {
let cursor_line = goto_line_1based.saturating_sub(1);
let tab_path = self.tabs[self.active_tab_index].path.clone();
if let Some(tab_data) = self.tab_contents.get_mut(&tab_path) {
tab_data.content.move_to(cursor_line, 0);
}
}
}
let tasks = vec![self.scroll_to_active_tab(), self.save_current_tabs()];
Task::batch(tasks)
}
Err(e) => {
let toast =
if e.starts_with("File too large") || e.starts_with("Binary file detected") {
super::ToastMessage::Warning(e)
} else {
super::ToastMessage::Error(e)
};
Task::done(EditorMessage::Toast(toast))
}
}
}
fn save_result(
&mut self,
path: &str,
result: Result<(), String>,
saved_hash: u64,
) -> Task<EditorMessage> {
match result {
Ok(()) => {
let still_matches_saved = self
.tab_contents
.get(path)
.is_some_and(|tab_data| hash_text(&tab_data.content.text()) == saved_hash);
if !still_matches_saved {
return Task::none();
}
if let Some(tab) = self.tabs.iter_mut().find(|t| t.path == path) {
tab.is_dirty = false;
}
if let Some(tab_data) = self.tab_contents.get_mut(path) {
tab_data.saved_text_hash = saved_hash;
}
if let Ok(meta) = std::fs::metadata(path) {
if let Ok(mtime) = meta.modified() {
self.file_mtimes.insert(path.to_string(), mtime);
}
}
match self.pending_close.take() {
Some(PendingCloseAction::CloseTab(close_idx)) => {
if close_idx < self.tabs.len() {
self.remove_tab_at(close_idx);
let mut tasks: Vec<Task<EditorMessage>> = Vec::new();
if !self.tabs.is_empty() {
tasks.push(self.scroll_to_active_tab());
}
tasks.push(self.save_current_tabs());
return Task::batch(tasks);
}
}
Some(PendingCloseAction::CloseOthers {
keep_idx,
remaining_dirty: mut remaining,
}) => {
return if remaining.is_empty() {
self.remove_all_tabs_except(keep_idx);
self.try_save_current_tabs().map_or_else(Task::none, |t| {
Task::batch([
t,
Task::done(EditorMessage::Toast(super::ToastMessage::Saved)),
])
})
} else {
let next = remaining.remove(0);
self.pending_close = Some(PendingCloseAction::CloseOthers {
keep_idx,
remaining_dirty: remaining,
});
build_save_task(&self.tabs, &self.tab_contents, next)
};
}
None => {}
}
if let Some(save_task) = self.try_save_current_tabs() {
save_task
} else {
Task::done(EditorMessage::Toast(super::ToastMessage::Saved))
}
}
Err(e) => {
self.pending_close = None;
let toast = super::ToastMessage::Error(e);
Task::done(EditorMessage::Toast(toast))
}
}
}
fn escape(&mut self) -> Task<EditorMessage> {
if let Some(modal) = self.active_modal.take() {
match modal {
ModalKind::CloseDialog(..) | ModalKind::CloseOthers(..) => {
self.pending_close = None;
}
_ => {}
}
return Task::none();
}
if let Some((_, path)) = self.active_tab() {
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
if tab_data.find_replace_state.is_some() {
tab_data.find_replace_state = None;
return Task::none();
}
}
}
if self.file_tree.tree_focused {
self.file_tree.tree_focused = false;
self.pending_enter_dir = None;
return Task::none();
}
self.pending_close = None;
Task::none()
}
fn global_search_toggle(&mut self) -> Task<EditorMessage> {
if matches!(self.active_modal, Some(ModalKind::GlobalSearch(_))) {
self.active_modal = None;
return Task::none();
}
if self.active_modal.is_some() {
return Task::none();
}
if let Some((_, path)) = self.active_tab() {
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data.find_replace_state = None;
}
}
let ws_path = match self.selected_workspace_path.as_ref() {
Some(p) => p.clone(),
None => return Task::none(),
};
let ws_name = match self.selected_workspace_name.as_ref() {
Some(n) => n.clone(),
None => return Task::none(),
};
self.global_search_gen = self.global_search_gen.wrapping_add(1);
let gs_gen = self.global_search_gen;
self.active_modal = Some(ModalKind::GlobalSearch(GlobalSearchState {
query: String::new(),
results: Vec::new(),
selected_index: 0,
status: GlobalSearchStatus::Idle,
}));
let engine_task = Task::perform(
async move {
match crate::search_engine::resolve_engine(&ws_name, &ws_path, "", false).await {
Ok(_) => EditorMessage::GlobalSearchResults {
r#gen: gs_gen,
results: Vec::new(),
error: None,
},
Err(e) => EditorMessage::GlobalSearchResults {
r#gen: gs_gen,
results: Vec::new(),
error: Some(e),
},
}
},
|msg| msg,
);
let focus_task =
iced::widget::operation::focus::<EditorMessage>(Id::new(GLOBAL_SEARCH_INPUT_ID));
Task::batch([engine_task, focus_task])
}
fn global_search_results(
&mut self,
r#gen: u64,
results: Vec<OwnedGrepMatch>,
error: Option<String>,
) -> Task<EditorMessage> {
if r#gen != self.global_search_gen {
return Task::none();
}
let Some(ModalKind::GlobalSearch(state)) = &mut self.active_modal else {
return Task::none();
};
if let Some(err) = error {
state.status = GlobalSearchStatus::Error(err);
state.results.clear();
return Task::none();
}
if results.is_empty() && state.query.is_empty() {
state.status = GlobalSearchStatus::Idle;
return Task::none();
}
if results.is_empty() {
state.status = GlobalSearchStatus::NoResults;
state.results.clear();
state.selected_index = 0;
return Task::none();
}
state.results = results;
state.selected_index = 0;
state.status = GlobalSearchStatus::Done;
Task::none()
}
fn find_replace_all(&mut self) -> Task<EditorMessage> {
let Some((idx, path)) = self.active_tab() else {
return Task::none();
};
let mut toast = None;
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
if let Some(ref state) = tab_data.find_replace_state {
if !state.matches.is_empty() {
tab_data
.undo_stack
.borrow_mut()
.snap_before_edit(&tab_data.content);
let cursor_before = tab_data.content.cursor();
let text = tab_data.content.text();
let replace = &state.replace;
let mut new_text = text;
for range in state.matches.iter().rev() {
new_text.replace_range(range.start..range.end, replace);
}
tab_data.content = EditorBuffer::from_file(&new_text, &path);
tab_data
.content
.move_to(cursor_before.line, cursor_before.column);
if let Some(ref mut state) = tab_data.find_replace_state {
state.matches.clear();
state.current_match_idx = 0;
}
toast = Some(EditorMessage::Toast(super::ToastMessage::SuccessMsg(
"All matches replaced".to_string(),
)));
}
}
}
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
if let Some(t) = toast {
Task::done(t)
} else {
Task::none()
}
}
fn toggle_dir(&mut self, dir_path: &str) -> Task<EditorMessage> {
self.dismiss_rename();
self.selected_file = None;
if self.file_tree.expanded_dirs.contains(dir_path) {
self.file_tree.tree_focused = true;
return self.collapse_dir(dir_path);
}
self.file_tree.expanded_dirs.insert(dir_path.to_string());
let read_task = if !self.dir_entries.contains_key(dir_path) {
match self.load_dir_async(dir_path, "ToggleDir") {
Some(t) => t,
None => return Task::none(),
}
} else {
Task::none()
};
self.rebuild_tree();
self.file_tree.tree_focused = true;
self.file_tree.focus_path(dir_path);
read_task
}
fn select_file(&mut self, path: &str) -> Task<EditorMessage> {
self.file_tree.tree_focused = true;
self.dismiss_rename();
self.pending_enter_dir = None;
self.file_tree.focus_path(path);
self.selected_file = Some(path.to_string());
let Some(abs_path) = self.abs_path(path) else {
return Task::none();
};
if let Some(pos) = self.tabs.iter().position(|t| t.path == abs_path) {
return self.switch_to_tab(pos);
}
let file_gen = self
.file_generations
.get(&abs_path)
.copied()
.unwrap_or(0)
.wrapping_add(1);
self.file_generations.insert(abs_path.clone(), file_gen);
spawn_file_load(abs_path, file_gen)
}
fn editor_action(&mut self, action: super::editor_widget::EditorAction) -> Task<EditorMessage> {
self.file_tree.tree_focused = false;
self.pending_enter_dir = None;
self.dismiss_rename();
let Some((idx, path)) = self.active_tab() else {
return Task::none();
};
let is_edit = action.is_edit_action();
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
if is_edit {
tab_data
.undo_stack
.borrow_mut()
.snap_before_edit(&tab_data.content);
}
tab_data.content.perform_action(action);
}
if is_edit {
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
}
Task::none()
}
fn save_active_tab(&self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let Some(idx) = self.active_tab_idx() else {
return Task::none();
};
build_save_task(&self.tabs, &self.tab_contents, idx)
}
fn close_dialog(&mut self, tab_index: usize, action: CloseAction) -> Task<EditorMessage> {
match action {
CloseAction::Save => {
if tab_index < self.tabs.len() {
self.active_modal = None;
self.pending_close = Some(PendingCloseAction::CloseTab(tab_index));
build_save_task(&self.tabs, &self.tab_contents, tab_index)
} else {
self.active_modal = None;
Task::none()
}
}
CloseAction::Discard => {
self.active_modal = None;
self.pending_close = None;
if tab_index < self.tabs.len() {
self.remove_tab_at(tab_index);
}
self.save_current_tabs()
}
CloseAction::Cancel => {
self.active_modal = None;
self.pending_close = None;
Task::none()
}
}
}
fn close_others_dialog(&mut self, keep_idx: usize, action: CloseAction) -> Task<EditorMessage> {
match action {
CloseAction::Save => {
self.active_modal = None;
let mut dirty: Vec<usize> = (0..self.tabs.len())
.filter(|&i| i != keep_idx && self.tabs[i].is_dirty)
.collect();
if dirty.is_empty() {
self.remove_all_tabs_except(keep_idx);
return self.save_current_tabs();
}
let first = dirty.remove(0);
self.pending_close = Some(PendingCloseAction::CloseOthers {
keep_idx,
remaining_dirty: dirty,
});
build_save_task(&self.tabs, &self.tab_contents, first)
}
CloseAction::Discard => {
self.active_modal = None;
self.pending_close = None;
self.remove_all_tabs_except(keep_idx);
self.save_current_tabs()
}
CloseAction::Cancel => {
self.active_modal = None;
self.pending_close = None;
Task::none()
}
}
}
fn close_other_tabs(&mut self, idx: usize) -> Task<EditorMessage> {
if idx >= self.tabs.len() {
return Task::none();
}
let dirty: Vec<usize> = (0..self.tabs.len())
.filter(|&i| i != idx && self.tabs[i].is_dirty)
.collect();
if dirty.is_empty() {
self.remove_all_tabs_except(idx);
return self.save_current_tabs();
}
self.active_modal = Some(ModalKind::CloseOthers(idx));
Task::none()
}
fn go_to_line_toggle(&mut self) -> Task<EditorMessage> {
if let Some(modal) = &self.active_modal {
if !matches!(modal, ModalKind::GotoLine(_)) {
return Task::none();
}
}
if let Some((_, path)) = self.active_tab() {
if matches!(self.active_modal, Some(ModalKind::GotoLine(_))) {
self.active_modal = None;
return Task::none();
}
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data.find_replace_state = None;
}
self.active_modal = Some(ModalKind::GotoLine(String::new()));
return iced::widget::operation::focus::<EditorMessage>(Id::new(GOTO_LINE_INPUT_ID));
}
Task::none()
}
fn go_to_line_input(&mut self, input: &str) -> Task<EditorMessage> {
let digits: String = input.chars().filter(char::is_ascii_digit).collect();
if matches!(self.active_modal, Some(ModalKind::GotoLine(_))) {
self.active_modal = Some(ModalKind::GotoLine(digits));
}
Task::none()
}
fn go_to_line_go(&mut self) -> Task<EditorMessage> {
let input = match &self.active_modal {
Some(ModalKind::GotoLine(v)) => v.clone(),
_ => return Task::none(),
};
let line_num: usize = match input.parse::<usize>() {
Ok(n) if n > 0 => n.saturating_sub(1), _ => return Task::none(),
};
let Some((_, path)) = self.active_tab() else {
return Task::none();
};
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data.content.move_to(line_num, 0);
}
self.active_modal = None;
Task::none()
}
fn global_search_input(&mut self, query: String) -> Task<EditorMessage> {
let Some(ModalKind::GlobalSearch(state)) = &mut self.active_modal else {
return Task::none();
};
state.query.clone_from(&query);
if query.is_empty() {
state.status = GlobalSearchStatus::Idle;
state.results.clear();
state.selected_index = 0;
self.global_search_gen = self.global_search_gen.wrapping_add(1);
return Task::none();
}
state.status = GlobalSearchStatus::Searching;
let ws_path = match self.selected_workspace_path.as_ref() {
Some(p) => p.clone(),
None => return Task::none(),
};
let ws_name = match self.selected_workspace_name.as_ref() {
Some(n) => n.clone(),
None => return Task::none(),
};
self.global_search_gen = self.global_search_gen.wrapping_add(1);
let gs_gen = self.global_search_gen;
Task::perform(run_global_search(ws_path, ws_name, query, gs_gen), |msg| {
msg
})
}
fn global_search_select(&mut self, idx: usize) -> Task<EditorMessage> {
let Some(ModalKind::GlobalSearch(state)) = &self.active_modal else {
return Task::none();
};
let Some(match_result) = state.results.get(idx) else {
return Task::none();
};
let abs_path = match_result.abs_path.clone();
#[expect(clippy::cast_possible_truncation)]
let line_number = match_result.line_number as usize;
self.active_modal = None;
let cursor_line = line_number.saturating_sub(1);
if let Some(existing_idx) = self.tabs.iter().position(|t| t.path == abs_path) {
self.active_tab_index = existing_idx;
if let Some(tab_data) = self.tab_contents.get_mut(&abs_path) {
tab_data.content.move_to(cursor_line, 0);
}
return self.scroll_to_active_tab();
}
let file_gen = self.generation.wrapping_add(1);
self.pending_goto = Some((abs_path.clone(), line_number, file_gen));
self.open_file_in_editor(&abs_path)
}
fn delete_file_requested(&mut self, path: String) -> Task<EditorMessage> {
let Some(abs_path) = self.abs_path(&path) else {
return Task::none();
};
self.active_modal = Some(ModalKind::DeleteConfirm(DeleteConfirmTarget {
path,
is_dir: false,
dirty_tab_count: 0,
abs_path,
}));
Task::none()
}
fn delete_directory_requested(&mut self, path: String) -> Task<EditorMessage> {
if path.is_empty() {
return Task::done(EditorMessage::Toast(super::ToastMessage::Warning(
"Cannot delete root directory".into(),
)));
}
let Some(abs_path) = self.abs_path(&path) else {
return Task::none();
};
let abs_prefix = format!("{abs_path}/");
let mut dirty_count = 0;
for tab in &self.tabs {
if tab.path.starts_with(&abs_prefix) {
if tab.is_dirty {
dirty_count += 1;
}
}
}
self.active_modal = Some(ModalKind::DeleteConfirm(DeleteConfirmTarget {
path,
is_dir: true,
dirty_tab_count: dirty_count,
abs_path,
}));
Task::none()
}
fn confirm_delete(&mut self) -> Task<EditorMessage> {
let Some(ModalKind::DeleteConfirm(target)) = self.active_modal.clone() else {
return Task::none();
};
self.active_modal = None;
if target.is_dir {
self.perform_dir_delete(&target)
} else {
self.perform_file_delete(&target)
}
}
fn new_item_submit(&mut self, name: &str) -> Task<EditorMessage> {
let Some(ModalKind::NewItem(target)) = self.active_modal.clone() else {
return Task::none();
};
let trimmed = name.trim();
if let Some(msg) = validate_item_name(trimmed) {
return Task::done(EditorMessage::Toast(super::ToastMessage::Warning(
msg.into(),
)));
}
self.active_modal = None;
self.perform_create_item(&target, trimmed)
}
fn new_item_input(&mut self, new_text: String) -> Task<EditorMessage> {
if let Some(ModalKind::NewItem(ref mut target)) = self.active_modal {
target.input_text = new_text;
}
Task::none()
}
fn rename_requested(&mut self, path: &str) -> Task<EditorMessage> {
let Some(ref ws) = self.selected_workspace_path else {
return Task::none();
};
if path.is_empty() {
return Task::done(EditorMessage::Toast(super::ToastMessage::Warning(
"Cannot rename root directory".into(),
)));
}
let abs_path = self
.abs_path(path)
.expect("RenameRequested: selected_workspace_path already guarded above");
let file_name = Path::new(&abs_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let is_dir = Path::new(&abs_path).is_dir();
self.active_modal = Some(ModalKind::Rename(RenameTarget {
abs_path,
ws_root: ws.clone(),
path: path.to_string(),
is_dir,
input_text: file_name,
error: None,
}));
iced::widget::operation::focus::<EditorMessage>(Id::from(format!("rename_input_{path}")))
}
fn rename_input(&mut self, new_text: String) -> Task<EditorMessage> {
if let Some(ModalKind::Rename(ref mut target)) = self.active_modal {
target.input_text = new_text;
if target.error.is_some() {
target.error = None;
}
}
Task::none()
}
fn rename_submit(&mut self) -> Task<EditorMessage> {
let Some(ModalKind::Rename(target)) = self.active_modal.clone() else {
return Task::none();
};
let trimmed = target.input_text.trim().to_string();
let error_msg = validate_item_name(&trimmed);
if let Some(msg) = error_msg {
if let Some(ModalKind::Rename(ref mut rt)) = self.active_modal {
rt.error = Some(msg.into());
}
return Task::none();
}
let parent_dir = Path::new(&target.path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_rel_path = if parent_dir.is_empty() {
trimmed.clone()
} else {
format!("{parent_dir}/{trimmed}")
};
let new_abs_path = Path::new(&target.ws_root)
.join(&new_rel_path)
.to_string_lossy()
.to_string();
if Path::new(&new_abs_path).exists() {
if let Some(ModalKind::Rename(ref mut rt)) = self.active_modal {
rt.error = Some("A file or directory with that name already exists".into());
}
return Task::none();
}
self.active_modal = None;
let old_abs = target.abs_path.clone();
let old_rel = target.path.clone();
let is_dir = target.is_dir;
let parent_dir_clone = parent_dir;
let ws_root = target.ws_root;
let dir_gen = self.bump_generation();
self.dir_generations
.insert(parent_dir_clone.clone(), dir_gen);
Task::perform(
async move {
let old_lower = old_rel.to_lowercase();
let new_lower = new_rel_path.to_lowercase();
let result = if old_lower == new_lower && old_rel != new_rel_path {
let temp_name = format!(
"{}_{}",
trimmed,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
);
let temp_abs = Path::new(&ws_root)
.join(&parent_dir_clone)
.join(&temp_name)
.to_string_lossy()
.to_string();
if let Err(e) = tokio::fs::rename(&old_abs, &temp_abs).await {
Err(format!("Rename failed: {e}"))
} else {
tokio::fs::rename(&temp_abs, &new_abs_path)
.await
.map_err(|e| format!("Rename failed: {e}"))
}
} else {
tokio::fs::rename(&old_abs, &new_abs_path)
.await
.map_err(|e| format!("Rename failed: {e}"))
};
let entries = read_directory_entries(&ws_root, &parent_dir_clone).await;
EditorMessage::RenameCompleted {
old_path: old_rel,
new_path: new_rel_path,
is_dir,
result,
dir_entries: entries,
rename_gen: dir_gen,
}
},
|msg| msg,
)
}
fn dismiss_rename(&mut self) {
if matches!(self.active_modal, Some(ModalKind::Rename(_))) {
self.active_modal = None;
}
}
fn rename_cancel(&mut self) -> Task<EditorMessage> {
self.active_modal = None;
Task::none()
}
#[expect(clippy::too_many_lines)]
fn rename_completed(
&mut self,
old_path: &str,
new_path: &str,
is_dir: bool,
result: Result<(), String>,
dir_entries: Result<Vec<FsEntry>, ReadDirError>,
rename_gen: u64,
) -> Task<EditorMessage> {
let re_path = Path::new(old_path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if self.dir_generations.get(&re_path) != Some(&rename_gen) {
return Task::none();
}
self.dir_generations.remove(&re_path);
match result {
Ok(()) => {
if self.selected_file.as_deref() == Some(old_path) {
self.selected_file = Some(new_path.to_string());
}
let Some(old_abs) = self.abs_path(old_path) else {
return Task::none();
};
let Some(new_abs) = self.abs_path(new_path) else {
return Task::none();
};
if is_dir {
let old_prefix = format!("{old_abs}/");
for tab in &mut self.tabs {
if tab.path.starts_with(&old_prefix) {
let rest = tab.path.strip_prefix(&old_prefix).unwrap();
tab.path = format!("{new_abs}/{rest}");
tab.file_name = Path::new(&tab.path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
}
}
rekey_map_prefix(
&mut self.tab_contents,
&format!("{old_abs}/"),
&new_abs,
|_| {},
);
if self.file_tree.expanded_dirs.remove(old_path) {
self.file_tree.expanded_dirs.insert(new_path.to_string());
}
rekey_set_prefix(
&mut self.file_tree.expanded_dirs,
&format!("{old_path}/"),
new_path,
);
let old_entries_prefix = format!("{old_path}/");
let new_path_clone = new_path.to_string();
rekey_map_prefix(
&mut self.dir_entries,
&old_entries_prefix,
new_path,
|entries: &mut Vec<FsEntry>| {
for entry in entries.iter_mut() {
update_entry_path(entry, &old_entries_prefix, &new_path_clone);
}
},
);
if let Some(mut own_entries) = self.dir_entries.remove(old_path) {
for entry in &mut own_entries {
update_entry_path(entry, &old_entries_prefix, new_path);
}
self.dir_entries.insert(new_path.to_string(), own_entries);
}
} else {
for tab in &mut self.tabs {
if tab.path == old_abs {
tab.path.clone_from(&new_abs);
tab.file_name = Path::new(&new_abs)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
break;
}
}
if let Some(data) = self.tab_contents.remove(&old_abs) {
self.tab_contents.insert(new_abs.clone(), data);
}
}
if is_dir {
let old_abs_prefix = format!("{old_abs}/");
rekey_map_prefix(&mut self.file_mtimes, &old_abs_prefix, &new_abs, |_| {});
rekey_set_prefix(&mut self.deleted_file_toasted, &old_abs_prefix, &new_abs);
rekey_map_prefix(
&mut self.file_generations,
&old_abs_prefix,
&new_abs,
|_| {},
);
} else {
if let Some(mtime) = self.file_mtimes.remove(&old_abs) {
self.file_mtimes.insert(new_abs.clone(), mtime);
}
if self.deleted_file_toasted.remove(&old_abs) {
self.deleted_file_toasted.insert(new_abs.clone());
}
if let Some(file_gen) = self.file_generations.remove(&old_abs) {
self.file_generations.insert(new_abs.clone(), file_gen);
}
}
match dir_entries {
Ok(entries) => {
self.dir_entries.insert(re_path, entries);
self.rebuild_tree();
self.file_tree.focus_path(new_path);
}
Err(ReadDirError::NotFound) => {
self.forget_directory(&re_path);
self.rebuild_tree();
}
Err(ReadDirError::Other(err)) => {
self.rebuild_tree();
return Task::done(EditorMessage::Toast(super::ToastMessage::Error(
format!(
"Rename succeeded but failed to refresh tree '{re_path}': {err}"
),
)));
}
}
Task::batch([
self.save_current_tabs(),
Task::done(EditorMessage::Toast(super::ToastMessage::SuccessMsg(
format!("Renamed \"{old_path}\" → \"{new_path}\""),
))),
])
}
Err(e) => {
match dir_entries {
Ok(entries) => {
self.dir_entries.insert(re_path, entries);
self.rebuild_tree();
}
Err(ReadDirError::NotFound) => {
self.forget_directory(&re_path);
self.rebuild_tree();
}
Err(ReadDirError::Other(_)) => {
self.rebuild_tree();
}
}
Task::done(EditorMessage::Toast(super::ToastMessage::Error(e)))
}
}
}
fn quick_open_toggle(&mut self) -> Task<EditorMessage> {
if matches!(self.active_modal, Some(ModalKind::QuickOpen(_))) {
self.active_modal = None;
return Task::none();
}
if self.active_modal.is_some() {
return Task::none();
}
self.scan_all_workspace_files();
self.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: String::new(),
selected_index: 0,
results: Vec::new(),
}));
iced::widget::operation::focus::<EditorMessage>(Id::new(QUICK_OPEN_INPUT_ID))
}
fn quick_open_input(&mut self, filter: String) -> Task<EditorMessage> {
let results = self.filter_workspace_files(&filter);
if let Some(ModalKind::QuickOpen(ref mut qo)) = self.active_modal {
qo.filter = filter;
qo.results = results;
qo.selected_index = 0;
}
Task::none()
}
fn quick_open_select(&mut self, idx: usize) -> Task<EditorMessage> {
let result_path = match &self.active_modal {
Some(ModalKind::QuickOpen(qo)) => qo.results.get(idx).cloned(),
_ => None,
};
self.active_modal = None;
if let Some(path) = result_path {
return self.open_file_in_editor(&path);
}
Task::none()
}
fn close_active_tab(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let idx = self.active_tab_index;
if idx < self.tabs.len() {
return self.close_tab_at(idx);
}
Task::none()
}
fn tree_focus_toggled(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
self.file_tree.tree_focused = !self.file_tree.tree_focused;
if self.file_tree.tree_focused && self.file_tree.visible_tree_nodes.is_empty() {
self.file_tree.rebuild_visible();
}
if !self.file_tree.tree_focused || self.file_tree.visible_tree_nodes.is_empty() {
self.file_tree.tree_focused = false;
self.pending_enter_dir = None;
}
Task::none()
}
fn tree_scrolled(&mut self, scroll_y: f32, viewport_h: f32) -> Task<EditorMessage> {
self.file_tree.scroll_y = scroll_y;
self.file_tree.viewport_h = Some(viewport_h);
Task::none()
}
fn tab_bar_scrolled(&mut self, scroll_x: f32, viewport_w: f32) -> Task<EditorMessage> {
self.tab_scroll_x = scroll_x;
self.tab_viewport_w = Some(viewport_w);
Task::none()
}
fn tree_nav_enter(&mut self) -> Task<EditorMessage> {
match &self.active_modal {
Some(ModalKind::GlobalSearch(gs)) => {
let idx = gs.selected_index.min(gs.results.len().saturating_sub(1));
return Task::done(EditorMessage::GlobalSearchSelect(idx));
}
Some(ModalKind::QuickOpen(qo)) => {
let idx = qo.selected_index.min(qo.results.len().saturating_sub(1));
return Task::done(EditorMessage::QuickOpenSelect(idx));
}
_ => {}
}
if self.active_modal.is_some() {
return Task::none();
}
let Some((_idx, path, is_dir)) = self.file_tree.focused_tree_node() else {
return Task::none();
};
if self.file_tree.focused_is_expanded_dir() {
return self.collapse_dir(&path);
}
if is_dir {
return self.expand_dir_and_focus(&path, "TreeNavEnter");
}
Task::done(EditorMessage::SelectFile(path))
}
fn tree_nav_left(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let Some((_idx, path, _)) = self.file_tree.focused_tree_node() else {
return Task::none();
};
if self.file_tree.focused_is_expanded_dir() {
return self.collapse_dir(&path);
}
self.file_tree.focus_parent::<EditorMessage>()
}
fn tree_nav_right(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let Some((idx, path, is_dir)) = self.file_tree.focused_tree_node() else {
return Task::none();
};
if !is_dir {
return Task::none();
}
if !self.file_tree.expanded_dirs.contains(&path) {
return self.expand_dir_and_focus(&path, "TreeNavRight");
}
self.file_tree.focus_next_row::<EditorMessage>(idx)
}
fn find_toggle(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let Some((_, path)) = self.active_tab() else {
return Task::none();
};
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
if tab_data.find_replace_state.is_none() {
let default_query = tab_data.content.selection().unwrap_or_default();
let mut state = FindReplaceState {
query: default_query,
replace: String::new(),
matches: Vec::new(),
current_match_idx: 0,
case_sensitive: false,
};
state.recompute(&tab_data.content);
tab_data.find_replace_state = Some(state);
}
}
iced::widget::operation::focus::<EditorMessage>(Id::new(FIND_SEARCH_ID))
}
fn with_active_find_state(
&mut self,
f: impl FnOnce(&mut FindReplaceState, &EditorBuffer),
) -> Task<EditorMessage> {
let Some((_, path)) = self.active_tab() else {
return Task::none();
};
if let Some(tab_data) = self.tab_contents.get_mut(&path)
&& let Some(state) = tab_data.find_replace_state.as_mut()
{
f(state, &tab_data.content);
}
Task::none()
}
fn find_query_input(&mut self, query: String) -> Task<EditorMessage> {
self.with_active_find_state(|state, content| {
state.query = query;
state.recompute(content);
})
}
fn find_replace_input(&mut self, replace: String) -> Task<EditorMessage> {
self.with_active_find_state(|state, _| {
state.replace = replace;
})
}
fn find_replace(&mut self) -> Task<EditorMessage> {
let Some((idx, path)) = self.active_tab() else {
return Task::none();
};
let Some((range, replace_text, case_sensitive, query)) = self
.tab_contents
.get(&path)
.and_then(|tab_data| tab_data.find_replace_state.as_ref())
.and_then(|state| {
let range = state.matches.get(state.current_match_idx)?;
Some((
range.clone(),
state.replace.clone(),
state.case_sensitive,
state.query.clone(),
))
})
else {
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
return Task::none();
};
let replace_end = range.start + replace_text.len();
if replace_text.is_empty() && range.start >= range.end {
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
return Task::none();
}
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data
.undo_stack
.borrow_mut()
.snap_before_edit(&tab_data.content);
let text = tab_data.content.text();
let new_text = format!(
"{}{}{}",
&text[..range.start],
replace_text,
&text[range.end..]
);
tab_data.content = EditorBuffer::from_file(&new_text, &path);
if let Some(ref mut state) = tab_data.find_replace_state {
state.matches = compute_text_matches(&new_text, &query, case_sensitive);
if !state.matches.is_empty() {
let next_idx = state
.matches
.iter()
.position(|m| m.start >= replace_end)
.unwrap_or(0)
.min(state.matches.len() - 1);
state.current_match_idx = next_idx;
if let Some(r) = state.matches.get(next_idx) {
let (line, col) = byte_offset_to_line_col(&new_text, r.start);
tab_data.content.move_to(line, col);
}
} else {
state.current_match_idx = 0;
let (line, col) = byte_offset_to_line_col(&new_text, replace_end);
tab_data.content.move_to(line, col);
}
}
}
update_dirty_flag(&mut self.tabs, &self.tab_contents, idx, &path);
Task::none()
}
fn find_toggle_case_sensitivity(&mut self) -> Task<EditorMessage> {
self.with_active_find_state(|state, content| {
state.case_sensitive = !state.case_sensitive;
state.recompute(content);
})
}
fn refresh_file_tree(&mut self) -> Task<EditorMessage> {
if self.active_modal.is_some() {
return Task::none();
}
let Some(ref ws_path) = self.selected_workspace_path else {
return Task::none();
};
let mut dirs_to_refresh: Vec<String> = Vec::new();
dirs_to_refresh.push(String::new());
dirs_to_refresh.extend(self.file_tree.expanded_dirs.iter().cloned());
dirs_to_refresh.retain(|d| !self.loading_dirs.contains(d));
if dirs_to_refresh.is_empty() {
return Task::none();
}
let mut tasks: Vec<Task<EditorMessage>> = Vec::new();
let root_path = ws_path.clone();
for dir_path in dirs_to_refresh {
let dir_gen = self.bump_generation();
self.dir_generations.insert(dir_path.clone(), dir_gen);
tasks.push(dir_expanded_task(
root_path.clone(),
dir_path.clone(),
dir_gen,
true,
));
}
if !self.git_status_loading {
self.git_status_loading = true;
let path = root_path;
let r#gen = self.git_status_gen;
tasks.push(Task::perform(
async move { load_git_status(path).await },
move |result| EditorMessage::GitStatusLoaded { r#gen, result },
));
}
Task::batch(tasks)
}
fn tick(&mut self) -> Task<EditorMessage> {
if let Some(ref ws_path) = self.selected_workspace_path {
let mut tasks: Vec<Task<EditorMessage>> = Vec::new();
if !self.git_status_loading {
self.git_status_loading = true;
let path = ws_path.clone();
let r#gen = self.git_status_gen;
tasks.push(Task::perform(
async move { load_git_status(path).await },
move |result| EditorMessage::GitStatusLoaded { r#gen, result },
));
}
if !self.git_ignore_loading {
self.git_ignore_loading = true;
let path = ws_path.clone();
let tree_paths = collect_tree_paths(&self.file_tree.nodes);
let r#gen = self.git_status_gen;
tasks.push(Task::perform(
async move { load_git_ignore(path, tree_paths).await },
move |result| EditorMessage::GitIgnoredLoaded { r#gen, result },
));
}
Task::batch(tasks)
} else {
Task::none()
}
}
fn check_file_changes(&mut self) -> Task<EditorMessage> {
let Some((idx, path)) = self.active_tab() else {
return Task::none();
};
if self.tabs[idx].is_dirty {
return Task::none();
}
let current_mtime = if let Ok(meta) = std::fs::metadata(&path) {
meta.modified().ok()
} else {
if !self.deleted_file_toasted.contains(&path) {
self.deleted_file_toasted.insert(path);
return Task::done(EditorMessage::Toast(super::ToastMessage::Warning(format!(
"File was deleted: {}",
self.tabs[idx].file_name
))));
}
return Task::none();
};
self.deleted_file_toasted.remove(&path);
let Some(current_mtime) = current_mtime else {
return Task::none();
};
let stored_mtime = if let Some(m) = self.file_mtimes.get(&path) {
*m
} else {
self.file_mtimes.insert(path, current_mtime);
return Task::none();
};
if current_mtime == stored_mtime {
return Task::none();
}
let cursor = if let Some(tab_data) = self.tab_contents.get(&path) {
tab_data.content.cursor()
} else {
return Task::none();
};
Task::perform(
async move {
let result = match tokio::fs::read_to_string(&path).await {
Ok(text) => validate_file_content(text.as_bytes()).map(|()| text),
Err(e) => Err(format!("Cannot read file: {e}")),
};
EditorMessage::FileReloaded {
path,
result,
cursor_line: cursor.line,
cursor_col: cursor.column,
}
},
|msg| msg,
)
}
fn file_reloaded(
&mut self,
path: String,
result: Result<String, String>,
cursor_line: usize,
cursor_col: usize,
) -> Task<EditorMessage> {
let Some(idx) = self.active_tab_idx() else {
return Task::none();
};
if self.tabs[idx].path != path || self.tabs[idx].is_dirty {
return Task::none();
}
let task = match result {
Ok(text) => {
let line_ending = detect_line_ending(&text);
if let Some(tab) = self.tabs.get_mut(idx) {
tab.is_dirty = false;
tab.line_ending = line_ending;
}
if let Some(tab_data) = self.tab_contents.get_mut(&path) {
tab_data.find_replace_state = None;
tab_data.content = EditorBuffer::from_file(&text, &path);
tab_data.content.move_to(cursor_line, cursor_col);
*tab_data.undo_stack.borrow_mut() = UndoStack::new();
tab_data.saved_text_hash = hash_text(&text);
}
Task::none()
}
Err(e) => Task::done(EditorMessage::Toast(super::ToastMessage::Warning(e))),
};
if let Ok(meta) = std::fs::metadata(&path) {
if let Ok(mtime) = meta.modified() {
self.file_mtimes.insert(path, mtime);
}
}
task
}
fn navigate_find_match(&mut self, direction: FindDirection) -> Task<EditorMessage> {
self.with_active_find_state(|state, content| {
if !state.matches.is_empty() {
let new_idx = match direction {
FindDirection::Next => (state.current_match_idx + 1) % state.matches.len(),
FindDirection::Prev => {
if state.current_match_idx == 0 {
state.matches.len().saturating_sub(1)
} else {
state.current_match_idx - 1
}
}
};
state.current_match_idx = new_idx;
if let Some(range) = state.matches.get(new_idx) {
let (line, col) = byte_offset_to_line_col(&content.text(), range.start);
content.move_to(line, col);
}
}
})
}
fn navigate_search_results(
selected_index: &mut usize,
results_len: usize,
direction: TreeNavDirection,
) {
match direction {
TreeNavDirection::Up if *selected_index > 0 => *selected_index -= 1,
TreeNavDirection::Down if *selected_index + 1 < results_len => *selected_index += 1,
_ => {}
}
}
fn navigate_tree_vertical(&mut self, direction: TreeNavDirection) -> Task<EditorMessage> {
if let Some(ModalKind::GlobalSearch(ref mut gs)) = self.active_modal {
Self::navigate_search_results(&mut gs.selected_index, gs.results.len(), direction);
return Task::none();
}
if let Some(ModalKind::QuickOpen(ref mut qo)) = self.active_modal {
Self::navigate_search_results(&mut qo.selected_index, qo.results.len(), direction);
return Task::none();
}
if self.active_modal.is_some() {
return Task::none();
}
self.file_tree.nav_and_scroll::<EditorMessage>(direction)
}
#[must_use]
pub fn view(&self) -> Element<'_, EditorMessage> {
if self.selected_workspace_name.is_none() {
return empty_placeholder(
text("No workspace selected")
.size(24)
.color(theme::TEXT_MUTED)
.font(theme::FONT_BOLD),
);
}
let tree_panel = self.build_tree_panel();
let editor_panel = self.build_editor_panel();
let split = row![tree_panel, editor_panel]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill);
let body = column([split.into()])
.spacing(0)
.width(Length::Fill)
.height(Length::Fill);
let placeholder: Element<'_, EditorMessage> = widget_helpers::empty_stack_placeholder();
let overlay: Element<'_, EditorMessage> = match &self.active_modal {
Some(ModalKind::CloseDialog(tab_idx)) => editor_dialog::build_close_dialog(
EditorMessage::CloseDialog {
tab_index: *tab_idx,
action: CloseAction::Save,
},
EditorMessage::CloseDialog {
tab_index: *tab_idx,
action: CloseAction::Discard,
},
EditorMessage::CloseDialog {
tab_index: *tab_idx,
action: CloseAction::Cancel,
},
"This file has unsaved changes. What would you like to do?".to_string(),
),
Some(ModalKind::CloseOthers(keep_idx)) => {
let dirty_count = self
.tabs
.iter()
.enumerate()
.filter(|(i, t)| *i != *keep_idx && t.is_dirty)
.count();
let desc = if dirty_count == 1 {
"1 file has unsaved changes. What would you like to do?".to_string()
} else {
format!("{dirty_count} files have unsaved changes. What would you like to do?")
};
editor_dialog::build_close_dialog(
EditorMessage::CloseOthersDialog {
keep_idx: *keep_idx,
action: CloseAction::Save,
},
EditorMessage::CloseOthersDialog {
keep_idx: *keep_idx,
action: CloseAction::Discard,
},
EditorMessage::CloseOthersDialog {
keep_idx: *keep_idx,
action: CloseAction::Cancel,
},
desc,
)
}
Some(ModalKind::GlobalSearch(gs)) => editor_dialog::build_global_search_overlay(gs),
Some(ModalKind::QuickOpen(qo)) => editor_dialog::build_quick_open_overlay(qo),
Some(ModalKind::NewItem(target)) => editor_dialog::build_new_item_input(target),
Some(ModalKind::DeleteConfirm(target)) => {
editor_dialog::build_delete_confirm_dialog(target)
}
Some(ModalKind::GotoLine(_) | ModalKind::Rename(_)) | None => placeholder,
};
iced::widget::stack([body.into(), overlay]).into()
}
fn build_tree_panel(&self) -> Element<'_, EditorMessage> {
let elements: Vec<Element<'_, EditorMessage>> = self
.file_tree
.nodes
.iter()
.enumerate()
.map(|(i, n)| self.render_tree_node(n, 0, 0, i == self.file_tree.nodes.len() - 1))
.collect();
let row_widths = widgets::collect_tree_row_widths(
&self.file_tree.nodes,
&self.file_tree.expanded_dirs,
|node, depth| {
let guide_chars = depth * 2;
if node.is_dir {
let label: std::borrow::Cow<'_, str> =
if self.loading_dirs.contains(&node.full_path) {
std::borrow::Cow::Owned(format!("{} Loading…", node.name))
} else if let Some(ref err) = node.error {
std::borrow::Cow::Owned(format!("{} [⚠ {err}]", node.name))
} else {
std::borrow::Cow::Borrowed(&node.name)
};
widgets::tree_row_natural_width(
guide_chars,
widgets::TREE_ICON_SIZE,
&label,
widgets::TREE_FONT_SIZE,
None,
None,
)
} else {
let suffix = node.error.as_ref().map(|_| ("[⚠]", 11.0));
widgets::tree_row_natural_width(
guide_chars,
widgets::TREE_FONT_SIZE,
&node.name,
widgets::TREE_FONT_SIZE,
suffix,
None,
)
}
},
);
let panel = widgets::build_tree_panel(&self.file_tree, elements, &row_widths, |viewport| {
EditorMessage::TreeScrolled(viewport.absolute_offset().y, viewport.bounds().height)
});
ContextMenu::new(
panel,
vec![
MenuItem::new(
"New File".into(),
EditorMessage::NewFileRequested(String::new()),
),
MenuItem::new(
"New Directory".into(),
EditorMessage::NewDirectoryRequested(String::new()),
),
],
)
.into()
}
fn dir_git_status(&self, node: &widgets::TreeNode) -> Option<GitFileStatus> {
let mut best: Option<GitFileStatus> = None;
for child in &node.children {
if child.is_dir {
if let Some(status) = self.dir_git_status(child) {
if best != Some(GitFileStatus::Modified) {
best = Some(status);
}
}
} else {
match self.git_status_cache.get(&child.full_path) {
Some(&GitFileStatus::Modified) => return Some(GitFileStatus::Modified),
Some(&GitFileStatus::Added) => {
best = Some(GitFileStatus::Added);
}
None => {}
}
}
}
best
}
#[must_use]
fn is_path_ignored(&self, full_path: &str) -> bool {
if self.git_ignore_cache.is_empty() {
return false;
}
if self.git_ignore_cache.contains(full_path) {
return true;
}
let mut path = full_path;
while let Some(pos) = path.rfind('/') {
path = &path[..pos];
if self.git_ignore_cache.contains(path) {
return true;
}
}
false
}
#[expect(clippy::too_many_arguments)]
fn render_tree_node_row<'a>(
&'a self,
guide: String,
icon: Element<'a, EditorMessage>,
name: Element<'a, EditorMessage>,
highlight: bool,
message: EditorMessage,
extra_context_items: Vec<MenuItem<EditorMessage>>,
full_path: &str,
) -> Element<'a, EditorMessage> {
let guide_text: Element<'a, EditorMessage> = text(guide)
.size(widgets::TREE_FONT_SIZE)
.color(theme::TEXT_MUTED)
.into();
let row = row![
guide_text,
icon,
Space::new().width(4),
name,
Space::new().width(Length::Fill),
]
.align_y(Alignment::Center)
.padding([0, 8]);
let btn = widgets::tree_node_button(row, highlight, Some(message));
let rel_path = full_path.to_string();
let mut menu_items: Vec<MenuItem<EditorMessage>> = extra_context_items;
menu_items.push(MenuItem::new(
"Copy Relative Path".into(),
EditorMessage::CopyRelativePath(rel_path),
));
if let Some(abs_path) = self.abs_path(full_path) {
menu_items.push(MenuItem::new(
"Copy Absolute Path".into(),
EditorMessage::CopyAbsolutePath(abs_path.clone()),
));
menu_items.push(MenuItem::new(
"Reveal in Finder".into(),
EditorMessage::RevealInFinder(abs_path),
));
}
ContextMenu::new(btn, menu_items).into()
}
fn render_tree_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, EditorMessage> {
widgets::render_tree_node(
node.is_dir,
|| self.render_dir_node(node, depth, ancestor_mask, is_last),
|| self.render_file_node(node, depth, ancestor_mask, is_last),
)
}
fn render_dir_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, EditorMessage> {
let is_expanded = self.file_tree.expanded_dirs.contains(&node.full_path);
let is_loading = self.loading_dirs.contains(&node.full_path);
let is_ignored = self.is_path_ignored(&node.full_path);
let icon = if is_expanded {
lucide::folder_open()
} else {
lucide::folder()
};
let dir_status = if is_expanded && !is_loading {
self.dir_git_status(node)
} else {
None
};
let icon_color = if is_ignored {
theme::TEXT_MUTED
} else if is_expanded && dir_status.is_some() {
match dir_status {
Some(GitFileStatus::Modified) => theme::STATUS_WARNING,
Some(GitFileStatus::Added) => theme::STATUS_SUCCESS,
_ => theme::ACCENT_LIGHT,
}
} else if is_expanded {
theme::ACCENT_LIGHT
} else {
theme::TEXT_MUTED
};
let (label_text, label_color) = if is_loading {
(format!("{} Loading…", node.name), theme::TEXT_MUTED)
} else if let Some(ref err) = node.error {
(format!("{} [⚠ {err}]", node.name), theme::STATUS_ERROR)
} else if is_ignored {
(node.name.clone(), theme::TEXT_MUTED)
} else if dir_status.is_some() {
let color = match dir_status {
Some(GitFileStatus::Modified) => theme::STATUS_WARNING,
Some(GitFileStatus::Added) => theme::STATUS_SUCCESS,
_ => theme::TEXT_SECONDARY,
};
(node.name.clone(), color)
} else {
(node.name.clone(), theme::TEXT_SECONDARY)
};
let is_focused = widgets::tree_node_focused(&self.file_tree, &node.full_path);
let icon_element: Element<'_, EditorMessage> =
icon.size(widgets::TREE_ICON_SIZE).color(icon_color).into();
let name_element: Element<'_, EditorMessage> =
self.build_rename_input(node).unwrap_or_else(|| {
text(label_text)
.size(widgets::TREE_FONT_SIZE)
.color(label_color)
.into()
});
let guide = widgets::tree_guide_prefix(ancestor_mask, depth, is_last);
let ctx_menu = self.render_tree_node_row(
guide,
icon_element,
name_element,
is_focused,
EditorMessage::ToggleDir(node.full_path.clone()),
vec![
MenuItem::new(
"New File".into(),
EditorMessage::NewFileRequested(node.full_path.clone()),
),
MenuItem::new(
"New Directory".into(),
EditorMessage::NewDirectoryRequested(node.full_path.clone()),
),
MenuItem::new(
"Rename".into(),
EditorMessage::RenameRequested(node.full_path.clone()),
),
MenuItem::new(
"Delete".into(),
EditorMessage::DeleteDirectoryRequested(node.full_path.clone()),
),
],
&node.full_path,
);
let mut col = column![ctx_menu].spacing(0);
if is_expanded {
for elem in widgets::render_tree_children(
&node.children,
depth,
ancestor_mask,
is_last,
|child, d, mask, last| self.render_tree_node(child, d, mask, last),
) {
col = col.push(elem);
}
}
col.into()
}
fn render_file_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, EditorMessage> {
let is_selected = self.selected_file.as_deref() == Some(&node.full_path);
let guide = widgets::tree_guide_prefix(ancestor_mask, depth, is_last);
let icon = lucide::file::<iced::Theme, iced::Renderer>();
let is_ignored = self.is_path_ignored(&node.full_path);
let icon_color = if is_selected {
theme::ACCENT
} else if is_ignored {
theme::TEXT_FAINT
} else {
theme::TEXT_MUTED
};
let git_status = self.git_status_cache.get(&node.full_path);
let name_color = if is_selected {
theme::TEXT_PRIMARY
} else if node.error.is_some() {
theme::STATUS_ERROR
} else if is_ignored {
theme::TEXT_MUTED
} else if git_status == Some(&GitFileStatus::Modified) {
theme::STATUS_WARNING
} else if git_status == Some(&GitFileStatus::Added) {
theme::STATUS_SUCCESS
} else {
theme::TEXT_SECONDARY
};
let name_weight = if is_selected {
iced::font::Weight::Bold
} else {
iced::font::Weight::Normal
};
let name_text: Element<'a, EditorMessage> =
self.build_rename_input(node).unwrap_or_else(|| {
if node.error.is_some() {
row![
text(&node.name)
.size(widgets::TREE_FONT_SIZE)
.color(name_color)
.font(iced::Font {
weight: name_weight,
..theme::FONT_REGULAR
}),
Space::new().width(4),
text("[⚠]").size(11).color(theme::STATUS_ERROR),
]
.align_y(Alignment::Center)
.into()
} else {
text(&node.name)
.size(widgets::TREE_FONT_SIZE)
.color(name_color)
.font(iced::Font {
weight: name_weight,
..theme::FONT_REGULAR
})
.into()
}
});
let is_focused = widgets::tree_node_focused(&self.file_tree, &node.full_path);
let icon_element: Element<'_, EditorMessage> =
icon.size(widgets::TREE_FONT_SIZE).color(icon_color).into();
self.render_tree_node_row(
guide,
icon_element,
name_text,
is_selected || is_focused,
EditorMessage::SelectFile(node.full_path.clone()),
vec![
MenuItem::new(
"Rename".into(),
EditorMessage::RenameRequested(node.full_path.clone()),
),
MenuItem::new(
"Delete".into(),
EditorMessage::DeleteFileRequested(node.full_path.clone()),
),
],
&node.full_path,
)
}
fn build_editor_panel(&self) -> Element<'_, EditorMessage> {
if self.tabs.is_empty() {
return empty_placeholder(
text("Select a file to edit")
.size(18)
.color(theme::TEXT_MUTED),
);
}
let tab_bar = self.build_tab_bar();
let find_bar = self.build_find_replace_bar();
let go_to_line = self.build_go_to_line_bar();
let editor_widget = self.build_editor_widget();
let mut col = column![tab_bar].spacing(0).width(Length::Fill);
if let Some(bar) = find_bar {
col = col.push(bar);
} else if let Some(bar) = go_to_line {
col = col.push(bar);
}
col = col.push(editor_widget);
col.height(Length::Fill).into()
}
fn build_tab_bar(&self) -> Element<'_, EditorMessage> {
let mut tab_buttons: Vec<Element<'_, EditorMessage>> = Vec::new();
for (i, tab) in self.tabs.iter().enumerate() {
let is_active = i == self.active_tab_index;
let dirty_dot: Option<Element<'_, EditorMessage>> = if tab.is_dirty {
Some(
lucide::circle::<iced::Theme, iced::Renderer>()
.size(8)
.color(theme::STATUS_WARNING)
.into(),
)
} else {
None
};
let name_color = if is_active {
theme::ACCENT
} else {
theme::TEXT_MUTED
};
let name_text = text(&tab.file_name).size(12).color(name_color);
let mut tab_row = row![].spacing(2).align_y(Alignment::Center);
if let Some(dot) = dirty_dot {
tab_row = tab_row.push(dot);
}
tab_row = tab_row.push(name_text).push(widgets::tab_close_button(
is_active,
EditorMessage::TabClosed(i),
));
let tab_btn = button(tab_row.padding([8, 8]))
.on_press(EditorMessage::TabSelected(i))
.style(theme::tab_button_style(is_active))
.padding(0);
let tab_abs_path = tab.path.clone();
let tab_rel_path = self
.selected_workspace_path
.as_ref()
.and_then(|ws| {
Path::new(&tab_abs_path)
.strip_prefix(ws)
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.unwrap_or_else(|| tab_abs_path.clone());
let ctx_menu = ContextMenu::new(
tab_btn,
vec![
MenuItem::new("Close".into(), EditorMessage::TabClosed(i)),
MenuItem::new("Close Others".into(), EditorMessage::CloseOtherTabs(i)),
MenuItem::new(
"Copy Relative Path".into(),
EditorMessage::CopyRelativePath(tab_rel_path),
),
MenuItem::new(
"Copy Absolute Path".into(),
EditorMessage::CopyAbsolutePath(tab_abs_path),
),
],
);
tab_buttons.push(ctx_menu.into());
}
widgets::tab_scrollable(
tab_buttons,
Some(self.tab_scroll_id.clone()),
Some(|viewport: scrollable::Viewport| {
EditorMessage::TabBarScrolled(viewport.absolute_offset().x, viewport.bounds().width)
}),
)
}
fn build_find_replace_bar(&self) -> Option<Element<'_, EditorMessage>> {
let idx = self.active_tab_idx()?;
let path = &self.tabs[idx].path;
let state = self.tab_contents.get(path)?.find_replace_state.as_ref()?;
let search_input = text_input("Find…", &state.query)
.on_input(EditorMessage::FindQueryInput)
.on_submit(EditorMessage::FindNext)
.id(Id::new(FIND_SEARCH_ID))
.style(widgets::text_input_style)
.width(Length::Fixed(200.0))
.size(13);
let replace_input = text_input("Replace…", &state.replace)
.on_input(EditorMessage::FindReplaceInput)
.on_submit(EditorMessage::FindNext)
.id(Id::new(FIND_REPLACE_ID))
.style(widgets::text_input_style)
.width(Length::Fixed(160.0))
.size(13);
let total = state.matches.len();
let match_label = if !state.query.is_empty() && state.query.len() < 2 {
"Min 2 chars".to_string()
} else if !state.query.is_empty() && total > 0 {
format!("{}/{}", state.current_match_idx.saturating_add(1), total)
} else if !state.query.is_empty() {
"0/0".to_string()
} else {
String::new()
};
let prev_btn = button(text("‹").size(14).color(theme::TEXT_SECONDARY))
.on_press(EditorMessage::FindPrev)
.style(theme::button_transparent)
.padding([2, 8]);
let next_btn = button(text("›").size(14).color(theme::TEXT_SECONDARY))
.on_press(EditorMessage::FindNext)
.style(theme::button_transparent)
.padding([2, 8]);
let replace_btn = button(text("Replace").size(11).color(theme::TEXT_SECONDARY))
.on_press(EditorMessage::FindReplace)
.style(theme::button_transparent)
.padding([2, 6]);
let replace_all_btn = button(text("All").size(11).color(theme::TEXT_SECONDARY))
.on_press(EditorMessage::FindReplaceAll)
.style(theme::button_transparent)
.padding([2, 6]);
let case_label_color = if state.case_sensitive {
theme::ACCENT_LIGHT
} else {
theme::TEXT_SECONDARY
};
let case_btn = button(text("Aa").size(11).color(case_label_color))
.on_press(EditorMessage::FindToggleCaseSensitivity)
.style(theme::button_transparent)
.padding([2, 6]);
let bar = row![
search_input,
replace_input,
prev_btn,
text(match_label).size(12).color(theme::TEXT_MUTED),
next_btn,
Space::new().width(Length::Fixed(4.0)),
case_btn,
replace_btn,
replace_all_btn,
]
.spacing(4)
.align_y(Alignment::Center)
.padding([4, 8]);
Some(
container(bar)
.style(theme::container_bar)
.width(Length::Fill)
.into(),
)
}
fn build_go_to_line_bar(&self) -> Option<Element<'_, EditorMessage>> {
let ModalKind::GotoLine(input_text) = self.active_modal.as_ref()? else {
return None;
};
let line_input = text_input("Line #", input_text)
.on_input(EditorMessage::GoToLineInput)
.on_submit(EditorMessage::GoToLineGo)
.id(Id::new(GOTO_LINE_INPUT_ID))
.style(widgets::text_input_style)
.width(Length::Fixed(120.0))
.size(13);
let go_btn = button(text("Go").size(12).color(theme::TEXT_SECONDARY))
.on_press(EditorMessage::GoToLineGo)
.style(theme::button_transparent)
.padding([2, 8]);
let bar = row![
text("Go to line:").size(12).color(theme::TEXT_MUTED),
Space::new().width(4),
line_input,
go_btn,
]
.spacing(4)
.align_y(Alignment::Center)
.padding([4, 8]);
Some(
container(bar)
.style(theme::container_bar)
.width(Length::Fill)
.into(),
)
}
fn build_editor_widget(&self) -> Element<'_, EditorMessage> {
let Some(idx) = self.active_tab_idx() else {
return empty_placeholder(
text("No file selected")
.size(EDITOR_FONT_SIZE)
.color(theme::TEXT_MUTED),
);
};
let path = &self.tabs[idx].path;
let Some(tab_data) = self.tab_contents.get(path) else {
return empty_placeholder(
text("Error: tab content missing")
.size(EDITOR_FONT_SIZE)
.color(theme::STATUS_ERROR),
);
};
let content = &tab_data.content;
let tree_focused = self.file_tree.tree_focused;
let find_bar_open = tab_data.find_replace_state.is_some();
let modal_overlay_open = self.active_modal.is_some();
let ignore_keyboard = tree_focused || modal_overlay_open || find_bar_open;
let (match_tuples, match_current_idx) = tab_data
.find_replace_state
.as_ref()
.map(|state| {
let text = tab_data.content.text();
let tuples: Vec<(usize, usize, usize)> = state
.matches
.iter()
.filter_map(|range| {
let (line, byte_col_start, line_start) =
byte_offset_to_line_byte_col(&text, range.start)?;
let byte_col_end = range.end.saturating_sub(line_start);
Some((line, byte_col_start, byte_col_end))
})
.collect();
(tuples, state.current_match_idx)
})
.unwrap_or_default();
let cursor = content.cursor();
let bracket_pair = if !ignore_keyboard && cursor.selection.is_none() {
let text = content.text();
super::editor_widget::find_matching_bracket(&text, cursor.line, cursor.column)
} else {
None
};
Self::build_highlighted_editor(
content,
Some(path.as_str()),
ignore_keyboard,
match_tuples,
match_current_idx,
bracket_pair,
)
}
fn build_highlighted_editor<'a>(
content: &'a super::editor_widget::EditorBuffer,
buffer_key: Option<&'a str>,
ignore_keyboard: bool,
matches: Vec<(usize, usize, usize)>,
match_current_idx: usize,
bracket_pair: Option<super::editor_widget::BracketPair>,
) -> Element<'a, EditorMessage> {
let editor = super::editor_widget::EditorWidget::new(content)
.padding(8.0)
.ignore_keyboard(ignore_keyboard)
.matches(matches, match_current_idx)
.bracket_pair(bracket_pair)
.buffer_key(buffer_key);
let element = iced::Element::new(editor);
let mapped = element.map(EditorMessage::EditorAction);
container(mapped)
.width(Length::Fill)
.height(Length::Fill)
.style(theme::base_container_style)
.into()
}
fn perform_file_delete(&mut self, target: &DeleteConfirmTarget) -> Task<EditorMessage> {
if let Some(tab_idx) = self.tabs.iter().position(|t| t.path == target.abs_path) {
self.remove_tab_at(tab_idx);
}
if self.selected_file.as_deref() == Some(&target.path) {
self.selected_file = None;
}
self.file_mtimes.remove(&target.abs_path);
self.deleted_file_toasted.remove(&target.abs_path);
self.perform_delete_with_refresh(
target.abs_path.clone(),
&target.path,
"file",
None,
|abs_path| async move {
tokio::fs::remove_file(&abs_path)
.await
.map_err(|e| e.to_string())
},
)
}
fn perform_dir_delete(&mut self, target: &DeleteConfirmTarget) -> Task<EditorMessage> {
let abs_prefix = format!("{}/", target.abs_path);
let mut affected_indices: Vec<usize> = self
.tabs
.iter()
.enumerate()
.filter(|(_, t)| t.path.starts_with(&abs_prefix))
.map(|(i, _)| i)
.collect();
affected_indices.sort_unstable_by(|a, b| b.cmp(a));
for &idx in &affected_indices {
self.remove_tab_at(idx);
}
self.perform_delete_with_refresh(
target.abs_path.clone(),
&target.path,
"directory",
Some(EditorMessage::DirDeleted {
dir_path: target.path.clone(),
workspace_path: self.selected_workspace_path.clone().unwrap_or_default(),
}),
|abs_path| async move {
tokio::fs::remove_dir_all(&abs_path)
.await
.map_err(|e| e.to_string())
},
)
}
fn perform_delete_with_refresh<D, F>(
&mut self,
abs_path: String,
rel_path: &str,
error_label: &'static str,
success_msg: Option<EditorMessage>,
delete_op: D,
) -> Task<EditorMessage>
where
D: FnOnce(String) -> F + 'static + Send,
F: Future<Output = Result<(), String>> + 'static + Send,
{
let parent_dir = {
let path = Path::new(rel_path);
path.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
};
let ws_path = self.selected_workspace_path.clone().unwrap_or_default();
let r#gen = if success_msg.is_none() {
let r#gen = self.bump_generation();
self.dir_generations.insert(parent_dir.clone(), r#gen);
Some(r#gen)
} else {
None
};
Task::perform(
async move {
if let Err(e) = delete_op(abs_path).await {
return EditorMessage::Toast(super::ToastMessage::Error(format!(
"Failed to delete {error_label}: {e}"
)));
}
if let Some(msg) = success_msg {
return msg;
}
let r#gen = r#gen.expect("file-delete path always registers a generation");
dir_expanded_msg(ws_path, parent_dir, r#gen, false).await
},
|msg| msg,
)
}
fn perform_create_item(&mut self, target: &NewItemTarget, name: &str) -> Task<EditorMessage> {
let abs_parent = target.abs_parent.clone();
let parent_dir = target.parent_dir.clone();
let is_dir = target.is_dir;
let ws_root = target.ws_root.clone();
let abs_new_path_str = Path::new(&abs_parent)
.join(name)
.to_string_lossy()
.to_string();
let r#gen = self.bump_generation();
self.dir_generations.insert(parent_dir.clone(), r#gen);
Task::perform(
async move {
if is_dir {
if let Err(e) = tokio::fs::create_dir(&abs_new_path_str).await {
return EditorMessage::Toast(super::ToastMessage::Error(format!(
"Failed to create directory: {e}"
)));
}
} else if let Err(e) = tokio::fs::write(&abs_new_path_str, "").await {
return EditorMessage::Toast(super::ToastMessage::Error(format!(
"Failed to create file: {e}"
)));
}
dir_expanded_msg(ws_root, parent_dir, r#gen, false).await
},
|msg| msg,
)
}
fn perform_reveal_in_finder(path: String) -> Task<EditorMessage> {
Task::perform(
async move {
#[cfg(target_os = "macos")]
{
if let Err(e) = std::process::Command::new("open")
.arg("-R")
.arg(&path)
.spawn()
{
tracing::warn!("Failed to open Finder for {path}: {e}");
}
}
#[cfg(target_os = "windows")]
{
if let Err(e) = std::process::Command::new("explorer")
.arg("/select,")
.arg(&path)
.spawn()
{
tracing::warn!("Failed to open Explorer for {path}: {e}");
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
if let Some(parent) = std::path::Path::new(&path).parent() {
if let Err(e) = std::process::Command::new("xdg-open").arg(parent).spawn() {
tracing::warn!("Failed to open file manager for {path}: {e}");
}
}
}
},
|()| (),
)
.discard::<EditorMessage>()
}
}
fn map_editor_shortcut(event: keyboard::Event) -> Option<EditorMessage> {
use keyboard::Key;
let (key, modifiers, physical_key) = super::parse_key_press(event)?;
let km = super::detect_keyboard_mods(modifiers);
let latin = |target: char| -> bool { key.to_latin(physical_key) == Some(target) };
if km.is_shortcut_platform_mod() && latin('b') {
return Some(EditorMessage::TreeFocusToggled);
}
if km.is_platform_mod && !km.altgr_active && modifiers.shift() && latin('f') {
return Some(EditorMessage::GlobalSearchToggle);
}
if km.is_shortcut_platform_mod() && !modifiers.shift() && latin('f') {
return Some(EditorMessage::FindToggle);
}
if km.is_shortcut_platform_mod() && latin('z') {
if modifiers.shift() {
return Some(EditorMessage::Redo);
}
return Some(EditorMessage::Undo);
}
if km.is_shortcut_platform_mod() && latin('s') {
return Some(EditorMessage::SaveActiveTab);
}
if modifiers.control() && matches!(key, Key::Named(keyboard::key::Named::Tab)) {
return if modifiers.shift() {
Some(EditorMessage::TabSwitchPrev)
} else {
Some(EditorMessage::TabSwitchNext)
};
}
if !km.altgr_active && modifiers.control() && latin('w') {
return Some(EditorMessage::CloseActiveTab);
}
#[cfg(target_os = "macos")]
{
if modifiers.command() && !modifiers.control() && latin('l') {
return Some(EditorMessage::GoToLineToggle);
}
}
#[cfg(not(target_os = "macos"))]
{
if !km.altgr_active && modifiers.control() && latin('g') {
return Some(EditorMessage::GoToLineToggle);
}
}
if km.is_shortcut_platform_mod() && latin('p') {
return Some(EditorMessage::QuickOpenToggle);
}
if km.is_shortcut_platform_mod() && latin('r') {
return Some(EditorMessage::RefreshFileTree);
}
#[cfg(target_os = "macos")]
if modifiers.command() && !modifiers.control() && latin('g') {
return if modifiers.shift() {
Some(EditorMessage::FindPrev)
} else {
Some(EditorMessage::FindNext)
};
}
if matches!(key, Key::Named(keyboard::key::Named::F3)) {
return if modifiers.shift() {
Some(EditorMessage::FindPrev)
} else {
Some(EditorMessage::FindNext)
};
}
if modifiers.shift() && matches!(key, Key::Named(keyboard::key::Named::Enter)) {
return Some(EditorMessage::FindPrev);
}
match &key {
Key::Named(named) => match named {
keyboard::key::Named::ArrowUp => Some(EditorMessage::TreeNavUp),
keyboard::key::Named::ArrowDown => Some(EditorMessage::TreeNavDown),
keyboard::key::Named::ArrowLeft => Some(EditorMessage::TreeNavLeft),
keyboard::key::Named::ArrowRight => Some(EditorMessage::TreeNavRight),
keyboard::key::Named::Enter => Some(EditorMessage::TreeNavEnter),
_ => None,
},
_ => None,
}
}
impl FindReplaceState {
fn recompute(&mut self, content: &EditorBuffer) {
let text = content.text();
self.matches = compute_text_matches(&text, &self.query, self.case_sensitive);
auto_jump_to_first_match(&text, content, self);
}
}
#[must_use]
fn byte_offset_to_line_byte_col(text: &str, offset: usize) -> Option<(usize, usize, usize)> {
if offset > text.len() {
return None;
}
let (line, line_start) = super::text_rendering::byte_line_and_start(text, offset);
Some((line, offset - line_start, line_start))
}
fn auto_jump_to_first_match(
text: &str,
content: &super::editor_widget::EditorBuffer,
state: &mut FindReplaceState,
) {
state.current_match_idx = 0;
if let Some(range) = state.matches.first() {
let (line, col) = byte_offset_to_line_col(text, range.start);
content.move_to(line, col);
}
}
#[cfg(test)]
#[path = "editor_tests.rs"]
mod tests;