670 lines
21 KiB
Rust
670 lines
21 KiB
Rust
//! 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<String>,
|
||
}
|
||
|
||
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<String, usize>,
|
||
files: Vec<usize>,
|
||
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<Row> {
|
||
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<Pending>,
|
||
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::<Vec<_>>(),
|
||
["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::<Vec<_>>(),
|
||
[
|
||
"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.));
|
||
}
|
||
}
|