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.
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
@@ -31,6 +33,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 }
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
onclick: move |_| {
|
||||||
|
note.set(Some(opened.clone()));
|
||||||
|
navigator.push(Route::Editor {});
|
||||||
|
},
|
||||||
span { class: "tree-caret" }
|
span { class: "tree-caret" }
|
||||||
"{name}"
|
"{name}"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
// 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 {});
|
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}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(¬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]
|
#[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");
|
||||||
|
|||||||
Reference in New Issue
Block a user