diff --git a/src/main.rs b/src/main.rs index fbaaca1..4d8ee9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,8 @@ mod workspace_list; use dioxus::prelude::*; +use std::path::PathBuf; + use crate::route::Route; use crate::workspace::Workspace; @@ -30,6 +32,9 @@ fn App() -> Element { // gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem // Outlet, durch das sich keine Props durchreichen lassen. use_context_provider(|| Signal::new(None::)); + // Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich + // ändert, ohne dass der Workspace wechselt. + use_context_provider(|| Signal::new(None::)); rsx! { document::Link { rel: "icon", href: FAVICON } diff --git a/src/views/editor/dionotes_editor.rs b/src/views/editor/dionotes_editor.rs index 3ae8919..8f44a9f 100644 --- a/src/views/editor/dionotes_editor.rs +++ b/src/views/editor/dionotes_editor.rs @@ -24,14 +24,35 @@ pub fn Home() -> Element { #[component] pub fn Editor() -> Element { + let note = use_context::>>(); let mut source = use_signal(String::new); + // Beim Wechsel der Notiz den Inhalt nachladen — auf einem eigenen Thread, + // damit eine große Datei auf langsamem Laufwerk nicht die Oberfläche hält. + use_effect(move || { + let path = note.read().clone(); + spawn(async move { + let text = match path { + Some(path) => tokio::task::spawn_blocking(move || { + std::fs::read_to_string(path).unwrap_or_default() + }) + .await + .unwrap_or_default(), + None => String::new(), + }; + source.set(text); + }); + }); + rsx! { CodeEditor { class: "editor-class", value: source(), language: Language::Markdown, theme: Theme::CATPPUCCIN_MOCHA, + // TODO: Änderungen landen bisher nirgends. Speichern, Autosave und + // der Umgang mit ungesicherten Änderungen gehören zum + // Notiz-Handling und damit in den nächsten Effort. oninput: move |value| source.set(value), } } @@ -119,9 +140,12 @@ fn TreeLevel(nodes: Vec, expanded: Signal>, depth: us #[component] fn TreeEntry(node: TreeNode, mut expanded: Signal>, depth: usize) -> Element { + let mut note = use_context::>>(); + let navigator = use_navigator(); let path = node.path().to_path_buf(); let name = node.name().to_string(); let is_open = expanded.read().contains(&path); + let is_current = note.read().as_deref() == Some(path.as_path()); match node { TreeNode::Folder { children, .. } => { @@ -144,15 +168,20 @@ fn TreeEntry(node: TreeNode, mut expanded: Signal>, depth: usiz } } } - TreeNode::File { .. } => rsx! { - // TODO: Klick öffnet die Notiz im Editor. Gehört zum Notiz-Handling - // und damit in den nächsten Effort, nicht in diese Map. - button { - class: "file-selector", - style: "--depth: {depth}", - span { class: "tree-caret" } - "{name}" + TreeNode::File { .. } => { + let opened = path.clone(); + rsx! { + button { + class: if is_current { "file-selector active" } else { "file-selector" }, + style: "--depth: {depth}", + onclick: move |_| { + note.set(Some(opened.clone())); + navigator.push(Route::Editor {}); + }, + span { class: "tree-caret" } + "{name}" + } } - }, + } } } diff --git a/src/views/workspace_selector/selector.rs b/src/views/workspace_selector/selector.rs index 058ffb8..a6ae947 100644 --- a/src/views/workspace_selector/selector.rs +++ b/src/views/workspace_selector/selector.rs @@ -11,6 +11,7 @@ use crate::workspace_list::WorkspaceList; #[component] pub fn Selector() -> Element { let mut workspace = use_context::>>(); + let mut note = use_context::>>(); let mut list = use_signal(WorkspaceList::load); let navigator = use_navigator(); @@ -50,18 +51,32 @@ pub fn Selector() -> Element { ))); } workspace.set(Some(opened)); - navigator.push(Route::Home {}); + // Mit Notiz direkt in den Editor, sonst auf den leeren Stand. + if note.read().is_some() { + navigator.push(Route::Editor {}); + } else { + navigator.push(Route::Home {}); + } }; let mut create_at = move |root: PathBuf| match Workspace::create(root) { - Ok(opened) => accept(opened), + Ok(opened) => { + // Ein frischer Workspace bringt eine Kurzanleitung mit; die wird + // gleich angezeigt, statt den Nutzer vor eine leere Fläche zu setzen. + note.set(opened.welcome_note()); + accept(opened); + } Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))), }; // Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt" // ohne eigenen Knopf abgedeckt. let mut open_entry = move |root: PathBuf| match Workspace::open(root) { - Ok(opened) => accept(opened), + Ok(opened) => { + // Ein bestehender Workspace öffnet ohne vorgewählte Notiz. + note.set(None); + accept(opened); + } Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))), }; diff --git a/src/workspace.rs b/src/workspace.rs index 562ee74..fe2e7bd 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -7,6 +7,35 @@ use std::path::{Path, PathBuf}; const MARKER_DIR: &str = ".dionotes"; const MARKER_FILE: &str = "workspace.ron"; +/// Kurzanleitung, die ein frisch angelegter Workspace mitbekommt. +pub const WELCOME_FILE: &str = "Willkommen.md"; + +const WELCOME_TEXT: &str = "# Willkommen bei DioNotes + +Dieser Ordner ist jetzt ein **Workspace** — eine Sammlung von Notizen, die als +gewöhnliche Markdown-Dateien nebeneinander liegen. Du kannst sie jederzeit mit +jedem anderen Programm öffnen, sichern oder synchronisieren. + +## Wie es aufgebaut ist + +- Jede Notiz ist eine `.md`-Datei in diesem Ordner. +- **Unterordner funktionieren.** Sie erscheinen links als aufklappbarer Baum. +- Der versteckte Ordner `.dionotes` macht diesen Ordner zum Workspace. Dort + landen später auch die Einstellungen. Lösche ihn nicht — ohne ihn erkennt + DioNotes den Ordner nicht wieder. +- Alles, was mit einem Punkt beginnt, bleibt in der Liste unsichtbar. Ein + `.git`-Ordner stört also nicht. + +## Gut zu wissen + +DioNotes liest den Ordner beim Öffnen einmal ein. Änderst du etwas von außen — +etwa über einen Sync-Dienst —, hol dir den neuen Stand mit dem Knopf **⟳** über +der Notizliste. + +Diese Datei ist eine ganz normale Notiz. Du kannst sie überschreiben oder +löschen, sobald du sie nicht mehr brauchst. +"; + /// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen, /// die eine ältere App nicht bloß ignorieren kann. const SCHEMA_VERSION: u32 = 1; @@ -101,6 +130,12 @@ impl Workspace { root.join(MARKER_DIR).join(MARKER_FILE) } + /// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat. + pub fn welcome_note(&self) -> Option { + let path = self.root.join(WELCOME_FILE); + path.is_file().then_some(path) + } + /// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt /// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist. pub fn has_marker(root: &Path) -> bool { @@ -133,6 +168,10 @@ impl Workspace { if !root.is_dir() { return Err(OpenError::Unreachable); } + // Zuerst prüfen: Sobald `.dionotes` angelegt ist, ist der Ordner + // nie mehr leer. + let was_empty = dir_is_empty(&root).unwrap_or(false); + let marker_path = Self::marker_path(&root); if let Some(dir) = marker_path.parent() { fs::create_dir_all(dir)?; @@ -141,6 +180,14 @@ impl Workspace { let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default()) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; fs::write(&marker_path, text)?; + + // Nur in einen frischen Workspace. Wer einen Ordner voller Notizen + // importiert, will keine fremde Datei dazwischen — er kennt das + // Programm dann meist schon. + if was_empty { + let _ = fs::write(root.join(WELCOME_FILE), WELCOME_TEXT); + } + Ok(Self { root }) } } @@ -179,6 +226,26 @@ mod tests { assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created); } + #[test] + fn a_fresh_workspace_gets_a_welcome_note() { + let dir = scratch("welcome-fresh"); + let ws = Workspace::create(dir.clone()).expect("anlegbar"); + + let note = ws.welcome_note().expect("Willkommensnotiz vorhanden"); + assert!(note.ends_with("Willkommen.md")); + assert!(fs::read_to_string(¬e).unwrap().contains("Workspace")); + } + + #[test] + fn an_imported_folder_gets_no_welcome_note() { + let dir = scratch("welcome-import"); + fs::write(dir.join("eigene-notiz.md"), "# Meins").unwrap(); + + let ws = Workspace::create(dir.clone()).expect("anlegbar"); + assert!(ws.welcome_note().is_none(), "Import darf keine fremde Datei bekommen"); + assert!(!dir.join("Willkommen.md").exists()); + } + #[test] fn create_leaves_existing_files_alone() { let dir = scratch("create-keeps-files");