1 Commits

Author SHA1 Message Date
7d0c7f93f1 feat: Workspace-Liste laden und speichern
Die anwendungsweite Liste bekannter Workspaces, so wie sie in den Issues #3
und #4 festgelegt wurde. Bewusst ohne Oberfläche, damit dieser Teil sich mit
echten Tests pruefen laesst statt nur mit cargo check.

Ablageort ueber einen einzigen Ausdruck fuer alle Plattformen:
state_dir().or_else(config_local_dir) - state_dir() liefert nur unter Linux
Some, wo die XDG-Spec "recently used files" ausdruecklich als State fuehrt,
also nach $XDG_STATE_HOME und nicht nach ~/.config. Unter Windows faellt es
auf %LOCALAPPDATA% durch, bewusst nicht Roaming: absolute Workspace-Pfade
sind maschinenspezifisch und zeigten auf einem anderen Rechner ins Leere.
Verifiziert, ein Test haelt den Ort fest.

Die Liste enthaelt nur Pfade. Kein Anzeigename, weil der Ordnername ihn
liefert und ein eigenes Feld nach dem ersten Umbenennen falsch waere. Kein
Zeitstempel, weil die Reihenfolge selbst die Sortierung ist - promote() hebt
einen Eintrag an die Spitze und entfernt ihn vorher aus der Liste, wodurch
Duplikate gar nicht erst entstehen koennen.

Eine unlesbare Datei ergibt eine leere Liste statt eines Fehlers: Sie
enthaelt nichts Unwiederbringliches, der Nutzer waehlt seine Ordner einmal
neu. Genau deshalb braucht sie auch keine Schema-Version.

Neu im Abhaengigkeitsgraph ist allein ron; dirs und serde lagen bereits
transitiv vor. directories waere die teurere Variante gewesen - sein einziger
Mehrwert waere die Kleinschreibung des Ordnernamens, die hier eine Konstante
erledigt.
2026-08-28 09:39:13 +02:00
14 changed files with 129 additions and 1073 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

24
Cargo.lock generated
View File

@@ -2330,10 +2330,9 @@ dependencies = [
"dioxus-code",
"dioxus-code-editor",
"dirs",
"rfd",
"ron",
"serde",
"tokio",
"strum",
]
[[package]]
@@ -6413,6 +6412,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

@@ -11,13 +11,9 @@ dioxus = { version = "0.7.1", features = ["router"] }
dioxus-code = "0.1.2"
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
dirs = "6.0.0"
# Dieselbe Konfiguration, die dioxus-desktop ohnehin zieht. Mit den
# Default-Features kaemen neun Wayland-Pakete dazu, die xdg-portal nicht
# braucht — der Dialog laeuft unter Linux ueber das Portal.
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,142 +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.
-------------------------------------------------------------------------- */
.dialog-scrim {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: var(--dn-space-4);
background: rgb(0 0 0 / 0.55);
z-index: 50;
}
.dialog {
width: min(28rem, 100%);
display: flex;
flex-direction: column;
gap: var(--dn-space-4);
padding: var(--dn-space-5);
border: 1px solid var(--dn-border-strong);
border-radius: var(--dn-radius-lg);
background: var(--dn-bg-sunken);
box-shadow: var(--dn-shadow);
}
.dialog h2 {
font-size: var(--dn-text-md);
font-weight: 600;
}
.dialog p {
color: var(--dn-fg-muted);
line-height: var(--dn-leading-normal);
}
.dialog code {
font-family: var(--dn-font-code);
font-size: var(--dn-text-sm);
color: var(--dn-fg);
overflow-wrap: anywhere;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--dn-space-2);
}
/* --- Utilities ---------------------------------------------------------------- */
.visually-hidden {

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

@@ -171,6 +171,9 @@
.visible {
visibility: visible;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}

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

@@ -1,234 +1,25 @@
use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route;
use crate::workspace::{dir_is_empty, OpenError, 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();
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()
}
});
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}"
)));
}
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 {});
}
};
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);
}
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 {
return;
};
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.
if Workspace::has_marker(&root) {
open_entry(root);
return;
}
match dir_is_empty(&root) {
Ok(true) => create_at(root),
// Nicht leer ist beim Import der Normalfall, keine Ausnahme.
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: "workspace-selector-layout",
div { class: "workspace-selector-view",
// TODO: Workspace-Liste aus workspaces.ron laden und darstellen
// (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur
// der leere Zustand.
div { class: "empty-state",
span { class: "empty-state-title", "Hier ist noch nichts" }
span { class: "empty-state-title", "Noch kein Workspace" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
}
} 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),
}
}
}
}
}
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,
// TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und
// den Workspace in den Context schreiben.
button { class: "btn btn-primary", disabled: true,
"Workspace hinzufügen…"
}
}
if let Some(message) = error() {
span { class: "field-error", "{message}" }
}
}
if let Some(root) = pending() {
NotEmptyDialog {
root: root.clone(),
onconfirm: move |root| {
pending.set(None);
create_at(root);
},
oncancel: move |_| pending.set(None),
}
}
}
}
/// 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.
#[component]
fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler<PathBuf>, oncancel: EventHandler<()>) -> Element {
let shown = root.display().to_string();
let confirmed = root.clone();
rsx! {
div { class: "dialog-scrim",
div { class: "dialog",
h2 { "Ordner ist nicht leer" }
p {
code { "{shown}" }
" enthält bereits Dateien. DioNotes legt darin nur den Ordner "
code { ".dionotes" }
" an und verändert nichts Vorhandenes."
}
div { class: "dialog-actions",
button { class: "btn btn-ghost", onclick: move |_| oncancel.call(()), "Abbrechen" }
button {
class: "btn btn-primary",
onclick: move |_| onconfirm.call(confirmed.clone()),
"Workspace anlegen"
}
}
}
}
}

View File

@@ -1,55 +1,4 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::io;
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;
/// Inhalt von `.dionotes/workspace.ron`.
///
/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das
/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim
/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem
/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Marker {
schema_version: u32,
}
use std::path::PathBuf;
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
///
@@ -61,61 +10,6 @@ pub struct Workspace {
pub root: PathBuf,
}
/// Warum ein Ordner sich nicht als Workspace öffnen ließ.
///
/// 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)]
pub enum OpenError {
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
/// aus der Liste entfernt.
Unreachable,
/// Der Ordner ist da, trägt aber keinen Marker.
NotAWorkspace,
/// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht**
/// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein
/// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den
/// Schutz auszuhebeln.
Damaged,
/// Der Marker stammt aus einer neueren Fassung der App.
TooNew { found: u32 },
Io(String),
}
impl fmt::Display for OpenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unreachable => write!(f, "nicht erreichbar"),
Self::NotAWorkspace => write!(f, "kein Workspace mehr"),
Self::Damaged => write!(f, "beschädigt"),
Self::TooNew { found } => write!(
f,
"mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})"
),
Self::Io(err) => write!(f, "{err}"),
}
}
}
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",
}
}
}
impl Workspace {
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
@@ -125,176 +19,16 @@ impl Workspace {
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
}
fn marker_path(root: &Path) -> PathBuf {
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 {
Self::marker_path(root).is_file()
}
/// Einen bestehenden Workspace öffnen.
pub fn open(root: PathBuf) -> Result<Self, OpenError> {
if !root.is_dir() {
return Err(OpenError::Unreachable);
}
let marker_path = Self::marker_path(&root);
if !marker_path.is_file() {
return Err(OpenError::NotAWorkspace);
}
let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?;
let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?;
if marker.schema_version > SCHEMA_VERSION {
return Err(OpenError::TooNew { found: marker.schema_version });
}
Ok(Self { root })
}
/// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen.
///
/// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv.
pub fn create(root: PathBuf) -> Result<Self, OpenError> {
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)?;
}
let marker = Marker { schema_version: SCHEMA_VERSION };
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 })
}
}
/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen.
pub fn dir_is_empty(path: &Path) -> io::Result<bool> {
Ok(fs::read_dir(path)?.next().is_none())
}
#[cfg(test)]
mod tests {
use super::{dir_is_empty, OpenError, Workspace};
use std::fs;
use super::Workspace;
use std::path::PathBuf;
/// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb
/// braucht es keine Zufallszahl und keine tempfile-Crate.
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dionotes-test-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
dir
}
#[test]
fn name_is_the_folder_name() {
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
assert_eq!(ws.name(), "Notizen");
}
#[test]
fn create_then_open_roundtrip() {
let dir = scratch("create-open");
let created = Workspace::create(dir.clone()).expect("anlegbar");
assert!(Workspace::has_marker(&dir));
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");
fs::write(dir.join("notiz.md"), "# Hallo").unwrap();
Workspace::create(dir.clone()).expect("anlegbar");
assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo");
}
#[test]
fn missing_folder_is_unreachable() {
let dir = scratch("gone").join("weg");
assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable)));
}
#[test]
fn folder_without_marker_is_not_a_workspace() {
let dir = scratch("no-marker");
assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace)));
}
#[test]
fn unreadable_marker_is_damaged_and_not_recreated() {
let dir = scratch("damaged");
Workspace::create(dir.clone()).expect("anlegbar");
let marker = dir.join(".dionotes").join("workspace.ron");
fs::write(&marker, "kaputt").unwrap();
assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged)));
// Der Marker wurde nicht stillschweigend ersetzt.
assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt");
}
#[test]
fn newer_schema_is_refused_with_the_version() {
let dir = scratch("too-new");
Workspace::create(dir.clone()).expect("anlegbar");
fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap();
match Workspace::open(dir) {
Err(OpenError::TooNew { found }) => assert_eq!(found, 99),
other => panic!("erwartet TooNew, war {other:?}"),
}
}
#[test]
fn empty_folder_is_recognised() {
let dir = scratch("emptiness");
assert!(dir_is_empty(&dir).unwrap());
fs::write(dir.join("etwas.txt"), "x").unwrap();
assert!(!dir_is_empty(&dir).unwrap());
}
}