Merge pull request 'Willkommensnotiz fuer frisch angelegte Workspaces' (#15) from feat/willkommensnotiz into main
Reviewed-on: #15
This commit was merged in pull request #15.
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;
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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