From c8cf525ec5664693a8308323401fae41fde36ec8 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Fri, 28 Aug 2026 10:57:17 +0200 Subject: [PATCH] feat: Willkommensnotiz fuer frisch angelegte Workspaces Ein neu angelegter Workspace bekommt eine Willkommen.md mit einer Kurzanleitung: was ein Workspace ist, dass Unterordner funktionieren, wozu .dionotes da ist und wo der Knopf zum Neu-Einlesen sitzt. Sie wird direkt im Editor geoeffnet, statt den Nutzer vor eine leere Flaeche zu setzen. Nur bei einem leeren Ordner. Wer einen Ordner voller Notizen importiert, will keine fremde Datei dazwischen - er kennt das Programm dann meist schon. Die Pruefung liegt vor dem Anlegen von .dionotes, denn danach ist der Ordner nie mehr leer; genau diesen Fehler hat der Test beim ersten Anlauf gefunden. Damit die Notiz angezeigt werden kann, gibt es jetzt die geoeffnete Notiz als eigenen Context - getrennt vom Workspace, weil sie sich aendert, ohne dass der Workspace wechselt. Der Editor laedt ihren Inhalt beim Wechsel nach, auf einem eigenen Thread. Dadurch schrumpft das TODO im Notiz-Baum auf eine Zeile, die gleich mit erledigt ist: Ein Klick auf eine Datei oeffnet sie und markiert sie in der Leiste. Die dafuer vorhandene CSS-Regel .file-selector.active hat damit wieder einen Nutzer. Ausdruecklich nicht dabei: Speichern, Autosave, der Umgang mit ungesicherten Aenderungen. Der Editor zeigt den Inhalt, Aenderungen landen bisher nirgends - das gehoert zum Notiz-Handling und steht als TODO im Code. --- src/main.rs | 5 ++ src/views/editor/dionotes_editor.rs | 47 +++++++++++++---- src/views/workspace_selector/selector.rs | 21 ++++++-- src/workspace.rs | 67 ++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6091a69..aae573c 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; @@ -31,6 +33,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"); -- 2.49.1