//! Source paths are navigation groups, not model containment or include ownership. //! The workspace retains every buffer; expanding folders never changes its files. use crate::{context_menu, source_workspace::Workspace, style}; use iced::{ Element, Fill, Point, widget::{Space, button, column, container, row, scrollable, text, tooltip}, }; use oa_core::source_project::SourceDiagnostic; use std::collections::{BTreeMap, BTreeSet}; const ROW_HEIGHT: f32 = 32.; const INDENT: f32 = 16.; #[derive(Debug, Clone)] pub enum Event { ToggleFolder(String), OpenFile(String), FileMenu(String, Point), } /// Presentation only. Folder keys are exact logical prefixes without a final `/`. /// Start compact; explicit file navigation reveals that file's ancestors. #[derive(Default)] pub struct State { expanded: BTreeSet, } impl State { pub fn toggle(&mut self, path: &str) { if !self.expanded.remove(path) { self.expanded.insert(path.into()); } } pub fn reveal(&mut self, file: &str) { for (offset, _) in file.match_indices('/') { self.expanded.insert(file[..offset].into()); } } pub fn collapse_all(&mut self) { self.expanded.clear(); } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum RowKind { Folder { expanded: bool, file_count: usize, }, File { entry: bool, detached: bool, closable: bool, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Row { /// Original logical file path or exact folder prefix. Never normalized here. pub path: String, pub name: String, pub depth: usize, pub kind: RowKind, pub selected: bool, pub contains_active: bool, pub dirty_files: usize, pub diagnostics: usize, } impl Row { pub fn tooltip(&self) -> String { let mut lines = vec![match &self.kind { RowKind::Folder { .. } => format!("{}/", self.path), RowKind::File { .. } => self.path.clone(), }]; match self.kind { RowKind::Folder { file_count, .. } => { lines.push(format!( "{file_count} open source file{}", if file_count == 1 { "" } else { "s" } )); if self.contains_active { lines.push("Contains the active source file".into()); } } RowKind::File { entry, detached, .. } => { lines.push(if detached { "Detached buffer · closing never deletes its file".into() } else if entry { "Project entry".into() } else { "Included source file".into() }); if self.selected { lines.push("Active source file".into()); } } } if self.dirty_files > 0 { lines.push(format!( "{} modified source draft{}", self.dirty_files, if self.dirty_files == 1 { "" } else { "s" } )); } if self.diagnostics > 0 { lines.push(format!( "{} source diagnostic{}", self.diagnostics, if self.diagnostics == 1 { "" } else { "s" } )); } lines.join("\n") } } #[derive(Default)] struct Folder { name: String, folders: BTreeMap, files: Vec, file_count: usize, dirty_files: usize, diagnostics: usize, contains_active: bool, } enum Pending { Folder(usize, String, usize), File(usize, usize), } /// Build a flat arena and traverse only expanded folders. The arena avoids /// recursive construction, traversal and drop for deeply nested logical paths. pub fn rows(workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State) -> Vec { let mut issue_counts = BTreeMap::<&str, usize>::new(); for issue in diagnostics { *issue_counts.entry(&issue.path).or_default() += 1; } let mut folders = vec![Folder::default()]; let mut files = Vec::with_capacity(workspace.files.len()); for (path, buffer) in &workspace.files { let mut parts = path.split('/').peekable(); let mut current = 0; let mut ancestors = vec![0]; let mut name = ""; while let Some(part) = parts.next() { if parts.peek().is_none() { name = part; break; } current = match folders[current].folders.get(part).copied() { Some(index) => index, None => { let index = folders.len(); folders.push(Folder { name: part.into(), ..Default::default() }); folders[current].folders.insert(part.into(), index); index } }; ancestors.push(current); } let detached = !workspace.reachable.contains(path); let dirty_files = usize::from(buffer.dirty()); let diagnostics = issue_counts.get(path.as_str()).copied().unwrap_or(0); let selected = workspace.active == *path; for index in ancestors { let folder = &mut folders[index]; folder.file_count += 1; folder.dirty_files += dirty_files; folder.diagnostics += diagnostics; folder.contains_active |= selected; } folders[current].files.push(files.len()); files.push(Row { path: path.clone(), name: name.into(), depth: 0, kind: RowKind::File { entry: workspace.entry == *path, detached, closable: detached && !buffer.dirty(), }, selected, contains_active: selected, dirty_files, diagnostics, }); } let mut pending = Vec::new(); append_children(&mut pending, &folders, &files, 0, "", 0); let mut result = Vec::new(); while let Some(next) = pending.pop() { match next { Pending::File(index, depth) => { let mut row = files[index].clone(); row.depth = depth; result.push(row); } Pending::Folder(index, path, depth) => { let folder = &folders[index]; let expanded = state.expanded.contains(&path); result.push(Row { path: path.clone(), name: folder.name.clone(), depth, kind: RowKind::Folder { expanded, file_count: folder.file_count, }, selected: false, contains_active: folder.contains_active, dirty_files: folder.dirty_files, diagnostics: folder.diagnostics, }); if expanded { append_children(&mut pending, &folders, &files, index, &path, depth + 1); } } } } result } /// Locate the active file after `State::reveal`, retaining one ancestor row of /// context. If it is intentionally collapsed, locate its visible folder instead. pub fn active_offset( workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State, ) -> iced::widget::operation::AbsoluteOffset { let rows = rows(workspace, diagnostics, state); let active = rows .iter() .position(|row| row.selected) .or_else(|| rows.iter().rposition(|row| row.contains_active)); active.map_or_else(iced::widget::operation::AbsoluteOffset::default, |index| { iced::widget::operation::AbsoluteOffset { x: rows[index].depth.saturating_sub(1) as f32 * INDENT, y: (index as f32 * ROW_HEIGHT - ROW_HEIGHT).max(0.), } }) } fn append_children( pending: &mut Vec, folders: &[Folder], files: &[Row], parent: usize, prefix: &str, depth: usize, ) { let folder = &folders[parent]; let mut children = folder.files.clone(); children.sort_by_cached_key(|index| { ( files[*index].name.to_lowercase(), files[*index].path.clone(), ) }); pending.extend( children .into_iter() .rev() .map(|index| Pending::File(index, depth)), ); let mut children: Vec<_> = folder.folders.values().copied().collect(); children.sort_by_cached_key(|index| { ( folders[*index].name.to_lowercase(), folders[*index].name.clone(), ) }); pending.extend(children.into_iter().rev().map(|index| { let name = &folders[index].name; let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; Pending::Folder(index, path, depth) })); } pub fn view<'a>( workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State, ) -> Element<'a, Event> { let mut content = column![].spacing(0); for entry in rows(workspace, diagnostics, state) { let hint = entry.tooltip(); let (symbol, action, muted) = match entry.kind { RowKind::Folder { expanded, .. } => ( if expanded { "⌄" } else { "›" }, Event::ToggleFolder(entry.path.clone()), false, ), RowKind::File { entry: is_entry, detached, .. } => ( if is_entry { "◆" } else if detached { "○" } else { "·" }, Event::OpenFile(entry.path.clone()), detached, ), }; let caption = row![ text(symbol).size(15).width(18), text(entry.name) .size(12) .width(Fill) .wrapping(text::Wrapping::None) .color(if muted { style::MUTED } else { style::INK }), text(if entry.dirty_files > 0 { "*" } else { "" }) .size(12) .color(style::TEAL), text(if entry.diagnostics > 0 { "!" } else { "" }) .size(12) .color(style::MUTED), text(match entry.kind { RowKind::Folder { file_count, .. } => file_count.to_string(), RowKind::File { .. } => String::new(), }) .size(11) .color(style::MUTED), ] .spacing(4) .align_y(iced::Center); let selected = entry.selected || (entry.contains_active && matches!( entry.kind, RowKind::Folder { expanded: false, .. } )); let control = button(caption) .width(210. - entry.depth.min(2) as f32 * INDENT) .height(ROW_HEIGHT) .padding([7, 6]) .clip(true) .on_press(action) .style(if selected { style::selected } else { style::nav }); let control: Element<'a, Event> = if matches!(entry.kind, RowKind::File { .. }) { let path = entry.path; context_menu::Area::new(control, move |point| Event::FileMenu(path.clone(), point)) .into() } else { control.into() }; content = content.push( row![ Space::new().width(entry.depth as f32 * INDENT), Space::new().width(8), container(Space::new()) .width(1) .height(ROW_HEIGHT) .style(|_| container::Style { background: Some(style::LINE.into()), ..Default::default() }), Space::new().width(7), tooltip( control, container(text(hint).size(12)).padding(8).style(style::card), tooltip::Position::Right, ), ] .height(ROW_HEIGHT) .align_y(iced::Center), ); } if workspace.files.is_empty() { content = content.push( text("No source files are open.") .size(12) .color(style::MUTED), ); } scrollable(content) .id("source-files") .height(Fill) .width(Fill) .direction(scrollable::Direction::Both { vertical: scrollable::Scrollbar::default(), horizontal: scrollable::Scrollbar::default(), }) .into() } #[cfg(test)] mod tests { use super::*; use oa_core::{language, source_project::SourceBundle, validation::Severity}; fn workspace(paths: &[&str]) -> Workspace { Workspace::new( &SourceBundle { entry: "project.arcadia".into(), files: paths .iter() .map(|p| ((*p).into(), "// source\n".into())) .collect(), }, true, ) } fn diagnostic(path: &str) -> SourceDiagnostic { SourceDiagnostic { path: path.into(), diagnostic: language::Diagnostic { severity: Severity::Error, code: "test".into(), object_id: None, field: None, line: 1, column: 1, message: "Source needs repair".into(), span: language::Span::default(), }, related: Vec::new(), } } #[test] fn folders_are_compact_and_sorted_before_files_without_losing_duplicate_basenames() { let workspace = workspace(&[ "project.arcadia", "z.arcadia", "A.arcadia", "sa/parts/shared.arcadia", "oa/shared.arcadia", "sa/other.arcadia", "sa/Parts/another.arcadia", ]); let mut state = State::default(); let compact = rows(&workspace, &[], &state); assert_eq!( compact .iter() .map(|row| row.path.as_str()) .collect::>(), ["oa", "sa", "A.arcadia", "project.arcadia", "z.arcadia"] ); state.reveal("sa/parts/shared.arcadia"); state.reveal("oa/shared.arcadia"); let expanded = rows(&workspace, &[], &state); assert_eq!( expanded .iter() .map(|row| row.path.as_str()) .collect::>(), [ "oa", "oa/shared.arcadia", "sa", "sa/Parts", "sa/parts", "sa/parts/shared.arcadia", "sa/other.arcadia", "A.arcadia", "project.arcadia", "z.arcadia" ] ); assert_eq!( expanded .iter() .filter(|row| row.name == "shared.arcadia") .count(), 2 ); assert_eq!( expanded .iter() .find(|row| row.path == "sa/parts/shared.arcadia") .unwrap() .depth, 2 ); } #[test] fn collapsed_folders_retain_active_dirty_and_diagnostic_markers() { let mut workspace = workspace(&[ "project.arcadia", "sa/parts/first.arcadia", "sa/second.arcadia", ]); workspace.active = "sa/parts/first.arcadia".into(); workspace .files .get_mut(&workspace.active) .unwrap() .text .push_str("// draft\n"); let issues = [ diagnostic(&workspace.active), diagnostic("sa/second.arcadia"), diagnostic("missing.arcadia"), ]; let mut state = State::default(); let compact = rows(&workspace, &issues, &state); let folder = compact.iter().find(|row| row.path == "sa").unwrap(); assert!(folder.contains_active); assert!(!folder.selected); assert_eq!((folder.dirty_files, folder.diagnostics), (1, 2)); assert!(folder.tooltip().contains("Contains the active source file")); state.reveal(&workspace.active); let expanded = rows(&workspace, &issues, &state); let active = expanded.iter().find(|row| row.selected).unwrap(); assert_eq!(active.path, workspace.active); assert_eq!((active.dirty_files, active.diagnostics), (1, 1)); assert!(active.tooltip().contains("1 modified source draft")); state.collapse_all(); assert_eq!(rows(&workspace, &issues, &state), compact); } #[test] fn detached_buffers_keep_paths_and_only_clean_buffers_are_closable() { let mut workspace = workspace(&[ "project.arcadia", "archive/clean.arcadia", "archive/draft.arcadia", ]); workspace.reachable.retain(|path| path == "project.arcadia"); workspace .files .get_mut("archive/draft.arcadia") .unwrap() .text .push_str("// retain\n"); let mut state = State::default(); state.toggle("archive"); let all = rows(&workspace, &[], &state); assert_eq!( all.iter() .find(|row| row.path == "archive/clean.arcadia") .unwrap() .kind, RowKind::File { entry: false, detached: true, closable: true } ); assert_eq!( all.iter() .find(|row| row.path == "archive/draft.arcadia") .unwrap() .kind, RowKind::File { entry: false, detached: true, closable: false } ); assert_eq!( all.iter() .find(|row| row.path == "project.arcadia") .unwrap() .kind, RowKind::File { entry: true, detached: false, closable: false } ); state.toggle("archive"); assert_eq!(rows(&workspace, &[], &state).len(), 2); assert_eq!(workspace.files.len(), 3); } #[test] fn exact_unicode_spaces_and_file_folder_name_collisions_are_not_normalized() { let paths = [ "project.arcadia", "Réponse équipe/États du système.arcadia", "same.arcadia", "same.arcadia/nested.arcadia", ]; let workspace = workspace(&paths); let mut state = State::default(); for path in paths { state.reveal(path); } let all = rows(&workspace, &[], &state); let file_paths: BTreeSet<_> = all .iter() .filter(|row| matches!(row.kind, RowKind::File { .. })) .map(|row| row.path.as_str()) .collect(); assert_eq!(file_paths, paths.into_iter().collect()); assert_eq!( all.iter().filter(|row| row.path == "same.arcadia").count(), 2 ); assert!( all.iter() .find(|row| row.name == "États du système.arcadia") .unwrap() .tooltip() .starts_with("Réponse équipe/États du système.arcadia\n") ); } #[test] fn deep_paths_build_expand_and_drop_on_a_small_stack() { std::thread::Builder::new() .stack_size(128 * 1024) .spawn(|| { let file = format!("{}leaf.arcadia", "folder/".repeat(600)); let workspace = workspace(&["project.arcadia", &file]); let mut state = State::default(); assert_eq!(rows(&workspace, &[], &state).len(), 2); state.reveal(&file); let all = rows(&workspace, &[], &state); let leaf = all.iter().find(|row| row.path == file).unwrap(); assert_eq!(leaf.depth, 600); assert_eq!(all.len(), 602); }) .unwrap() .join() .unwrap(); } #[test] fn active_scroll_offset_uses_the_visible_order_and_retains_ancestor_context() { let mut workspace = workspace(&[ "project.arcadia", "a/first.arcadia", "b/inner/deeper/active.arcadia", ]); workspace.active = "b/inner/deeper/active.arcadia".into(); let mut state = State::default(); state.reveal("a/first.arcadia"); state.reveal(&workspace.active); let offset = active_offset(&workspace, &[], &state); assert_eq!((offset.x, offset.y), (32., 128.)); state.toggle("b/inner"); let offset = active_offset(&workspace, &[], &state); assert_eq!((offset.x, offset.y), (0., 64.)); workspace.files.clear(); let offset = active_offset(&workspace, &[], &state); assert_eq!((offset.x, offset.y), (0., 0.)); } }