3 Commits

Author SHA1 Message Date
5b4012a1e4 Merge pull request 'Willkommensnotiz fuer frisch angelegte Workspaces' (#15) from feat/willkommensnotiz into main
Reviewed-on: #15
2026-08-28 09:00:42 +00:00
cd70e331da Merge branch 'main' into feat/willkommensnotiz 2026-08-28 09:00:36 +00:00
c8cf525ec5 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.
2026-08-28 10:57:17 +02:00
4 changed files with 128 additions and 12 deletions

View File

@@ -7,6 +7,8 @@ mod workspace_list;
use dioxus::prelude::*; use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route; use crate::route::Route;
use crate::workspace::Workspace; use crate::workspace::Workspace;
@@ -30,6 +32,9 @@ fn App() -> Element {
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem // gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
// Outlet, durch das sich keine Props durchreichen lassen. // Outlet, durch das sich keine Props durchreichen lassen.
use_context_provider(|| Signal::new(None::<Workspace>)); use_context_provider(|| Signal::new(None::<Workspace>));
// Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich
// ändert, ohne dass der Workspace wechselt.
use_context_provider(|| Signal::new(None::<PathBuf>));
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }

View File

@@ -24,14 +24,35 @@ pub fn Home() -> Element {
#[component] #[component]
pub fn Editor() -> Element { pub fn Editor() -> Element {
let note = use_context::<Signal<Option<PathBuf>>>();
let mut source = use_signal(String::new); 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! { rsx! {
CodeEditor { CodeEditor {
class: "editor-class", class: "editor-class",
value: source(), value: source(),
language: Language::Markdown, language: Language::Markdown,
theme: Theme::CATPPUCCIN_MOCHA, 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), oninput: move |value| source.set(value),
} }
} }
@@ -119,9 +140,12 @@ fn TreeLevel(nodes: Vec<TreeNode>, expanded: Signal<HashSet<PathBuf>>, depth: us
#[component] #[component]
fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element { fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element {
let mut note = use_context::<Signal<Option<PathBuf>>>();
let navigator = use_navigator();
let path = node.path().to_path_buf(); let path = node.path().to_path_buf();
let name = node.name().to_string(); let name = node.name().to_string();
let is_open = expanded.read().contains(&path); let is_open = expanded.read().contains(&path);
let is_current = note.read().as_deref() == Some(path.as_path());
match node { match node {
TreeNode::Folder { children, .. } => { TreeNode::Folder { children, .. } => {
@@ -144,15 +168,20 @@ fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usiz
} }
} }
} }
TreeNode::File { .. } => rsx! { TreeNode::File { .. } => {
// TODO: Klick öffnet die Notiz im Editor. Gehört zum Notiz-Handling let opened = path.clone();
// und damit in den nächsten Effort, nicht in diese Map. rsx! {
button { button {
class: "file-selector", class: if is_current { "file-selector active" } else { "file-selector" },
style: "--depth: {depth}", style: "--depth: {depth}",
span { class: "tree-caret" } onclick: move |_| {
"{name}" note.set(Some(opened.clone()));
navigator.push(Route::Editor {});
},
span { class: "tree-caret" }
"{name}"
}
} }
}, }
} }
} }

View File

@@ -11,6 +11,7 @@ use crate::workspace_list::WorkspaceList;
#[component] #[component]
pub fn Selector() -> Element { pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>(); let mut workspace = use_context::<Signal<Option<Workspace>>>();
let mut note = use_context::<Signal<Option<PathBuf>>>();
let mut list = use_signal(WorkspaceList::load); let mut list = use_signal(WorkspaceList::load);
let navigator = use_navigator(); let navigator = use_navigator();
@@ -50,18 +51,32 @@ pub fn Selector() -> Element {
))); )));
} }
workspace.set(Some(opened)); 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) { 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}"))), Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
}; };
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt" // Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
// ohne eigenen Knopf abgedeckt. // ohne eigenen Knopf abgedeckt.
let mut open_entry = move |root: PathBuf| match Workspace::open(root) { 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}"))), Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
}; };

View File

@@ -7,6 +7,35 @@ use std::path::{Path, PathBuf};
const MARKER_DIR: &str = ".dionotes"; const MARKER_DIR: &str = ".dionotes";
const MARKER_FILE: &str = "workspace.ron"; 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, /// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
/// die eine ältere App nicht bloß ignorieren kann. /// die eine ältere App nicht bloß ignorieren kann.
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;
@@ -101,6 +130,12 @@ impl Workspace {
root.join(MARKER_DIR).join(MARKER_FILE) root.join(MARKER_DIR).join(MARKER_FILE)
} }
/// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat.
pub fn welcome_note(&self) -> Option<PathBuf> {
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 /// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist. /// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
pub fn has_marker(root: &Path) -> bool { pub fn has_marker(root: &Path) -> bool {
@@ -133,6 +168,10 @@ impl Workspace {
if !root.is_dir() { if !root.is_dir() {
return Err(OpenError::Unreachable); 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); let marker_path = Self::marker_path(&root);
if let Some(dir) = marker_path.parent() { if let Some(dir) = marker_path.parent() {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
@@ -141,6 +180,14 @@ impl Workspace {
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default()) let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(&marker_path, text)?; 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 }) Ok(Self { root })
} }
} }
@@ -179,6 +226,26 @@ mod tests {
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created); 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(&note).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] #[test]
fn create_leaves_existing_files_alone() { fn create_leaves_existing_files_alone() {
let dir = scratch("create-keeps-files"); let dir = scratch("create-keeps-files");