Files
ARCADIA-Studio/crates/oa-desktop/src/source_tree.rs
T

670 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.));
}
}