Add direct diagram connections, movable ports, and contextual authoring

This commit is contained in:
2026-09-06 02:13:49 +03:00
parent 35133de650
commit fef2e0dbce
39 changed files with 7076 additions and 499 deletions
+669
View File
@@ -0,0 +1,669 @@
//! 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.));
}
}