1 Commits

13 changed files with 145 additions and 747 deletions

View File

@@ -264,22 +264,6 @@ The initial UI rendered by the component on the client must be identical to the
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
## Woran gerade gearbeitet wird
**Bevor du irgendetwas anderes tust:** Laufende Vorhaben liegen als *Wayfinder-Map* im
Issue-Tracker, nicht in diesem Repo. Die offene Map findest du mit
```bash
tea issues ls --labels wayfinder:map --state open
```
Lies ihren Beschreibungstext (Destination, Notes, Decisions so far) **und ihren letzten
Kommentar** — dort steht der Arbeitsstand samt Einstiegspunkt. Erst danach lohnt der Blick
in den Code.
Ist keine Map offen, gibt es kein laufendes Planungsvorhaben; dann gilt die normale
Auftragslage aus dem Gespräch.
## Agent skills
### Issue tracker

23
Cargo.lock generated
View File

@@ -2333,7 +2333,7 @@ dependencies = [
"rfd",
"ron",
"serde",
"tokio",
"strum",
]
[[package]]
@@ -6413,6 +6413,27 @@ dependencies = [
"quote",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subsecond"
version = "0.7.10"

View File

@@ -17,7 +17,7 @@ dirs = "6.0.0"
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
ron = "0.12.2"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.53.1", features = ["rt"] }
strum = { version = "0.28.0", features = ["derive"] }
[features]
default = ["desktop"]

View File

@@ -9,18 +9,8 @@
.file-selector {
position: relative;
display: flex;
align-items: center;
gap: var(--dn-space-1);
/* Einrückung pro Baumebene — --depth setzt die Komponente je Knoten. */
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1)
calc(var(--dn-space-3) + var(--depth, 0) * var(--dn-space-4));
/* Als <button> im Baum: Browser-Vorgaben zurücknehmen. */
width: 100%;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
display: block;
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1) var(--dn-space-3);
border-radius: var(--dn-radius-sm);
color: var(--dn-fg-muted);
font-family: var(--dn-font-code);
@@ -33,19 +23,6 @@
background-color var(--dn-dur-fast) var(--dn-ease);
}
/* Das Dreieck hält seinen Platz auch bei Dateien, damit Ordner- und
Dateinamen derselben Ebene bündig stehen. */
.tree-caret {
flex: none;
width: 0.9em;
color: var(--dn-fg-faint);
font-size: 0.8em;
}
.file-selector.is-folder {
color: var(--dn-fg);
}
.file-selector::before {
content: "";
position: absolute;
@@ -111,12 +88,6 @@
border-color: transparent;
}
/* Quadratischer Knopf für ein einzelnes Zeichen — Refresh, Schließen. */
.btn-icon {
padding: var(--dn-space-1) var(--dn-space-2);
line-height: 1;
}
.btn-ghost:hover:not(:disabled) {
background: var(--dn-bg-raised);
border-color: transparent;
@@ -196,92 +167,6 @@
font-weight: 600;
}
/* --- Workspace-Listeneintrag ---------------------------------------------------
Ordnername, darunter der Pfad, rechts bei Bedarf ein Statusabzeichen. Ein
defekter Eintrag wird gedämpft, bleibt aber anklickbar: Ein Klick versucht
es erneut, womit „Stick wieder eingesteckt" ohne eigenen Knopf abgedeckt ist.
-------------------------------------------------------------------------- */
.ws-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-item {
display: flex;
align-items: center;
gap: var(--dn-space-3);
width: 100%;
padding: var(--dn-space-3);
border: 0;
border-radius: var(--dn-radius);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color var(--dn-dur-fast) var(--dn-ease);
}
.ws-item:hover {
background: var(--dn-bg-raised);
}
.ws-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ws-item-name {
color: var(--dn-fg);
font-weight: 500;
}
/* Bei Überlänge vorne kürzen: Das Ende eines Pfades sagt mehr als sein Anfang. */
.ws-item-path {
direction: rtl;
text-align: left;
color: var(--dn-fg-faint);
font-family: var(--dn-font-code);
font-size: var(--dn-text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-item.is-broken .ws-item-name {
color: var(--dn-fg-muted);
}
.ws-badge {
flex: none;
padding: 2px var(--dn-space-2);
border-radius: 999px;
font-size: var(--dn-text-xs);
white-space: nowrap;
}
/* Drei Zustände, drei Farben — sie führen zu verschiedenen Handlungen:
Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. */
.ws-badge.unreachable {
background: color-mix(in oklab, var(--dn-warning) 18%, transparent);
color: var(--dn-warning);
}
.ws-badge.no-marker {
background: color-mix(in oklab, var(--dn-fg-faint) 22%, transparent);
color: var(--dn-fg-muted);
}
.ws-badge.damaged {
background: color-mix(in oklab, var(--dn-danger) 18%, transparent);
color: var(--dn-danger);
}
/* --- Dialog --------------------------------------------------------------------
Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline
an ihren Ort, nicht in ein zweites Fenster.

View File

@@ -10,50 +10,29 @@
}
/* --- Workspace selector ----------------------------------------------------
The pre-editor screen. One view, no tabs: the list of known workspaces
leads, adding one is the secondary action in the footer. Opening is the
everyday case; adding happens once per workspace.
The pre-editor screen: pick or create a workspace. Centered, deliberately
sparse — it has exactly one job.
-------------------------------------------------------------------------- */
.selector-shell {
.workspace-selector-layout {
flex: 1;
min-height: 0;
width: 100%;
max-width: 40rem;
margin: 0 auto;
padding: var(--dn-space-7) var(--dn-space-5);
display: flex;
flex-direction: column;
gap: var(--dn-space-5);
}
.selector-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--dn-space-4);
}
.selector-title {
font-size: var(--dn-text-xl);
font-weight: 600;
}
/* Nur die Liste scrollt — Kopf und Fuß bleiben stehen. */
.selector-list-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.selector-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--dn-space-3);
padding-top: var(--dn-space-4);
border-top: 1px solid var(--dn-border);
justify-content: center;
gap: var(--dn-space-6);
padding: var(--dn-space-6);
}
.workspace-selector-view {
width: 100%;
max-width: 34rem;
padding: var(--dn-space-6);
background: var(--dn-bg-sunken);
border: 1px solid var(--dn-border);
border-radius: var(--dn-radius-lg);
box-shadow: var(--dn-shadow);
}
/* --- Editor shell ----------------------------------------------------------
@@ -80,22 +59,6 @@
overscroll-behavior: contain;
}
/* Kopfzeile der Datei-Leiste: Beschriftung und Neu-einlesen. Bleibt stehen,
während der Baum darunter scrollt. */
.files-bar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--dn-space-2);
padding: var(--dn-space-1) var(--dn-space-1) var(--dn-space-1) var(--dn-space-2);
}
.files-bar-empty {
padding: var(--dn-space-3) var(--dn-space-2);
color: var(--dn-fg-faint);
font-size: var(--dn-text-xs);
}
.editor-container {
flex: 1;
min-width: 0;

20
assets/header.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -4,19 +4,7 @@ Issues and specs for this repo live as **Gitea** issues at
<https://gitlab.hive-brain.de/RDietermann/dionotes-client>.
> The host is named `gitlab.hive-brain.de`, but the server is **Gitea 1.25.5**, not GitLab.
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (developed against v0.15).
**Auf einem frischen Gerät** ist `tea` erst einzurichten — ohne das schlägt jedes Kommando
unten fehl:
```bash
tea login add --name hive --url https://gitlab.hive-brain.de --token <token>
tea login list # prueft, ob es sitzt
```
Der Token braucht mindestens `write:issue`, `write:repository` und `write:user`. Der hier
verwendete hat **kein** `read:admin` und **kein** `write:organization` — Wartungs-API und
Anlegen unter einer Organisation scheitern damit.
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (v0.15 installed).
Run `tea` from inside the clone — it resolves owner/repo from `git remote`. It prints
`NOTE: no login matched this repository, falling back to login 'hive'` on every call;

View File

@@ -1,14 +1,11 @@
mod components;
mod route;
mod tree;
mod views;
mod workspace;
mod workspace_list;
use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route;
use crate::workspace::Workspace;
@@ -33,9 +30,6 @@ 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::<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! {
document::Link { rel: "icon", href: FAVICON }

View File

@@ -1,4 +1,5 @@
use dioxus::prelude::*;
use strum::EnumMessage;
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
use crate::views::workspace_selector::selector::Selector;
@@ -8,17 +9,28 @@ use crate::views::workspace_selector::selector::Selector;
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
/// URL zu sehen bekommen, die er nicht kennt.
#[derive(Debug, Clone, Routable, PartialEq)]
#[derive(Debug, Clone, Routable, PartialEq, EnumMessage)]
#[rustfmt::skip]
pub enum Route {
#[nest("/workspace")]
#[layout(EditorShell)]
#[route("/")]
#[strum(message = "Home")]
Home {},
#[route("/editor")]
#[strum(message = "Editor")]
Editor {},
#[end_layout]
#[end_nest]
#[route("/")]
Selector {},
}
impl Route {
/// Die Einträge der Datei-Leiste im Editor.
///
/// Explizit statt über `EnumIter`, weil das Enum jetzt auch den Selector
/// enthält und der in dieser Leiste nichts zu suchen hat. Wermutstropfen:
/// eine neue Editor-Route hier einzutragen erzwingt der Compiler nicht.
pub const EDITOR_TABS: [Self; 2] = [Self::Home {}, Self::Editor {}];
}

View File

@@ -1,194 +0,0 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Ein Knoten im Notiz-Baum.
///
/// Verschachtelt statt flache Arena: Der Baum wird beim Öffnen einmal
/// aufgebaut und danach nur gelesen — die Indirektion einer Arena zahlt sich
/// erst aus, wenn Knoten wandern oder sich gegenseitig referenzieren.
///
/// Als Enum statt als Struct mit `children: Vec<_>`, weil damit unmöglich
/// wird, was ein Struct erlaubt: eine Datei mit Kindern.
///
/// Eine einzige `File`-Variante für alle Dateitypen. Wer wissen will, ob es
/// eine Notiz ist, fragt `path.extension()` — das ist eine Zeile an der
/// Stelle, die es braucht, statt einer Klassifizierung, die der Baum beim
/// Aufbau festschreibt.
#[derive(Debug, Clone, PartialEq)]
pub enum TreeNode {
File {
name: String,
path: PathBuf,
},
Folder {
name: String,
path: PathBuf,
children: Vec<TreeNode>,
},
}
impl TreeNode {
pub fn name(&self) -> &str {
match self {
Self::File { name, .. } | Self::Folder { name, .. } => name,
}
}
pub fn path(&self) -> &Path {
match self {
Self::File { path, .. } | Self::Folder { path, .. } => path,
}
}
pub fn is_folder(&self) -> bool {
matches!(self, Self::Folder { .. })
}
}
/// Liest den Inhalt eines Ordners rekursiv ein und liefert seine Kinder.
///
/// `std::fs::read_dir` mit eigener Rekursion statt `walkdir`: Letzteres liegt
/// zwar bereits im Abhängigkeitsgraph, iteriert aber flach — daraus einen
/// Baum zusammenzusetzen wäre mehr Code, nicht weniger.
pub fn read_tree(root: &Path) -> io::Result<Vec<TreeNode>> {
let mut nodes = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
// Alles mit führendem Punkt bleibt draußen. Das erledigt `.dionotes`
// und `.git` in einem Aufwasch, ohne eine Ausnahmeliste, die bei
// `.obsidian` oder `.vscode` wieder wachsen müsste.
if name.starts_with('.') {
continue;
}
let path = entry.path();
// `symlink_metadata` folgt dem Link nicht, `metadata` schon. Symlinks
// zu folgen hieße, einen Zyklusschutz über kanonisierte Pfade
// mitzuführen — realer Aufwand für einen Fall, den in einem
// Notizordner kaum jemand hat, während ein Hänger beim Öffnen für den
// Nutzer schwer zu deuten wäre.
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
if meta.is_symlink() {
continue;
}
if meta.is_dir() {
// Ein Unterordner ohne Leserecht wird leer dargestellt, statt den
// ganzen Baum scheitern zu lassen.
let children = read_tree(&path).unwrap_or_default();
nodes.push(TreeNode::Folder { name, path, children });
} else {
nodes.push(TreeNode::File { name, path });
}
}
sort(&mut nodes);
Ok(nodes)
}
/// Ordner zuerst, dann Dateien, jeweils alphabetisch ohne Rücksicht auf
/// Groß-/Kleinschreibung — die Konvention aus jedem Dateibrowser. `read_dir`
/// liefert plattformabhängig unsortiert.
fn sort(nodes: &mut [TreeNode]) {
nodes.sort_by(|a, b| {
b.is_folder()
.cmp(&a.is_folder())
.then_with(|| a.name().to_lowercase().cmp(&b.name().to_lowercase()))
});
}
#[cfg(test)]
mod tests {
use super::{read_tree, TreeNode};
use std::fs;
use std::path::PathBuf;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dionotes-tree-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
dir
}
fn names(nodes: &[TreeNode]) -> Vec<&str> {
nodes.iter().map(|n| n.name()).collect()
}
#[test]
fn folders_come_first_then_files_each_alphabetically() {
let dir = scratch("sorting");
fs::write(dir.join("zebra.md"), "").unwrap();
fs::write(dir.join("Apfel.md"), "").unwrap();
fs::create_dir(dir.join("Werkzeug")).unwrap();
fs::create_dir(dir.join("archiv")).unwrap();
let tree = read_tree(&dir).unwrap();
// Gross-/Kleinschreibung darf die Reihenfolge nicht bestimmen.
assert_eq!(names(&tree), ["archiv", "Werkzeug", "Apfel.md", "zebra.md"]);
}
#[test]
fn every_file_type_is_included_not_just_markdown() {
let dir = scratch("all-files");
fs::write(dir.join("notiz.md"), "").unwrap();
fs::write(dir.join("bild.png"), "").unwrap();
fs::write(dir.join("ohne-endung"), "").unwrap();
let tree = read_tree(&dir).unwrap();
assert_eq!(names(&tree), ["bild.png", "notiz.md", "ohne-endung"]);
}
#[test]
fn dotted_names_are_skipped_files_and_folders_alike() {
let dir = scratch("hidden");
fs::create_dir_all(dir.join(".dionotes")).unwrap();
fs::create_dir_all(dir.join(".git").join("objects")).unwrap();
fs::write(dir.join(".gitignore"), "").unwrap();
fs::write(dir.join("sichtbar.md"), "").unwrap();
assert_eq!(names(&read_tree(&dir).unwrap()), ["sichtbar.md"]);
}
#[test]
fn empty_folders_are_shown() {
let dir = scratch("empty-folder");
fs::create_dir(dir.join("leer")).unwrap();
let tree = read_tree(&dir).unwrap();
match &tree[..] {
[TreeNode::Folder { name, children, .. }] => {
assert_eq!(name, "leer");
assert!(children.is_empty());
}
other => panic!("erwartet ein leerer Ordner, war {other:?}"),
}
}
#[test]
fn nesting_is_recursive() {
let dir = scratch("nested");
fs::create_dir_all(dir.join("a").join("b")).unwrap();
fs::write(dir.join("a").join("b").join("tief.md"), "").unwrap();
let tree = read_tree(&dir).unwrap();
let TreeNode::Folder { children: a, .. } = &tree[0] else {
panic!("erwartet Ordner a");
};
let TreeNode::Folder { children: b, .. } = &a[0] else {
panic!("erwartet Ordner b");
};
assert_eq!(names(b), ["tief.md"]);
}
#[test]
fn missing_root_is_an_error_not_an_empty_tree() {
let dir = scratch("no-root").join("gibtsnicht");
assert!(read_tree(&dir).is_err());
}
}

View File

@@ -1,64 +1,58 @@
use dioxus::prelude::*;
use dioxus_code::Theme;
use dioxus_code_editor::{CodeEditor, Language};
use std::collections::HashSet;
use std::path::PathBuf;
use strum::EnumMessage;
use crate::components::custom_link::custom_link_component::CustomLink;
use crate::route::Route;
use crate::tree::{read_tree, TreeNode};
use crate::workspace::Workspace;
/// Was rechts steht, solange keine Notiz gewählt ist.
///
/// TODO: Sobald ein Klick im Baum eine Notiz öffnet, ist das der Ort für den
/// zuletzt bearbeiteten Stand oder eine Kurzhilfe. Gehört zum Notiz-Handling.
const HEADER_SVG: Asset = asset!("/assets/header.svg");
/// Home page
#[component]
pub fn Home() -> Element {
rsx! {
div { class: "empty-state",
span { class: "empty-state-title", "Keine Notiz geöffnet" }
span { "Wähle links eine Notiz aus." }
Hero {}
}
}
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
#[component]
pub fn Hero() -> Element {
rsx! {
div {
id: "hero",
img { src: HEADER_SVG, id: "header" }
div { id: "links",
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
}
}
}
}
#[component]
pub fn Editor() -> Element {
let note = use_context::<Signal<Option<PathBuf>>>();
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),
}
}
}
/// Die Editor-Hülle: Notiz-Baum links, Editorfläche rechts.
/// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
///
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
@@ -74,51 +68,25 @@ pub fn EditorShell() -> Element {
}
});
// Offene Ordner liegen **neben** dem Baum, nicht darin: Es gibt keinen
// Filewatcher, der Nutzer liest bei Bedarf neu ein — steckte `expanded`
// im Knoten, klappte jeder Refresh sämtliche Ordner zu.
let expanded = use_signal(HashSet::<PathBuf>::new);
// Einlesen auf einem eigenen Thread: Ein Workspace auf einem langsamen
// Laufwerk würde die Oberfläche sonst blockieren.
let mut tree = use_resource(move || {
let root = workspace.read().as_ref().map(|ws| ws.root.clone());
async move {
let Some(root) = root else {
return Vec::new();
};
tokio::task::spawn_blocking(move || read_tree(&root).unwrap_or_default())
.await
.unwrap_or_default()
}
});
if workspace.read().is_none() {
return rsx! {};
}
let nodes = tree.read().clone().unwrap_or_default();
rsx! {
div {
class: "layout-container",
div {
class: "files-bar",
div { class: "files-bar-head",
span { class: "field-label", "Notizen" }
// Kein Filewatcher — externe Änderungen holt man sich hier.
button {
class: "btn btn-ghost btn-icon",
title: "Neu einlesen",
onclick: move |_| tree.restart(),
"⟳"
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
for entry in Route::EDITOR_TABS {
CustomLink {
key: "{entry}",
label: entry.get_message().unwrap_or_default(),
href: entry.clone(),
css: "file-selector"
}
}
if nodes.is_empty() {
div { class: "files-bar-empty", "Keine Notizen in diesem Workspace." }
} else {
TreeLevel { nodes: nodes.clone(), expanded, depth: 0 }
}
}
div {
class: "editor-container",
@@ -127,61 +95,3 @@ pub fn EditorShell() -> Element {
}
}
}
/// Eine Ebene des Baums. Ruft sich für aufgeklappte Ordner selbst auf.
#[component]
fn TreeLevel(nodes: Vec<TreeNode>, expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element {
rsx! {
for node in nodes {
TreeEntry { key: "{node.path().display()}", node: node.clone(), expanded, depth }
}
}
}
#[component]
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 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, .. } => {
let toggle_path = path.clone();
rsx! {
button {
class: "file-selector is-folder",
style: "--depth: {depth}",
onclick: move |_| {
let mut open = expanded.write();
if !open.remove(&toggle_path) {
open.insert(toggle_path.clone());
}
},
span { class: "tree-caret", if is_open { "▾" } else { "▸" } }
"{name}"
}
if is_open {
TreeLevel { nodes: children, expanded, depth: depth + 1 }
}
}
}
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}"
}
}
}
}
}

View File

@@ -2,84 +2,40 @@ use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route;
use crate::workspace::{dir_is_empty, OpenError, Workspace};
use crate::workspace::{dir_is_empty, Workspace};
use crate::workspace_list::WorkspaceList;
/// Der Bildschirm vor dem Editor. Eine Ansicht, keine Reiter: Die Liste der
/// bekannten Workspaces führt, das Hinzufügen ist Nebenaktion in der Fußzeile.
/// Öffnen ist der Alltagsfall, Hinzufügen passiert pro Workspace einmal.
/// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen
/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt,
/// das Hinzufügen ist Nebenaktion.
#[component]
pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>();
let mut note = use_context::<Signal<Option<PathBuf>>>();
let mut list = use_signal(WorkspaceList::load);
let navigator = use_navigator();
// Ordner, der auf die Bestätigung „ist nicht leer" wartet.
let mut pending = use_signal(|| None::<PathBuf>);
let mut error = use_signal(|| None::<String>);
// Zustandsprüfung der Einträge, nebenläufig und auf einem eigenen Thread:
// Auf einem nicht eingehängten Netzlaufwerk läuft schon `is_dir()` in ein
// Timeout von Sekunden, und das würde sonst den Selector einfrieren —
// ausgerechnet den Bildschirm, den jeder Start zuerst zeigt.
//
// spawn_blocking setzt die Tokio-Runtime voraus, die dioxus-desktop
// mitbringt. Für die web-/mobile-Features müsste das anders laufen —
// die sind laut Entscheidung in Issue #1 aber ausdrücklich nicht Ziel.
let probed = use_resource(move || {
let paths = list.read().entries().to_vec();
async move {
tokio::task::spawn_blocking(move || {
paths
.into_iter()
.map(|path| {
let state = Workspace::open(path.clone()).err();
(path, state)
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
}
});
// Übernimmt einen Workspace: Liste voranstellen und sichern, in den
// Context legen, in den Editor wechseln.
let mut accept = move |opened: Workspace| {
list.write().promote(opened.root.clone());
if let Err(err) = list.read().save() {
error.set(Some(format!(
"Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"
)));
// Kein Abbruch: Der Workspace ist offen, nur das Merken schlug fehl.
error.set(Some(format!("Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}")));
}
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 {});
};
// Wird sowohl nach dem Ordner-Dialog als auch nach der Rückfrage gebraucht.
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
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);
}
Ok(opened) => 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) => {
// Ein bestehender Workspace öffnet ohne vorgewählte Notiz.
note.set(None);
accept(opened);
}
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
};
let choose = move |_| async move {
// Abbruch liefert None und ist kein Fehler — keine Meldung.
let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else {
@@ -88,75 +44,43 @@ pub fn Selector() -> Element {
let root = handle.path().to_path_buf();
error.set(None);
// Ein vorhandener Workspace wird geöffnet, nicht abgelehnt: derselbe
// Knopf nimmt neue und wiedergefundene Workspaces auf.
// Ein bereits vorhandener Workspace wird geöffnet, nicht abgelehnt:
// derselbe Knopf nimmt neue und wiedergefundene Workspaces auf.
if Workspace::has_marker(&root) {
open_entry(root);
match Workspace::open(root) {
Ok(opened) => accept(opened),
// Die Display-Impl benennt den Zustand bereits: „beschädigt",
// „kein Workspace mehr", „nicht erreichbar".
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
}
return;
}
match dir_is_empty(&root) {
Ok(true) => create_at(root),
// Nicht leer ist beim Import der Normalfall, keine Ausnahme.
// Nicht leer ist der Normalfall beim Import — deshalb eine
// Rückfrage, keine Ablehnung.
Ok(false) => pending.set(Some(root)),
Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))),
}
};
let paths = list.read().entries().to_vec();
let states = probed.read().clone();
// Solange die Prüfung läuft, bleiben die Einträge unmarkiert. Die lineare
// Suche ist bei einer Workspace-Liste billiger als eine Map aufzubauen.
let state_of = |path: &PathBuf| -> Option<OpenError> {
states
.as_ref()?
.iter()
.find(|(known, _)| known == path)
.and_then(|(_, state)| state.clone())
};
let is_empty = paths.is_empty();
rsx! {
div { class: "selector-shell",
div { class: "selector-head",
span { class: "selector-title", "Workspace öffnen" }
if !is_empty {
span { class: "field-hint", "{paths.len()} bekannt" }
}
}
div { class: "selector-list-wrap",
if is_empty {
div { class: "empty-state",
span { class: "empty-state-title", "Hier ist noch nichts" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
div { class: "workspace-selector-layout",
div { class: "workspace-selector-view",
// TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen)
// statt des leeren Zustands, sobald Einträge vorhanden sind.
div { class: "empty-state",
span { class: "empty-state-title", "Noch kein Workspace" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
button { class: "btn btn-primary", onclick: choose,
"Workspace hinzufügen…"
}
} else {
div { class: "ws-list",
for path in paths.iter() {
WorkspaceEntry {
key: "{path.display()}",
path: path.clone(),
state: state_of(path),
onopen: move |path| open_entry(path),
}
}
if let Some(message) = error() {
span { class: "field-error", "{message}" }
}
}
}
div { class: "selector-foot",
span { class: "field-hint", "Ein Workspace ist ein Ordner mit Notizen." }
button {
class: if is_empty { "btn btn-primary" } else { "btn" },
onclick: choose,
"Workspace hinzufügen…"
}
}
if let Some(message) = error() {
span { class: "field-error", "{message}" }
}
}
if let Some(root) = pending() {
@@ -172,37 +96,6 @@ pub fn Selector() -> Element {
}
}
/// Ein Eintrag der Workspace-Liste: Ordnername, darunter der Pfad, rechts bei
/// Bedarf das Statusabzeichen. Ein defekter Eintrag bleibt anklickbar und wird
/// nie automatisch entfernt — meist fehlt nur gerade das Laufwerk.
#[component]
fn WorkspaceEntry(path: PathBuf, state: Option<OpenError>, onopen: EventHandler<PathBuf>) -> Element {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let full = path.display().to_string();
let clicked = path.clone();
// Klasse und Beschriftung des Abzeichens vorab, damit rsx! den Zustand
// nicht zweimal gleichzeitig braucht.
let broken = state.is_some();
let badge = state.as_ref().map(|state| (state.badge_class(), state.to_string()));
rsx! {
button {
class: if broken { "ws-item is-broken" } else { "ws-item" },
onclick: move |_| onopen.call(clicked.clone()),
span { class: "ws-item-body",
span { class: "ws-item-name", "{name}" }
span { class: "ws-item-path", "{full}" }
}
if let Some((class, label)) = badge {
span { class: "ws-badge {class}", "{label}" }
}
}
}
}
/// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält.
/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da,
/// dass nichts Vorhandenes verändert wird.

View File

@@ -7,35 +7,6 @@ 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;
@@ -66,7 +37,7 @@ pub struct Workspace {
/// Die ersten drei Varianten sind genau die Zustände, mit denen ein
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug)]
pub enum OpenError {
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
@@ -81,7 +52,7 @@ pub enum OpenError {
Damaged,
/// Der Marker stammt aus einer neueren Fassung der App.
TooNew { found: u32 },
Io(String),
Io(io::Error),
}
impl fmt::Display for OpenError {
@@ -101,18 +72,7 @@ impl fmt::Display for OpenError {
impl From<io::Error> for OpenError {
fn from(err: io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl OpenError {
/// CSS-Klasse fuer das Statusabzeichen am Listeneintrag.
pub fn badge_class(&self) -> &'static str {
match self {
Self::Unreachable => "unreachable",
Self::NotAWorkspace => "no-marker",
Self::Damaged | Self::TooNew { .. } | Self::Io(_) => "damaged",
}
Self::Io(err)
}
}
@@ -130,12 +90,6 @@ impl Workspace {
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
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
pub fn has_marker(root: &Path) -> bool {
@@ -168,10 +122,6 @@ 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)?;
@@ -180,14 +130,6 @@ 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 })
}
}
@@ -226,26 +168,6 @@ 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(&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]
fn create_leaves_existing_files_alone() {
let dir = scratch("create-keeps-files");