3 Commits

Author SHA1 Message Date
d470756920 docs: Rechercheergebnisse zum Notiz-Handling uebernehmen
Beide Recherchen zur Map #16 lagen nur auf lokalen Wegwerf-Branches. Die
Kurzfassungen stehen an den Tickets #21 und #22, die Langfassungen sind mit
Quellen und der Trennung zwischen Belegtem, Abgeleitetem und Unbelegtem aber
das, worauf man zurueckkommt.

Beide enthalten Messungen, die keine Dokumentation hergibt: dass trash 5.2.6
auf Netzlaufwerken gar nicht funktioniert und Windows dort sonst still und
endgueltig loeschen wuerde, und dass ein ungeprunter Append-Log im
Yjs-Benchmark das Siebzigfache des Rohtexts misst.

Ausserdem /.claude in die .gitignore - dort liegen lokale Einstellungen und
die temporaeren Worktrees der Recherche-Agenten.
2026-08-28 12:48:20 +02:00
c8cf525ec5 feat: Willkommensnotiz fuer frisch angelegte Workspaces
Ein neu angelegter Workspace bekommt eine Willkommen.md mit einer
Kurzanleitung: was ein Workspace ist, dass Unterordner funktionieren, wozu
.dionotes da ist und wo der Knopf zum Neu-Einlesen sitzt. Sie wird direkt im
Editor geoeffnet, statt den Nutzer vor eine leere Flaeche zu setzen.

Nur bei einem leeren Ordner. Wer einen Ordner voller Notizen importiert, will
keine fremde Datei dazwischen - er kennt das Programm dann meist schon. Die
Pruefung liegt vor dem Anlegen von .dionotes, denn danach ist der Ordner nie
mehr leer; genau diesen Fehler hat der Test beim ersten Anlauf gefunden.

Damit die Notiz angezeigt werden kann, gibt es jetzt die geoeffnete Notiz als
eigenen Context - getrennt vom Workspace, weil sie sich aendert, ohne dass der
Workspace wechselt. Der Editor laedt ihren Inhalt beim Wechsel nach, auf einem
eigenen Thread.

Dadurch schrumpft das TODO im Notiz-Baum auf eine Zeile, die gleich mit
erledigt ist: Ein Klick auf eine Datei oeffnet sie und markiert sie in der
Leiste. Die dafuer vorhandene CSS-Regel .file-selector.active hat damit
wieder einen Nutzer.

Ausdruecklich nicht dabei: Speichern, Autosave, der Umgang mit ungesicherten
Aenderungen. Der Editor zeigt den Inhalt, Aenderungen landen bisher nirgends -
das gehoert zum Notiz-Handling und steht als TODO im Code.
2026-08-28 10:57:17 +02:00
7917fa08cc feat: Notiz-Baum in der Datei-Leiste
Setzt Issue #7 um und schliesst damit die Workspace-Map ab. Die files-bar
zeigt nicht mehr zwei Platzhalter-Routen, sondern den rekursiv eingelesenen
Inhalt des offenen Workspace.

TreeNode ist ein verschachteltes Enum mit einer einzigen File-Variante fuer
alle Dateitypen - wer wissen will, ob es eine Notiz ist, fragt
path.extension(). Aufgebaut wird ueber std::fs::read_dir mit eigener
Rekursion; walkdir liegt zwar im Graph, iteriert aber flach, woraus einen
Baum zu bauen mehr Code waere.

Eingesammelt wird alles ausser Namen mit fuehrendem Punkt - Dateien wie
Ordner, womit .dionotes und .git ohne Ausnahmeliste wegfallen - und ausser
Symlinks, deren Verfolgung einen Zyklusschutz erfordern wuerde. Leere Ordner
werden angezeigt, sortiert wird Ordner-vor-Dateien und alphabetisch ohne
Ruecksicht auf Gross-/Kleinschreibung.

Der Aufklapp-Zustand liegt als HashSet<PathBuf> neben dem Baum, nicht darin:
Es gibt keinen Filewatcher, der Nutzer liest bei Bedarf neu ein - steckte
expanded im Knoten, klappte jeder Refresh saemtliche Ordner zu. Persistiert
wird er nicht.

Mit dem Baum entfaellt die Tab-Leiste im Editor und damit EDITOR_TABS, der
letzte Rest des in #5 dokumentierten Pflegepunkts. strum wird dadurch
nirgends mehr gebraucht und faellt samt strum_macros aus dem Graph.

Ausserdem entfernt: der Dioxus-Vorlagentext Hero samt header.svg, der nach
dem Oeffnen eines Workspace die rechte Haelfte mit ungestylten Werbelinks
fuellte. Home ist jetzt ein Empty-State.
2026-08-28 10:49:15 +02:00
21 changed files with 2324 additions and 143 deletions

8
.gitignore vendored
View File

@@ -9,9 +9,11 @@
# Dioxus build output
/dist
# Node (tailwind toolchain)
node_modules/
# IDE
/.idea
# dx erzeugt diese Datei bei jedem vollen Build neu, auch ohne Eingabedatei
# und ohne dass sie noch verlinkt waere. Lokales Nebenprodukt, kein Quellcode.
assets/tailwind.css
# Claude Code (Arbeitsverzeichnis, lokale Einstellungen und Worktrees)
/.claude

22
Cargo.lock generated
View File

@@ -2333,7 +2333,6 @@ dependencies = [
"rfd",
"ron",
"serde",
"strum",
"tokio",
]
@@ -6414,27 +6413,6 @@ 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,6 @@ 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"] }
strum = { version = "0.28.0", features = ["derive"] }
tokio = { version = "1.53.1", features = ["rt"] }
[features]

View File

@@ -1,32 +1,51 @@
# DioNotes Client
# Development
Lokale Markdown-Notizen-App auf Basis von [Dioxus 0.7](https://dioxuslabs.com/learn/0.7), Desktop.
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
## Entwicklung
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Automatic Tailwind (Dioxus 0.7+)
As of Dioxus 0.7, there no longer is a need to manually install tailwind. Simply `dx serve` and you're good to go!
Automatic tailwind is supported by checking for a file called `tailwind.css` in your app's manifest directory (next to Cargo.toml). To customize the file, use the dioxus.toml:
```toml
[application]
tailwind_input = "my.css"
tailwind_output = "assets/out.css" # also customize the location of the out file!
```
### Tailwind Manual Install
To use tailwind plugins or manually customize tailwind, you can can install the Tailwind CLI and use it directly.
### Tailwind
1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation/tailwind-cli
3. Run the following command in the root of the project to start the Tailwind CSS compiler:
```bash
dx serve # Desktop-App starten (Standard-Plattform)
dx serve --platform web # im Browser
cargo test # Tests
npx @tailwindcss/cli -i ./input.css -o ./assets/tailwind.css --watch
```
## Aufbau
### Serving Your App
```
assets/css/ Design-System: tokens → base → layout → components,
in dieser Reihenfolge in main.rs verlinkt
src/route.rs alle Routen in einem Routable-Enum
src/workspace.rs
src/components/ wiederverwendbare Bausteine (CustomLink, CustomButton, …)
src/views/ die Bildschirme: Selector und Editor
docs/ Konventionen für Agenten, Rechercheergebnisse
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve
```
Es gibt **kein** Tailwind und keine npm-Toolchain — das Styling liegt vollständig in
`assets/css/` und arbeitet mit den `--dn-*`-Tokens.
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```
## Wo die Entscheidungen liegen
Fachbegriffe stehen in [`CONTEXT.md`](CONTEXT.md). Entscheidungen werden im
Issue-Tracker geführt statt in `docs/adr/`; eine Wayfinder-Map indexiert pro Vorhaben,
welche Entscheidung wo begründet ist. Siehe [`docs/agents/`](docs/agents/).

View File

@@ -9,8 +9,18 @@
.file-selector {
position: relative;
display: block;
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1) var(--dn-space-3);
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;
border-radius: var(--dn-radius-sm);
color: var(--dn-fg-muted);
font-family: var(--dn-font-code);
@@ -23,6 +33,19 @@
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;
@@ -88,6 +111,12 @@
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;

View File

@@ -80,6 +80,22 @@
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;

View File

@@ -4,9 +4,9 @@
Palette: Catppuccin Mocha, matching the editor's Theme::CATPPUCCIN_MOCHA
so the editor pane and the app chrome are the same surface.
All tokens are prefixed --dn- to keep them in one namespace. The prefix was
originally defence against Tailwind v4, which publishes its own --font-*,
--text-* and --radius-* on :root; Tailwind is gone, the convention stays.
All tokens are prefixed --dn-. Tailwind v4 publishes its own --font-*,
--text-*, --radius-* and --leading-* on :root and tree-shakes that set as
utilities come and go, so an unprefixed token would collide silently.
========================================================================== */
:root {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

275
assets/tailwind.css Normal file
View File

@@ -0,0 +1,275 @@
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--leading-tight: 1.25;
--leading-normal: 1.5;
--radius-sm: 0.25rem;
--radius-lg: 0.5rem;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.visible {
visibility: visible;
}
.relative {
position: relative;
}
.static {
position: static;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.flex {
display: flex;
}
.table {
display: table;
}
.border-collapse {
border-collapse: collapse;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.resize {
resize: both;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.text-wrap {
text-wrap: wrap;
}
.underline {
text-decoration-line: underline;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-border-style: solid;
--tw-outline-style: solid;
}
}
}

View File

@@ -61,27 +61,6 @@ Create a Gitea issue.
Run `tea issues <index>` plus `tea comments ls <index>`.
## Branch- und PR-Workflow
Änderungen werden **nicht direkt auf `main`** committet, sondern auf einem eigenen Branch
pro logischer Einheit — `feat/`, `fix/`, `refactor/`, `chore/`, `docs/`. Eine Einheit ist,
was man in einem Satz begründen kann; wenn die Commit-Nachricht „und außerdem" braucht,
sind es zwei Branches.
Zurück nach `main` geht es über einen **Pull Request in Gitea**, nicht per lokalem Merge:
```bash
git push -u origin <branch>
tea pulls create --title "..." --description "..."
```
`--description` nimmt wie bei Issues nur eine Zeichenkette; für längere Texte eine Datei
schreiben und mit `$(cat datei)` übergeben. Bezieht sich der PR auf ein Ticket, gehört
`Closes #<n>` in die Beschreibung, damit Gitea es beim Mergen schließt.
Gemergt wird in der Weboberfläche — das Mergen ist eine Entscheidung des Menschen, nicht
Teil der Umsetzung.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.

View File

@@ -0,0 +1,941 @@
# Was es kostet, Historie später möglich zu halten
> Research zu Gitea-Issue [#22](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/22). Stand: 2026-08-28.
> Recherchiert gegen Primärquellen (Hersteller-Doku, Quellcode auf GitHub, Microsoft Learn, docs.rs, crates.io).
> **Nichts davon wurde gebaut oder kompiliert.** Es wurde kein Anwendungscode geschrieben und keine Dependency
> hinzugefügt. Alles unter „Belegt" stammt aus der jeweils genannten Quelle; alles Abgeleitete ist als solches
> markiert.
>
> Historie ist **nicht geplant**. Dieses Dokument beschafft nur die Fakten für die Schreib-Entscheidung in
> [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19).
---
## Kurzantwort
**Nein — ein Schreibverfahren „temporäre Datei schreiben, dann umbenennen" schließt eine spätere Historie
nicht aus. Es ist im Gegenteil das *einzige* der beiden zur Wahl stehenden Verfahren, das Historie
überhaupt billig macht.**
Die Begründung in einem Satz: Historie braucht die **Vorfassung**, und beim Umbenennen liegt die Vorfassung
zum Zeitpunkt des Schreibens noch unversehrt auf der Platte — ein direktes `fs::write` hat sie da schon
vernichtet.
Der Einwand aus der Beratungsrunde trifft eine reale Gefahr, aber am falschen Ort. Teuer wird Historie nicht
durch das **Wie** des Schreibens, sondern durch das **Wo**: Wenn nach einem halben Jahr fünf Stellen im Code
eine Notiz auf die Platte schreiben, muss man fünf Stellen anfassen. Wenn es genau eine gibt, ist Historie
eine Änderung *innerhalb dieser einen Funktion* — und alles Weitere (Format, Ablageort, Aufbewahrung) eine
Entscheidung, die man dann trifft, nicht heute.
Der Preis dafür, Historie möglich zu halten, beträgt also: **eine Funktion.** Keine Datei, kein Format, kein
Byte auf der Platte, keine Dependency.
Und die Asymmetrie läuft genau andersherum als behauptet: Nicht das schlichte Schreiben ist die schwer
umkehrbare Entscheidung, sondern **der Append-Log**. Von „Datei ist die Wahrheit" zu „Datei plus Sicherungen"
ist eine Ergänzung. Von „Log ist die Wahrheit" zurück ist eine Migration. Wer die Option offenhalten will,
hält sie gerade dadurch offen, dass er den Log **nicht** baut.
Dass der Log die Einbahnstraße ist, ist dabei nicht nur ein Architektur-Argument: Die Automerge-Maintainer
schreiben selbst, dass sich Historie **nicht verwerfen lässt, ohne das Dokument zu ersetzen**, und Yjs
begründet in seiner README, warum Tombstones konstruktionsbedingt liegenbleiben müssen (3.2).
---
## 1. Was Historie technisch überhaupt braucht
Bevor man Kosten schätzt, lohnt es, die Anforderung auf ihre Bestandteile zu zerlegen. Eine Historie —
gleich welcher Bauart — braucht genau vier Dinge:
1. **Einen Auslöser.** Ein Zeitpunkt, an dem entschieden wird: jetzt entsteht eine neue Fassung.
2. **Die Vorfassung.** Der Inhalt, der gleich ersetzt wird.
3. **Einen Ablageort.** Irgendwo müssen die alten Fassungen hin.
4. **Eine Aufräumregel.** Ohne sie wächst der Ablageort unbegrenzt.
Nur **Punkt 1 und 2 hängen am Schreibverfahren.** Punkt 3 und 4 sind reine Folgeentscheidungen und komplett
unabhängig davon, wie die `.md` auf die Platte kommt.
Und beide, Auslöser und Vorfassung, fallen beim temp+rename-Verfahren **ohnehin an**:
- Der **Auslöser** ist der Aufruf der Schreibfunktion. Präziser geht es nicht.
- Die **Vorfassung** liegt in dem Moment, in dem die temporäre Datei fertig geschrieben ist und das Umbenennen
ansteht, noch vollständig und unangetastet unter ihrem Pfad. Man muss sie nur lesen, bevor man umbenennt.
Bei einem direkten `fs::write` auf die Zieldatei ist die Vorfassung dagegen nach dem ersten Byte weg. Wer dort
später Historie nachrüsten will, muss die Datei **zusätzlich vorher lesen** — was möglich, aber ein
Extra-Schritt ist, den das rename-Verfahren geschenkt bekommt.
> **Nebenbefund, der #19 unabhängig von Historie betrifft:** [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20)
> (Zeitstempel-Konflikt) verlangt ohnehin, dass die Schreibfunktion vor dem Schreiben den Zustand der
> Zieldatei prüft. Wer dort statt eines `stat` gleich den Inhalt liest, hat die Vorfassung ohne
> zusätzlichen Syscall in der Hand. Das ist kein Argument, es heute zu tun — nur der Beleg, dass es
> später auch nichts kostet.
---
## 2. Wie vergleichbare lokale Markdown-Editoren es lösen
Kurzüberblick, Details und Belege in den Unterabschnitten:
| Werkzeug | Wo liegt die Historie? | Format | Intervall | Aufbewahrung | Neben den `.md`? |
|---|---|---|---|---|---|
| **Obsidian** (File recovery) | App-Systemordner, `%APPDATA%\Obsidian\` — **außerhalb** des Vault | Volltext-Snapshots | 5 Min (Default) | 7 Tage (Default) | **Nein** |
| **Obsidian Sync** (Version history) | Auf Obsidians Servern | serverseitig | — | 1 Monat (Standard) / 12 Monate (Plus) | **Nein**, kostenpflichtig |
| **Joplin** (Note History) | Tabelle `revisions` in `database.sqlite` | Diff-Kette (`diff-match-patch`, JSON) | 10 Min | 90 Tage (Default) | **Nein** — es gibt gar keine `.md` |
| **Logseq** (0.10.9) | `logseq/bak/<datei>/` **im** Graph-Ordner | Volltext-Kopien, Zeitstempel im Namen | **nur bei Konflikt oder Schreibfehler**, nicht bei jedem Speichern | 6 Fassungen pro Datei | **Ja** |
| **Zettlr** | — es gibt keine | — | — | — | — |
| **Syncthing** (kein Editor, aber das beste Vorbild) | `.stversions/` **im** Sync-Ordner | Volltext-Kopien mit Zeitstempel im Namen | pro Änderung | konfigurierbar, logarithmisch ausgedünnt | **Ja** |
**Das auffälligste Muster:** Von den untersuchten Editoren legt **keiner** seine Historie neben die
Notizdateien. Sie liegt entweder im App-Ordner (Obsidian), in einer Datenbank (Joplin) oder auf einem Server
(Obsidian Sync). Wer neben den `.md` versioniert, ist ein Sync-Werkzeug, kein Editor.
### 2.1 Obsidian — Historie als aufgesetztes Core-Plugin, komplett außerhalb des Vault
Obsidian ist der direkteste Vergleichspunkt: lose `.md`-Dateien in einem Ordner, dasselbe Versprechen an den
Nutzer, dieselbe Ausgangslage.
**Belegt** (Zitate aus der offiziellen Help-Seite <https://obsidian.md/help/plugins/file-recovery>):
- „File recovery is a core plugin that protects your work from accidental deletions, file corruption, or
unwanted changes by automatically saving **complete snapshots** of your notes at regular intervals."
- „**Snapshots capture the full content of your files, not just changes**, allowing you to restore any
previous version." — also **Volltext, keine Diffs.**
- „**By default, snapshots are saved a minimum of 5 minutes from each other, and kept for 7 days.**"
- „**Snapshots are kept in the Global settings, outside of the vault**, to account for vault-related data
loss. This means that **snapshots are stored with the absolute path to the note**. If you've moved your
vault recently, you may need to move it back to the location where it was when the snapshot was taken."
- „If you are using Obsidian Sync or other syncing services, **File recovery snapshots will not sync between
devices. Snapshots are device-specific and remain local to each device.**"
- „File recovery is **not a complete backup solution**."
- Nur `.md` und `.canvas` sind wiederherstellbar.
- Aufräumen automatisch nach Ablauf der Tagesgrenze, manuell über *Clear history*.
Der Ablageort ist damit belegt als der Electron-userData-Ordner der App —
**Windows: `%APPDATA%\Obsidian\`** (<https://obsidian.md/help/data-storage>), **nicht** `.obsidian/` im Vault
und **nicht** neben den Notizen.
**Vermutung (starkes Indiz, nicht wörtlich belegt):** Die Snapshots liegen in Obsidians IndexedDB/LevelDB.
Die Doku nennt bei IndexedDB nur Sync-State und Metadata-Cache, führt aber für File recovery **dieselbe**
Apple-Lockdown-Mode-Einschränkung auf wie für IndexedDB. Ein Serialisierungsformat ist nirgends dokumentiert;
Obsidian ist Closed Source.
**Widerspruch in der Doku selbst, den man kennen sollte:** Der Abschnitt „Storage and performance" derselben
Seite behauptet, Snapshots „only store changed files", während der Haupttext „full content […] not just
changes" sagt. Plausible Auflösung (**Vermutung**): Nur *geänderte Dateien* werden überhaupt gesnapshottet,
jede davon aber als Volltext.
**Zum Schreibverfahren:** Es gibt **keine offizielle Aussage**, ob Obsidian atomar (temp+rename) oder
in-place schreibt. Belegt ist nur die Plugin-API-Semantik: `Vault.process` ist dokumentiert als „**Atomically
read, modify, and save the contents of a note**" und garantiert, „that the file doesn't change between
reading the current content and writing the updated content"
(<https://docs.obsidian.md/Plugins/Vault>). Das ist eine **Sperre gegen konkurrierende Plugins innerhalb des
Obsidian-Prozesses**, keine dokumentierte Aussage über `rename()`-Atomarität gegenüber dem Dateisystem —
diese Unterscheidung macht die Doku nirgends explizit (**Lesart, nicht wörtlich belegt**). Der Changelog zu
1.13.4 (30.07.2026) enthält den Eintrag „Fixed a rare race condition where a file could get stuck and fail to
save until the app was restarted" (<https://obsidian.md/changelog/2026-07-30-desktop-v1.13.4/>) — ein Beleg
dafür, dass der Save-Pfad auch nach über zehn Jahren nicht trivial ist.
**Warum das für #22 der wichtigste Befund ist:** Obsidians Historie ist ein **nachträglich aufgesetztes
Plugin, das mit dem Schreibverfahren des Kerns nichts zu tun hat**. Es beobachtet Dateien und legt Kopien
woanders ab. Genau das ist das „Historie ist eine Schicht *über* dem Schreiben"-Modell — von dem am längsten
etablierten Werkzeug dieser Kategorie in Produktion vorgeführt.
**Der Preis, den Obsidian für diesen Ort zahlt** (alles wörtlich aus der Doku oben): absolute Pfade, die nach
einem Vault-Umzug brechen; keine Mitnahme beim Backup; kein Sync zwischen Geräten. Ein Mittelweg —
Historie in einem versteckten Ordner *im* Vault — hat von den untersuchten Werkzeugen keiner gewählt; für
Obsidian existiert dazu ein offener Feature-Request
(<https://forum.obsidian.md/t/file-recovery-store-file-snapshots-in-the-vault-folder-instead-of-the-obsidians-system-folder/116677>,
**Sekundärquelle, nur als Hinweis**).
### 2.2 Joplin — der Gegenentwurf: keine `.md`, dafür Diff-Ketten in SQLite
Joplin ist als Vergleich nur begrenzt tauglich, weil es das Versprechen, das DioNotes gibt, gar nicht erst
macht — aber es zeigt sehr genau, was man mit einer Datenbank *dahinter* gewinnt.
**Belegt** (<https://joplinapp.org/help/dev/spec/user_profile/>): `database.sqlite` ist „The central SQLite
database file where **all notes, notebooks, tags, and metadata are stored**". Es gibt keine `.md`-Dateien.
Unter Windows: `%UserProfile%\.config\joplin-desktop`.
**Belegt** (<https://joplinapp.org/help/apps/note_history/>):
- „All the apps save a version of the modified notes **every 10 minutes**."
- Aufbewahrung „by default, for **90 days**".
- „These revisions are then **synced across all the devices**." — anders als bei Obsidian.
- Wiederherstellen kopiert in einen Ordner „Restored Notes", ohne die aktuelle Version zu verändern.
- Bei unterschiedlichen Einstellungen pro Gerät gewinnt die **kürzeste** Aufbewahrungsdauer.
**Belegt aus dem Quellcode** (<https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Revision.ts>,
<https://github.com/laurent22/joplin/blob/dev/packages/lib/services/RevisionService.ts>,
<https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts>):
- Tabelle `revisions`, Felder u. a. `title_diff`, `body_diff`, `metadata_diff`, `item_id`, `parent_id`.
- Diffs über **Google `diff-match-patch`**, serialisiert als JSON (`createTextPatch` →
`JSON.stringify(dmp.patch_make(old, new))`).
- Defaults: `revisionService.ttlDays` = **90**, `revisionService.intervalBetweenRevisions` = **10 Min**
(nicht in der UI sichtbar), `revisionService.oldNoteInterval` = **7 Tage**.
- Die erste Revision einer Notiz ist ein Volltext-Patch gegen den Leerstring; Folgerevisionen sind Diffs mit
`parent_id` auf die Vorgängerin. `mergeDiffs()` läuft die Kette bis zur Wurzel zurück.
- **Aufräumen mit Rebase am Kopf** — der interessanteste Teil. Kommentar wörtlich aus `Revision.ts`:
> „When deleting old revisions, we need to make sure that the oldest surviving revision is a 'merged' one
> (as opposed to a diff from a now deleted revision). So every time we deleted a revision, we need to find
> if there's a corresponding surviving revision and modify that revision into a 'merged' one."
Konkret: Die älteste überlebende Revision wird rekonstruiert und als Volltext-Patch gegen den Leerstring
neu geschrieben, `parent_id` auf `''`. Damit bleibt die Kettenlänge auf das TTL-Fenster begrenzt.
- Änderungen, die aus **Sync oder Entschlüsselung** stammen, werden ausgefiltert — nur echte lokale Edits
erzeugen Revisionen.
**Die Lehre für DioNotes:** Diff-Ketten sind sparsam, aber nicht umsonst. Sie kosten eine Rekonstruktion in
`O(Kettenlänge)` bei **jedem Anlegen und jedem Anzeigen** einer Revision, plus eine nicht triviale
Rebase-Logik beim Aufräumen, plus einen dokumentierten Fehlerpfad, wenn eine zu löschende Revision noch
verschlüsselt ist. Obsidian hat für dasselbe Feature Volltext gewählt und dafür die Aufbewahrung auf 7 statt
90 Tage gesetzt. **Retention-Dauer und Speicherstrategie hängen direkt zusammen** — das ist die eigentliche
Stellschraube, und sie liegt vollständig in der Zukunft.
### 2.3 Logseq — Sicherungskopien im Workspace, aber nur wenn es brenzlig wird
Logseq ist für DioNotes der **lehrreichste** Fall, weil es als einziges untersuchtes Werkzeug seine
Sicherungskopien **neben die Notizen legt** — genau der Mittelweg aus der Ticket-Frage — und weil dabei
sichtbar wird, wo dieser Weg scheuert.
> **Versionsvorbehalt:** Der `master`-Branch von Logseq ist inzwischen die DB-basierte Neuentwicklung ohne
> `handler/file.cljs`. Alle Datei-relevanten Belege stammen deshalb aus **Tag `0.10.9`**, der letzten
> dateibasierten Release-Linie.
**Belegt** — drei generierte Verzeichnisse im Graph-Ordner:
| Pfad | Zweck | Beleg |
|---|---|---|
| `logseq/bak` | Sicherung überschriebener Dateien | [`backup_file.cljs#L7`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L7) |
| `logseq/version-files/local` | explizit angeforderte Versionen | [`backup_file.cljs#L8`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L8) |
| `logseq/.recycle` | Papierkorb — Löschen ist ein `renameSync` dorthin | [`handler.cljs#L55-L67`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L55-L67) |
**Namenskonvention** — pro Datei ein eigener Unterordner, Zeitstempel im Dateinamen
([`backup_file.cljs#L10-L51`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L10-L51)):
```
<graph>/logseq/bak/pages/Foo/2024-01-15T10_23_45.678Z.Desktop.md
└ relativer Pfad ┘└ ISO-8601, ":" → "_" ┘└ Plattform ┘
```
**Der wichtigste Befund: Es wird NICHT bei jedem Speichern gesichert.** Es gibt genau drei Auslöser
(**belegt**):
1. **Erkannte Abweichung zwischen Platte und DB beim Schreiben**
([`fs/node.cljs#L54-L57`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/node.cljs#L54-L57)) —
und selbst dann filtert der Electron-Handler ein zweites Mal und sichert nur, wenn der Diff eine
*Löschung* zeigt
([`handler.cljs#L75-L80`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L75-L80)):
```clojure
(when (and (string? db-content) (string? new-content)
(string-some-deleted? db-content new-content))
(backup-file/backup-file repo :backup-dir path ...))
```
2. **Schreibfehler** (EBUSY, Lock, fehlende Rechte) — mit Notification „A backup file was saved to …"
([`handler.cljs#L117-L140`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L140)).
3. **Externe Dateiänderung über den Watcher**, bevor sie in die DB übernommen wird — Quellcode-Kommentar
wörtlich: *„save the previous content in a versioned bak file to avoid data overwritten."*
([`watcher_handler.cljs#L44-L56`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/watcher_handler.cljs#L44-L56)).
Maintainer `cnrpman` fasst es zusammen: *„Basically it writes to `bak` when there's a dangerous file
operation… **It's a safe net.**"* ([Issue #3370](https://github.com/logseq/logseq/issues/3370#issuecomment-1556840114)).
**Das ist keine Historie, sondern ein Fangnetz** — und genau das macht es für DioNotes interessant: Der
billigste denkbare Nutzen (Datenverlust bei Konflikten verhindern) wird mit dem billigsten denkbaren
Mechanismus erkauft, ohne dass irgendetwas am Schreibverfahren hängt.
**Aufräumen — es gibt eine Grenze, sie greift nur zu lokal** (**belegt**,
[`backup_file.cljs#L27-L34`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L27-L34)):
```clojure
(defn- truncate-old-versioned-files!
"reserve the latest 6 version files"
[dir]
... (drop 6 (reverse (sort files))) ... removeSync ...)
```
Warum der Ordner trotzdem wächst (**Ableitung aus dem Code**, deckt sich mit den Nutzerberichten):
- Die Grenze gilt **pro Datei-Unterordner**; die **Zahl der Unterordner ist unbegrenzt**.
- Aufgeräumt wird **nur beim Schreiben eines neuen Backups in genau diesen Ordner**. Kein Startup-Sweep,
kein Hintergrundjob. Ordner von Dateien, die nie wieder konfligieren, bleiben ewig stehen.
- Sortiert wird rein lexikografisch — funktioniert nur, weil ISO-8601 lexikografisch = chronologisch ist.
Die Retention-Zahl wanderte über die Jahre: nie getrunct (≤ 0.6.1) → 10
([PR #4421](https://github.com/logseq/logseq/pull/4421), Commit-Text: *„Files in the logseq/bak folder are
never truncated"*) → 3 → 6. Gründer `tiensonqin` empfiehlt in
[Issue #4819](https://github.com/logseq/logseq/issues/4819): *„**I suggest to cleanup backups using some
cron scripts.**"* Es gibt **keine offizielle Doku** zum bak-Ordner und **keinen Off-Schalter**
([Forum, Aug 2025](https://discuss.logseq.com/t/add-option-to-disable-backups-logseq-bak-folder/33107),
unbeantwortet).
**Korrektur einer verbreiteten Annahme:** Beschwerden über *Wachstum* gibt es reichlich
([#4819](https://github.com/logseq/logseq/issues/4819), [#3588](https://github.com/logseq/logseq/issues/3588)
„600 or more backup files", [#3370](https://github.com/logseq/logseq/issues/3370) offen seit 2021,
[Discussion #8624](https://github.com/logseq/logseq/discussions/8624) seit drei Jahren unbeantwortet). Aber
**keine Primärquelle nennt eine konkrete GB-Zahl.** Belegt ist unbegrenztes Ordner-Wachstum, nicht
unbegrenztes Größenwachstum.
**Weitere belegte Details:**
- **Nicht atomar geschrieben:** `fs/writeFileSync path content`, mit vorherigem `chmodSync` auf `644`, falls
die Datei nicht schreibbar ist
([`handler.cljs#L117-L126`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L126)).
- **Watcher: chokidar**, `awaitWriteFinish true`
([`fs_watcher.cljs`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/fs_watcher.cljs)).
- **Konflikt:** Vergleich **vor** dem Schreiben; bei Abweichung wird **nicht geschrieben**, sondern ein Modal
gezeigt mit zwei **editierbaren** Textareas („On disk:" / „In Logseq:"), je einem Button „Select this" —
kein Merge, kein Abbrechen
([`components/diff.cljs#L27-L90`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/diff.cljs#L27-L90)).
- **Undo überlebt den Neustart nicht.** Der Stack ist ein reines In-Memory-Atom über
DataScript-Datoms — `(def ^:private undo-redo-states (atom {}))`
([`undo_redo.cljs#L16-L28`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/modules/editor/undo_redo.cljs#L16-L28)).
Die offizielle Doku formuliert es vorsichtig: *„during their editing sessions"*
([docs: Undo and Redo](https://github.com/logseq/docs/blob/master/pages/Undo%20and%20Redo.md)).
- **Es gibt keine UI, die bak-Versionen anzeigt oder wiederherstellt** — nur „Open page backups directory",
das den Explorer öffnet. Der Menüpunkt „View page history" ist dagegen **Git**-basiert
(`shell/get-file-latest-git-log`) und setzt ein eigenes Git-Setup des Nutzers voraus
([`page_menu.cljs#L93-L159`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/page_menu.cljs#L93-L159)).
**Die Lehre:** Selbst das Werkzeug, das im Workspace sichert, hat sich gegen „bei jedem Speichern" und für
„nur wenn es brenzlig wird" entschieden — und hat trotzdem ein Aufräumproblem, weil die Grenze pro Datei
statt global gilt. Beides sind Entwurfsfehler, die man **beim Nachrüsten** vermeiden kann, weil sie mit dem
Schreibverfahren nichts zu tun haben.
### 2.4 Zettlr — gar keine Historie, und das ist die offizielle Position
**Belegt: Zettlr hat kein Snapshot-, History- oder Versions-Feature.** Die Doku delegiert das Thema
ausdrücklich an den Nutzer
(<https://docs.zettlr.com/en/first-time-users/workspaces.html>):
> „We strongly recommend that you regularly back up your workspaces. The simplest method is to use a cloud
> storage provider (such as Dropbox, Google Drive, OneDrive, or iCloud)… you should also back up the files
> onto an external storage device from time to time."
Vorhanden ist nur der System-Papierkorb via `shell.trashItem`
([`safe-delete.ts`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/util/safe-delete.ts))
und ein **überschreibendes** Autosave.
**Schreibverfahren — nicht atomar** (**belegt**,
[`fsal/index.ts#L529-L533`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/index.ts#L529-L533)):
```ts
public async writeTextFile (filePath: string, contents: string): Promise<void> {
await this._cache.del(filePath)
await fs.writeFile(filePath, contents, 'utf-8')
}
```
Drei Details des Save-Pfads, die #18 bis #20 direkt betreffen (**belegt**,
[`documents/index.ts#L1527-L1603`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1527-L1603)):
- Der Descriptor wird **vor** dem Schreiben aktualisiert, mit explizitem Kommentar zur Race Condition
„User ändert → Save startet → User ändert mehr → Save beendet und macht die Änderungen rückgängig".
- **Self-Echo-Unterdrückung:** `this._ignoreChanges.push(filePath)` — der eigene Write wird beim
Watcher-Event wieder herausgefiltert.
- **BOM und Zeilenende werden rekonstruiert:** intern arbeitet Zettlr immer mit `\n`, beim Schreiben wird der
ursprüngliche Zustand wiederhergestellt (`doc.descriptor.bom + content.split('\n').join(doc.descriptor.linefeed)`).
**Konfliktbehandlung, dreistufig** (**belegt**,
[`documents/index.ts#L1070-L1130`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1070-L1130)) —
für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) unmittelbar verwertbar:
1. **mtime-Filter**, bewusst `modtime <= ourModtime → return` statt Gleichheitsprüfung. Kommentar wörtlich:
*„In response to issue #1621: We will not check for equal modtime but only for newer modtime to prevent
sluggish cloud synchronization services (e.g. OneDrive and Box) from having text appear to 'jump'."*
2. **Inhaltsvergleich:** `if (diskContents === doc.lastSavedContent) return` — fängt Cloud-Dienste ab, die
nur Attribute anfassen.
3. **Dialog** „File changed on disk" mit den Buttons **„Keep editor contents"** / **„Load changes from disk"**
(kein Merge, kein Diff) und einer Checkbox „Always load changes from disk if there are no unsaved changes
in the editor", die direkt die Einstellung `alwaysReloadFiles` schreibt.
**Zur Git-Frage: Es gibt kein offizielles Zettlr-Git-Plugin** (**belegt**). `Zettlr/Zettlr-Git-Plugin`
liefert 404; die Organisation hat keine Plugin-Repos
(<https://github.com/orgs/Zettlr/repositories>). Zettlr hat überhaupt **kein Plugin-System** — Maintainer
`nathanlesage`: *„Writing a plugin system is incredibly difficult, and thus we should do that extremely
limited."* ([Discussion #3108](https://github.com/Zettlr/Zettlr/discussions/3108)). Das einzige Git-Feature
ist reine Erkennung: `dir.isGitRepository = (await fs.lstat(path.join(dir.path, '.git'))).isDirectory()`
([`fsal-directory.ts#L103-L105`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-directory.ts#L103-L105)).
[Issue #1050 „Integrate basic git functionality"](https://github.com/Zettlr/Zettlr/issues/1050) ist seit Juli
2020 offen, Label „Priority: Low".
**Die Lehre:** Ein etablierter, ernstgemeinter Markdown-Editor kommt seit Jahren **ohne jede eingebaute
Historie** aus und verweist stattdessen auf externe Werkzeuge. Das ist der empirische Beleg dafür, dass
„Historie nicht bauen" eine tragfähige Produktentscheidung ist — nicht bloß ein Aufschub.
### 2.5 Syncthing — kein Editor, aber das beste Vorbild für Ablage und Aufräumen
Syncthing ist der einzige untersuchte Vertreter, der Versionen **neben** den Nutzdateien ablegt, und hat dafür
die durchdachteste Aufräumstrategie. Alles hier **belegt** aus
<https://docs.syncthing.net/users/versioning.html>:
- **Ablageort:** Standardmäßig der Ordner **`.stversions`** innerhalb des freigegebenen Verzeichnisses. Ein
eigener Pfad ist konfigurierbar, aber die Doku warnt ausdrücklich: „please ensure that it's on the same
partition or filesystem as the regular folder path."
- **Namenskonvention:** zeitgestempelte Dateinamen.
- **Vier Modi:** *Trash Can* (letzte Fassung, `cleanoutDays`), *Simple* (N Fassungen pro Datei mit
Zeitstempel), *Staggered* (altersbasiertes Ausdünnen), *External* (delegiert an ein Kommando).
- **Der Staggered-Versioner dünnt logarithmisch aus:**
| Alter | behalten wird |
|---|---|
| 1 Stunde | die älteste Version pro 30-Sekunden-Intervall |
| 1 Tag | die älteste Version pro Stunde |
| 30 Tage | die älteste Version pro Tag |
| bis „Maximum Age" | die älteste Version pro Woche |
„Maximum Age" = 0 bedeutet: für immer behalten.
Das ist die Antwort auf die Aufräumfrage, falls DioNotes je Historie bekommt: **konstanter Overhead statt
unbegrenztem Wachstum**, ohne Diff-Rekonstruktion, ohne Datenbank. Vier Zeilen Regel.
---
## 3. Was ein Append-Log konkret kosten würde
Der Vorschlag aus der Beratungsrunde: ein Append-Log neben jeder Notiz, aus dem die `.md` als lesbare Sicht
materialisiert wird. Undo über Sitzungen, Historie und Konfliktauflösung fielen als Nebenprodukt an.
Der Vorschlag ist technisch seriös — das ist Event Sourcing, und es liefert tatsächlich alles drei. Die Frage
ist nicht, ob er funktioniert, sondern was er kostet.
### 3.1 Größenwachstum — belegte Zahlen
Die maßgebliche Zahl stammt aus dem **offiziellen Benchmark-Repo von Kevin Jahns**, dem Yjs-Autor, das die
Yjs-README selbst verlinkt (<https://github.com/dmonad/crdt-benchmarks>). Gemessen wird der bekannte
B4-Editier-Trace: 182.315 Einfügungen, 77.463 Löschungen, **104.852 Zeichen im Endtext**.
| | Yjs 13.6.11 | Automerge 2.1.10 |
|---|---|---|
| **Dokumentgröße gemerged** (`docSize`) | 159.929 B ≈ **1,53× Rohtext** | 129.116 B ≈ **1,23× Rohtext** |
| **Summe aller Einzel-Updates** (`updateSize`) | **7.306.126 B ≈ 70× Rohtext** | — |
| Durchschnittliche Update-Größe | 29 B | — |
| Parse-Zeit | 39 ms | 1.805 ms |
**Das ist die eigentliche Kostenaussage, und sie ist zweigeteilt** (**belegt**):
- **Ein *zusammengefasstes* CRDT-Dokument ist erstaunlich billig** — 1,2 bis 1,5× des Rohtextes, mit voller
Historie. Das ist deutlich günstiger, als man intuitiv erwartet.
- **Ein *ungekürzter Append-Log* — also genau das, was der Vorschlag beschreibt — kostet rund 70× den
Rohtext.** Der Unterschied zwischen 1,5× und 70× ist die Compaction. Ein Log, der nur angehängt und nie
zusammengefasst wird, liegt beim oberen Wert.
Bei der Skalierung B4×100 (10,5 Mio. Zeichen) bleibt die Dokumentgröße bei 1,53×, der **Arbeitsspeicher**
steigt aber auf 327,1 MB — etwa 31× des Textes (**belegt**, dieselbe Quelle).
Die offizielle Yjs-FAQ ordnet das ein (<https://docs.yjs.dev/api/faq>): „**Size is very rarely a practical
problem** as long as you deal with human-entered text input." Das ist ein fairer Einwand gegen eine reine
Größen-Argumentation — und der Grund, warum der Ausschlussgrund in 3.4 steht und nicht hier.
**Was daraus für DioNotes folgt** (**Ableitung**):
- **Eine Sidecar-Datei pro Notiz.** Ein Workspace mit 500 Notizen enthält 1.000 Dateien statt 500. Der
Notiz-Baum müsste die Hälfte davon ausblenden; jedes Backup-, Sync- und Suchwerkzeug des Nutzers sieht sie.
- **Autosave koppelt an den Speicherplatz.** Die Auslöser-Entscheidung aus
[#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) würde zur
Speicherplatz-Entscheidung: „Autosave nach Tipppause" erzeugt Größenordnungen mehr Log-Einträge als
„manuell mit Strg+S". Diese Kopplung existiert bei keiner anderen Option aus Abschnitt 4.
### 3.2 Aufräumen: bei CRDT-Logs nicht vorgesehen
Der wichtigste Befund dieses Abschnitts, und er ist aus Maintainer-Mund belegt.
**Automerge** — Peter van Hardenberg (Maintainer) in
[Issue #799](https://github.com/automerge/automerge/issues/799), 24.12.2023, wörtlich:
> „'Compact' could mean a few different things here — one is whether you can reduce the on-disk size of an
> Automerge document. **This happens automatically when you call `save()`.** It quite literally compacts the
> history into a very efficient columnar representation.
>
> **Discarding the history is not currently possible without replacing the document: if you don't have the
> same history, you can't merge the same changes.** That's because we use a git-like commit hash for each
> change to ensure integrity."
„Shallow clone" und „cherry pick" nennt er als nützlich, verwandt und „**not yet implemented**"; nachgefasst
am 23.09.2024: „I don't know *when* it will happen." Der Automerge-2.0-Blogpost kündigt dasselbe schon seit
Januar 2023 an (<https://automerge.org/blog/automerge-2/>, Abschnitt „History Management"):
> „Today the best way to remove something from an Automerge document's history is to **recreate the document
> from scratch** or to reset to a time before that change went in."
Orion Henry (Automerge-Core) in [Issue #348](https://github.com/automerge/automerge/issues/348), 25.04.2022:
„the solutions really are to either have the nodes coordinate **a total document reset every so often —
throwing out the old document and starting fresh**". Issue #348 wurde am 21.08.2026 geschlossen mit dem
Hinweis auf `hexane`, den neuen Storage-Engine — der laut offiziellem Blogpost
(<https://automerge.org/blog/2026-july/>) „around 2–9× faster" ist, also **Kompression und Performance
liefert, keine History-Truncation**. Die Schließung bedeutet nicht, dass das Feature existiert.
**Yjs** — die README nennt den Grund formal
(<https://github.com/yjs/yjs/blob/main/README.md>, Abschnitt „Yjs CRDT Algorithm"):
> „CRDTs that are suitable for shared text editing suffer from the fact that they **only grow in size**. […]
> **We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs.**"
Im Quellcode bestätigt: `Item.gc()` ersetzt beim normalen Löschen nur den `content` durch
`ContentDeleted(len)` — **das komplette Item-Struct mit `id{client,clock}`, `origin`, `rightOrigin`, `parent`
und der Reihenfolge-Verlinkung bleibt stehen**
(<https://github.com/yjs/yjs/blob/main/src/structs/Item.js>). Und das Zusammenfassen von Updates hilft nicht:
„**Note that this feature only merges document updates and doesn't garbage-collect deleted content**"
(<https://docs.yjs.dev/api/document-updates>). Wer Snapshots will, muss GC ganz abschalten — der Code wirft
sonst: `if (originDoc.gc) { throw new Error('Garbage-collection must be disabled in \`originDoc\`!') }`
(<https://github.com/yjs/yjs/blob/main/src/utils/Snapshot.js>).
**Das Gegenargument, das fairerweise dazugehört.** Das offizielle Automerge-Cookbook
(<https://automerge.org/docs/cookbook/modeling-data/>) schreibt:
> „Martin Kleppmann did some experiments with a benchmark document to see how much space would be saved by
> discarding history […] **The savings are not all that great, which is why we haven't prioritised history
> truncation so far.**"
Das ist ernst zu nehmen — es gilt für Kleppmanns Messfall: **ein großes, weitgehend linear getipptes
Dokument mit wenigen Actor-IDs.** Eine Notiz-App hat das umgekehrte Profil: **viele kleine Dokumente, die
über Jahre in vielen kurzen Sitzungen angefasst werden**, also viele Actor-IDs pro Dokument. Ob die Ökonomie
dort kippt, ist für dieses Dokument **nicht belegt** — die 70×-Zahl aus 3.1 legt es nahe, ist aber am
B4-Trace gemessen, nicht am Notiz-Profil.
**Fazit dieses Abschnitts:** Aufräumen heißt bei einem CRDT-Log entweder gar nichts (Yjs behält Tombstones
konstruktionsbedingt) oder „Dokument wegwerfen und neu anfangen" (Automerge). Genau denselben Kompromiss
fährt Joplin mit dem Rebase der ältesten überlebenden Revision (2.2) — nur innerhalb einer Datenbank, wo er
billig ist. Neben jeder Notiz im Dateisystem ist er es nicht.
### 3.3 Sidecar-Dateien in Sync-Ordnern — belegte Risiken
Ob die bloße Verdopplung der Dateizahl schadet, ist **nicht belegt**; die harten Limits sprechen sogar
dagegen (Dropbox: „Users shouldn't sync more than **300.000 files**" und „**1.000.000 write operations per
namespace in a 24 hour period**", <https://help.dropbox.com/plans/large-deployments>; OneDrive: „no more than
a total of **300.000 items**",
<https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa>).
Bei typischen Workspace-Größen ist das unkritisch.
**Belegt sind dagegen zwei andere Fallstricke**, und beide treffen ein Sidecar-Design härter als eine
einzelne Datei:
- **Platzhalter-Dateien werden als gelöscht interpretiert.** Obsidians offizielle Sync-FAQ
(<https://help.obsidian.md/sync/faq>): „Cloud storage services like OneDrive and Dropbox offer 'Files
on-demand' or 'Online-only files' features […] However, since these files are not always available locally,
**Obsidian Sync will interpret them as deleted, leading to their removal from your remote vault.**"
Ergänzend (<https://help.obsidian.md/sync-notes>): „iCloud Drive on Windows may lead to **file duplication
or corruption**." Und die architektonische Begründung: „**Obsidian requires access to the entire vault for
its features** […] This makes it difficult for some services to function reliably with Obsidian."
- **Das bloße Nachsehen, ob eine Datei existiert, kann die App einfrieren.** Apples Technote TN3150
(<https://developer.apple.com/documentation/technotes/tn3150-getting-ready-for-data-less-files>):
„Materializing a dataless file can take time […] **the app may become unresponsive.** If an app's main
queue remains unresponsive for a long time, **the system may terminate the app and trigger a watchdog
crash.**" Als auslösende Aktionen nennt Apple ausdrücklich „**enumerating a directory's contents and
checking whether a file exists**", und: „`stat` and `getattrlist` both trigger the materialization of any
intermediate folders." Für jede Notiz die zugehörige Log-Datei zu suchen oder zu statten ist genau dieses
Muster. (Die Technote gilt für Apple-Plattformen; DioNotes ist Windows-only, aber der Mechanismus —
Platzhalter-Dateien, die beim Zugriff nachgeladen werden — ist bei OneDrive Files On-Demand derselbe.
**Übertragung: Vermutung.**)
Apples eigene Empfehlung für mehrteilige Dokumente ist bezeichnend: **File Packages statt nebeneinander\-
liegender Dateien** („If your document data format consists of multiple distinct pieces, use a file
package", <https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html>).
Für DioNotes untauglich — dann wäre die `.md` keine eigenständige Datei mehr, und genau das ist das
Versprechen.
> **Nicht als Beleg verwendbar** (gegengeprüft und verworfen): Zotereos Warnung „extremely likely to corrupt
> your database" betrifft SQLite und File Locking, nicht Sidecar-Strukturen. git-annex' Warnung gegen
> Dropbox-Ordner beruht auf Case-Insensitivity und Symlink-Handling. Für DVC gibt es keine offizielle
> Warnung, nur Community-Threads.
### 3.4 Das Zwei-Wahrheiten-Problem — der eigentliche Einwand
Dieser Punkt braucht keine externe Quelle, er folgt aus den Rahmenbedingungen des Projekts.
DioNotes hat **keinen Filewatcher** ([#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16),
Rahmenentscheidung) und rechnet **ausdrücklich damit, dass Dropbox, Syncthing oder Git die Dateien ändern,
während die App läuft** ([#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20)).
Damit gilt für einen Append-Log:
- Ändert jemand die `.md` von außen, weiß der Log nichts davon. Der Log sagt „Inhalt X", die Datei sagt
„Inhalt Y". **Zwei Wahrheiten.**
- Beim nächsten Materialisieren gewinnt der Log — und **überschreibt die externe Änderung stillschweigend.**
Das ist kein Randfall, sondern der Normalfall bei jedem Sync-Konflikt.
- Der Ausweg wäre, die externe Änderung als neuen Eintrag in den Log einzuspeisen (Diff gegen den zuletzt
materialisierten Stand). Das ist machbar — aber es setzt genau die Konflikterkennung voraus, die #20 erst
noch entscheiden muss, und macht sie von einer Kann- zu einer **Muss**-Anforderung.
Die entscheidende Beobachtung: **Beim „Datei ist die Wahrheit"-Modell gibt es dieses Problem nicht.** Eine
Sicherungskopie, die veraltet ist, ist eine weniger nützliche Sicherungskopie — kein inkonsistenter Zustand.
Die Datei ist immer richtig, weil sie per Definition die Wahrheit ist. Der Append-Log führt eine
Korrektheitslücke ein, die das schlichte Verfahren gar nicht erst hat.
### 3.5 Bewertung am Versprechen
Der Bewertungsmaßstab aus dem Projektkontext: „Der Nutzer kann das Werkzeug jederzeit verlassen, seine
Dateien sind mit jedem Editor lesbar."
Ein Append-Log, aus dem die `.md` materialisiert wird, degradiert die `.md` zur **Ausgabe**. Formal bleibt sie
lesbar — praktisch ist sie es nicht mehr im Sinne des Versprechens, denn:
- Eine Änderung an ihr ist **wirkungslos oder wird verworfen**, sobald DioNotes das nächste Mal
materialisiert. Der Nutzer *darf* seine Dateien dann nicht mehr mit einem anderen Editor bearbeiten,
sondern nur noch lesen.
- Das Werkzeug zu verlassen, hieße die Historie zurückzulassen — sie steckt im Log, nicht in der Datei.
Der Unterschied zu einer Sicherungskopie ist damit kein gradueller: Eine Sicherungskopie ist **zusätzliche**
Information neben der Wahrheit. Ein Log ist **die** Wahrheit, und die Datei wird zur Kopie. Das ist genau die
Umkehrung, die das Versprechen bricht.
---
## 4. Die billigeren Mittelwege
Zur Einordnung: Alle drei sind **Aufsätze auf ein unverändertes Schreibverfahren**. Keiner verlangt, dass
#19 heute irgendetwas anders entscheidet. Sie sind hier aufgeführt, damit die Größenordnung des „später"
bekannt ist — nicht als Empfehlung, heute etwas davon zu bauen.
### 4.1 Eine einzelne Sicherungskopie der Vorfassung
Der billigste Weg: Vor dem Umbenennen die Zieldatei nach `<name>.md.bak` (oder in `.dionotes/bak/`) kopieren.
Eine Fassung, überschrieben bei jedem Speichern.
- **Kosten:** eine Datei pro je gespeicherter Notiz, Größe = Größe der Notiz. Bei 500 Notizen à 4 KB also
~2 MB. Kein Wachstum über die Zeit, weil jede Sicherung die vorige ersetzt. **Aufräumen entfällt.**
- **Nutzen:** deckt genau einen Fall ab — „ich habe gerade etwas kaputtgespeichert". Kein Undo über
Sitzungen, keine Historie.
- **Präzedenzfall:** Logseq macht im Kern nichts anderes, nur mit sechs statt einer Fassung und nur bei
Konflikt (2.3).
- **Fallstrick:** Wenn die Sicherung **neben** der Notiz liegt, sieht der Nutzer sie im Ordner und im Sync —
und der Notiz-Baum müsste sie ausblenden, ohne die Punkt-Regel aufzuweichen. In `.dionotes/` liegt sie
außer Sichtweite, wird aber beim Löschen einer Notiz zur Waise. Beides lösbar, beides eine Entscheidung
für später.
### 4.2 Zeitstempel-Ordner mit logarithmischer Ausdünnung (das Syncthing-Modell)
Ein Ordner `.dionotes/versions/<relativer-pfad>/<zeitstempel>.md`, ausgedünnt nach dem Muster aus 2.5.
- **Kosten:** Volltext-Kopien, aber durch die Ausdünnung mit **konstantem Overhead statt unbegrenztem
Wachstum**. Bei den Syncthing-Buckets landet man pro Notiz nach einem Jahr bei grob 50–100 Fassungen
(**Abschätzung, nicht belegt**) — für eine 4-KB-Notiz also ein paar hundert Kilobyte.
- **Nutzen:** echte Historie mit Zeitpunkten, Undo über Sitzungen, kein Diff-Algorithmus nötig.
- **Belegtes Vorbild in Produktion:** Syncthings `.stversions` mit vier Modi und den Ausdünn-Buckets
(2.5, <https://docs.syncthing.net/users/versioning.html>).
- **Warum das die beste Nachrüst-Option wäre:** Es braucht keine Datenbank, kein Diff-Format, keine
Kettenrekonstruktion wie bei Joplin (2.2) — und es vermeidet Logseqs Aufräumfehler, weil die Ausdünnung
altersbasiert statt anzahlbasiert-pro-Datei ist.
### 4.3 Git-Repository im Workspace
Die Variante, die manche Nutzer heute schon von Hand fahren — und die Logseqs „View page history" bereits
voraussetzt (2.3).
**Belegte Kosten:**
- Pro Commit entstehen **mindestens drei lose Objekte** (Blob + Root-Tree + Commit), pro Verzeichnisebene ein
weiteres Tree-Objekt. Format: zlib-komprimierter Header + Inhalt unter
`.git/objects/<2>/<38>` (<https://git-scm.com/book/en/v2/Git-Internals-Git-Objects>).
- **Lose Objekte enthalten den vollen Blob, keine Deltas** — Delta-Kompression gibt es erst beim Packen. Eine
Ein-Zeichen-Änderung an einer 5-KB-Notiz erzeugt einen kompletten neuen komprimierten Blob (**Ableitung**
aus derselben Quelle).
- `gc.auto` = **6700** lose Objekte, `gc.autoPackLimit` = **50** Packs, `gc.pruneExpire` = **2.weeks.ago**,
`gc.autoDetach` = **true** (<https://git-scm.com/docs/git-config>, <https://git-scm.com/docs/git-gc>).
- Sobald Housekeeping ausgelöst wird, laufen laut Doku **alle** Housekeeping-Aufgaben mit (rerere, worktrees,
reflog).
**Ableitung für DioNotes:** 6700 ÷ ~3 Objekte je Commit ≈ **~2.200 Commits bis zum ersten automatischen
Repack**. Bei „ein Commit pro Speichern" mit Autosave ist das eine Frage von Wochen, nicht von Jahren. Der
Repack läuft dann im Hintergrund, schreibt große `.pack`-Dateien und löscht tausende lose Dateien auf einen
Schlag — mitten in einem Ordner, den womöglich Dropbox oder Syncthing beobachtet. Für den Sync-Client sieht
das aus wie ein Änderungssturm (**Ableitung, mittlere Konfidenz**). `.git/` gehörte damit zwingend aus dem
Sync ausgenommen.
**Die Rust-Seite** (**belegt**, crates.io/docs.rs):
| Crate | Version | Downloads gesamt | C-Toolchain nötig? |
|---|---|---|---|
| `git2` (libgit2-Bindings) | 0.21.0 | ~110 Mio. | **Ja** — `libgit2-sys` kompiliert ~180k Zeilen C via `cc` (kein CMake) |
| `gix` / gitoxide | 0.87.1 | ~44 Mio. | **Nein** — Feature-Doku wörtlich: „No C toolchain is involved." |
`gix` beschreibt sich als „pure Rust implementation of Git", testet Windows in CI („failures do prevent
releases") und deckt commit, log, status, diff, reset ab. Der Haken ist der Reifegrad: Der Haupt-Einstiegs\-
punkt `gix` steht laut eigenem Stability-Tier-Schema noch auf **„Initial Development"**, entsprechend
API-Churn bei jedem Minor. Unter Windows nutzt libgit2 WinHTTP, OpenSSL ist für lokale Repo-Operationen nicht
nötig (**Ableitung** aus der Windows-Verlinkung in `build.rs`).
**Bewertung:** Git ist der einzige Weg, der Historie *und* Diff *und* Branching liefert — und der einzige,
der eine schwergewichtige Dependency plus unbegrenztes Wachstum plus periodisch teures Repacking mitbringt.
Für „Undo über Sitzungen" ist es mit Abstand die teuerste Antwort. Bemerkenswert ist, was die untersuchten
Editoren daraus machen: Zettlr empfiehlt Git **nicht** (2.4), Logseq benutzt es nur lesend und setzt voraus,
dass der Nutzer es selbst eingerichtet hat (2.3). **Niemand richtet dem Nutzer ein Repo ein.**
### 4.4 Kostenvergleich
| Option | Zusätzliche Dateien | Wachstum | Aufräumen | Dependency | Bricht das Versprechen? |
|---|---|---|---|---|---|
| Nichts tun (heute) | 0 | — | — | keine | nein |
| Eine Sicherungskopie (4.1) | 1 pro gespeicherter Notiz | keins | entfällt | keine | nein |
| Zeitstempel-Ordner (4.2) | N pro Notiz, ausgedünnt | beschränkt | Ausdünn-Regel, ~1 Funktion | keine | nein |
| Git im Workspace (4.3) | `.git/`, ≥3 Objekte pro Commit | unbeschränkt | `git gc`, teuer | `gix` oder `git2` | nein |
| Append-Log (Abschnitt 3) | 1 Log pro Notiz | **≈ 70× Rohtext** ungekürzt, ≈ 1,2–1,5× gemerged | **von den Bibliotheken nicht vorgesehen** (Automerge: Dokument neu anlegen; Yjs: Tombstones bleiben) | CRDT-/Log-Bibliothek | **ja** — die `.md` wird zur Kopie |
---
## 5. Die eigentliche Frage: Schließt temp+rename eine spätere Historie aus?
**Nein.** Die Begründung in vier Schritten.
### 5.1 Das Schreibverfahren ist für Historie günstig, nicht hinderlich
Wie in Abschnitt 1 hergeleitet: Historie braucht Auslöser und Vorfassung. Beim temp+rename-Verfahren ist die
Vorfassung im Moment vor dem Umbenennen noch vollständig auf der Platte. Der Einbau einer Historie wäre eine
Einfügung von wenigen Zeilen **an einer Stelle**, an der man ohnehin schon steht:
```
schreibe_notiz(pfad, inhalt):
prüfe Konflikt (aus #20)
── hier ließe sich später die Vorfassung wegsichern ──
schreibe inhalt nach pfad.tmp
sync
benenne pfad.tmp nach pfad um
```
Das Gegenteil gilt für den naiven Weg: `fs::write(pfad, inhalt)` zerstört die Vorfassung, bevor irgendjemand
sie sichern könnte. Wer dort Historie nachrüstet, muss die Datei zusätzlich vorher lesen. Das ist immer noch
billig — aber es ist der **teurere** der beiden Wege, und er ist zugleich der, den #19 aus Gründen der
Absturzsicherheit ohnehin ausschließen will.
**Damit ist der Einwand in seiner konkreten Form widerlegt**: Die Entscheidung, die #19 zu treffen hat, macht
Historie nicht teurer. Sie macht sie billiger.
### 5.2 Was Historie *tatsächlich* teuer machen würde
Damit das Ergebnis belastbar ist, hier die Gegenprobe — die Entscheidungen, die den Einwand berechtigt machen
würden. Keine davon ist die Wahl zwischen `rename` und `write`:
| Falle | Warum sie teuer wird | Kosten sie zu vermeiden |
|---|---|---|
| **Mehrere Schreibstellen.** Autosave schreibt hier, „Notiz verlassen" dort, „Fenster schließen" an dritter Stelle. | Historie muss an jeder Stelle nachgezogen werden, und jede vergessene Stelle ist ein Loch in der Historie, das man erst durch einen Bug bemerkt. | **Eine** Funktion, die schreibt. Alle Auslöser aus [#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) rufen dieselbe. Kostet heute nichts. |
| **Kein Notiz-Begriff, nur Pfade.** | Historie, die am Pfad hängt, reißt beim Umbenennen ab ([#23](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/23)). Obsidian hat genau dieses Problem, dokumentiert („snapshots are stored with the absolute path to the note"). | Entweder akzeptieren (Obsidian tut es) oder später eine stabile ID einführen. Beides nachrüstbar; die Kette bräche dann nur für Umbenennungen *vor* der Einführung. |
| **Umbenennen als Löschen+Neuanlegen implementiert.** | Zerreißt jede Historie und erzeugt Waisen im Ablageort. | Umbenennen als Umbenennen implementieren. Ist in #23 ohnehin die naheliegende Wahl. |
| **Der Log-Ansatz aus dem Vorschlag.** | Siehe Abschnitt 3 — er ist die einzige der hier betrachteten Optionen, die schwer umkehrbar ist. | Ihn nicht bauen. |
**Nur die erste Zeile ist eine echte Auflage an #19** — und sie kostet heute exakt nichts, weil es aktuell
noch **überhaupt keine Schreibstelle im Code gibt** (verifiziert: keine Fundstelle für `fs::`/`write`/`File::`
unter `src/`; der Editor nimmt Eingaben entgegen und verwirft sie, so beschrieben in
[#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16)).
### 5.3 Die Asymmetrie läuft andersherum
Der Vorschlag aus der Beratungsrunde begründet sich damit, dass die Schreib-Entscheidung schwer umkehrbar sei.
Prüft man beide Richtungen, ergibt sich das Gegenteil:
| Richtung | Aufwand |
|---|---|
| „Datei ist die Wahrheit" → „Datei plus Sicherungen" | **Ergänzung.** Eine Funktion erweitern, ein Ordner, eine Aufräumregel. Bestehende Notizen funktionieren unverändert weiter, die Historie beginnt eben ab Einführung. Kein Datenmigrations-Schritt, kein Bruch für den Nutzer. |
| „Log ist die Wahrheit" → „Datei ist die Wahrheit" | **Migration.** Jede Notiz aus dem Log materialisieren, die Logs entfernen oder verwaisen lassen, die gesamte gesammelte Historie wegwerfen oder in ein neues Format überführen — und den Nutzern erklären, warum ihre Historie weg ist. |
Wer sich die Option offenhalten will, hält sie also gerade dadurch offen, dass er den Log **nicht** baut. Der
Log ist die Einbahnstraße, nicht das schlichte Schreiben.
Obsidian ist der empirische Beleg: Das Werkzeug wurde 2020 veröffentlicht, hat von Anfang an lose `.md`-Dateien
geschrieben, und File recovery kam später als Core-Plugin obendrauf — vollständig außerhalb des Vault, ohne
das Dateiformat oder das Schreibverfahren anzufassen (**belegt** durch den Aufbau des Plugins, s. 2.1;
das genaue Erscheinungsdatum des Plugins ist **nicht belegt**).
### 5.4 Belastbarkeit dieser Antwort
Die Antwort steht auf zwei Beinen unterschiedlicher Härte:
- **Hart (belegt):** Obsidian betreibt eine produktive Historie für lose `.md`-Dateien als aufgesetzte
Schicht, die vom Schreibverfahren des Kerns entkoppelt ist und außerhalb des Datenordners lebt. Ein
Werkzeug mit derselben Ausgangslage wie DioNotes hat das Problem also nachweislich nachträglich gelöst.
- **Weich (Argumentation, nicht durch Quelle belegt):** Dass genau *eine* Schreibstelle die einzige
load-bearing Auflage ist, ist eine Schlussfolgerung aus der Zerlegung in Abschnitt 1, kein Zitat. Sie ist
aber schwer zu bestreiten: Eine Schicht über N Aufrufstellen einzuziehen kostet N Änderungen, über einer
Aufrufstelle eine.
**Fazit für #19: Der Einwand ist entkräftet. Die Schreib-Entscheidung darf allein nach den Kriterien aus #19
fallen — Absturzsicherheit, Sichtbarkeit der temporären Datei, Dateirechte, Aufräumen. Historie legt ihr
genau eine Auflage auf, und die ist heute gratis: eine einzige Funktion, durch die alles Schreiben läuft.**
---
## 6. Randbefunde für die Nachbartickets
Bei der Recherche fielen Belege an, die #22 nicht beantworten, aber #19 und #20 direkt betreffen. Sie stehen
hier, damit sie nicht verlorengehen — **nicht** als Entscheidung.
**Für [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19) (Schreibverfahren):**
- **Weder Rust noch Microsoft dokumentieren `rename` als atomar.** Das Wort „atomic" kommt weder in
<https://doc.rust-lang.org/std/fs/fn.rename.html> noch in der `MoveFileExW`-Doku vor. Rust garantiert nur
„replacing the original file if `to` already exists" und nennt `MoveFileExW` mit Fallback auf
`SetFileInformationByHandle`. Man verlässt sich auf undokumentiertes NTFS-Verhalten (**belegt durch
Abwesenheit**).
- **Microsoft empfiehlt für genau diesen Anwendungsfall `ReplaceFile`, nicht `MoveFileEx`.** Aus
<https://learn.microsoft.com/en-us/windows/win32/fileio/deprecation-of-txf>, Abschnitt „Applications
updating a single file with 'document-like' data": *„A common approach is to write the document to a new
file, then replace the original file with the new one. **One method to do this is with the `ReplaceFile`
API.**"*
- **Temp+rename verliert Metadaten.** Die `ReplaceFileW`-Remarks führen als Vorteil auf, dass die Funktion
*creation time, short file name, object identifier, DACLs, security resource attributes, encryption,
compression* und *named streams* der Originaldatei erhält — was per Kontrast belegt, dass ein nacktes
temp+rename genau diese verliert. Die **File ID ändert sich sogar bei `ReplaceFile`**. Kein untersuchtes
Rust-Crate (`tempfile`, `atomicwrites`, `atomic-write-file`) nutzt `ReplaceFile`; `atomic-write-file`
dokumentiert die Verluste immerhin explizit („No support for preserving timestamps, ACLs, extended
attributes…").
→ **Praktische Folge für #19:** Ein „erstellt am" gehört ins Frontmatter der `.md`, nicht ins Dateisystem.
- **Das realistischste Fehlerbild ist `ERROR_SHARING_VIOLATION`**, wenn Defender oder ein Sync-Client die
Zieldatei ohne `FILE_SHARE_DELETE` offen hält (<https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew>).
`tempfile::NamedTempFile::persist` gibt die temporäre Datei im `PersistError` zurück — genau für den
Retry-Fall gebaut. Ebenfalls belegt: `persist` synchronisiert weder Inhalt noch Verzeichnis.
- **Die temporäre Datei muss im selben Verzeichnis liegen** — `fs::rename` scheitert über Volume-Grenzen
(„This will not work if the new name is on a different mount point"), und Syncthing warnt bei seinem
eigenen Versions-Ordner mit derselben Begründung.
- **`fs-err` löst das Problem nicht** (es ist nur ein Drop-in-Ersatz für `std::fs` mit besseren
Fehlermeldungen), ist aber die naheliegende Ergänzung, um überhaupt zu sehen, *welche* Datei die
Sharing-Violation geworfen hat.
**Für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) (Zeitstempel-Konflikt):**
- **Zettlrs mtime-Regel ist bewusst `modtime <= ourModtime → ignorieren`, nicht Gleichheitsprüfung**, mit
wörtlicher Begründung im Code: *„to prevent sluggish cloud synchronization services (e.g. OneDrive and Box)
from having text appear to 'jump'"*. Danach folgt ein **Inhaltsvergleich**, weil Cloud-Dienste Dateien
anfassen, ohne den Inhalt zu ändern. Ein reiner mtime-Vergleich fängt das nicht ab.
- **Logseq vergleicht ebenfalls den Inhalt vor dem Schreiben** und schreibt bei Abweichung gar nicht.
Beide untersuchten Open-Source-Editoren lösen den Konflikt über Inhalt, nicht über Zeitstempel allein.
- **Beide brauchen einen Self-Echo-Filter**, damit der eigene Schreibvorgang nicht als externe Änderung
zurückkommt (Zettlr: `_ignoreChanges`-Liste; Logseq: Inhaltsvergleich). Das ist erst relevant, falls je ein
Filewatcher dazukommt — aber es erklärt, warum ein Watcher nicht „nur ein Watcher" ist.
- **Dropbox nennt als dokumentierte Ursache Nr. 3 für Conflicted Copies wörtlich „applications with an
auto-save feature"** (<https://help.dropbox.com/organize/conflicted-copy>). Das ist ein Argument für
[#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18), Autosave zu entprellen statt pro
Tastendruck zu schreiben.
- **Syncthings Konfliktnamen** sind `<name>.sync-conflict-<date>-<time>-<modifiedBy>.<ext>`, und diese
Dateien werden „treated as normal files after they are created, so they are propagated between devices" —
sie tauchen also im Notiz-Baum auf, wenn nichts sie ausfiltert.
---
## 7. Belegt vs. Vermutung — Übersicht
| Aussage | Status |
|---|---|
| Obsidian: Snapshots außerhalb des Vault, `%APPDATA%\Obsidian\`, Volltext, 5 Min / 7 Tage Default | **Belegt** (help.obsidian.md) |
| Obsidian: Snapshots syncen nicht, absolute Pfade, brechen nach Vault-Umzug | **Belegt** (help.obsidian.md) |
| Obsidian: Snapshots liegen konkret in IndexedDB/LevelDB | **Vermutung** — starkes Indiz über die Lockdown-Mode-Einschränkung, kein wörtlicher Beleg |
| Obsidian: Serialisierungsformat der Snapshots | **Nicht belegbar** — Closed Source, nirgends dokumentiert |
| Obsidian: Schreibverfahren des Kerns (atomar oder nicht) | **Nicht belegbar** — keine offizielle Aussage |
| Obsidian: `Vault.process` ist „atomar" nur prozessintern, nicht auf FS-Ebene | **Lesart** — die Doku macht die Unterscheidung nicht explizit |
| Joplin: SQLite statt `.md`, `revisions`-Tabelle, `diff-match-patch`, 10 Min / 90 Tage | **Belegt** (joplinapp.org + Quellcode) |
| Joplin: Rebase der ältesten überlebenden Revision beim Aufräumen | **Belegt** (Quellcode-Kommentar + Implementierung) |
| Joplin: konkrete Größe der Revisions-Tabelle in der Praxis | **Nicht belegbar** — keine offizielle Zahl |
| Logseq: `logseq/bak/<datei>/<ISO>.md`, nur bei Konflikt/Schreibfehler/externer Änderung | **Belegt** (Quellcode 0.10.9) |
| Logseq: „reserve the latest 6 version files", nur beim Schreiben in denselben Ordner | **Belegt** (Quellcode) |
| Logseq: bak-Ordner werden GB-groß | **Nicht belegbar** — belegt ist unbegrenztes *Ordner*-Wachstum, keine Primärquelle nennt eine Größe |
| Logseq: nicht-atomares `writeFileSync`, chokidar-Watcher, Diff-Modal bei Konflikt | **Belegt** (Quellcode) |
| Logseq: Undo ist reines In-Memory-Atom, überlebt Neustart nicht | **Belegt** (Quellcode + Doku) |
| Zettlr: keine eingebaute Historie, Doku verweist auf Cloud-Backup | **Belegt** (docs.zettlr.com) |
| Zettlr: `fs.writeFile`, nicht atomar; mtime-Filter „nur neuer, nicht ungleich"; Konflikt-Dialog | **Belegt** (Quellcode master @ 86c6c56) |
| Zettlr: kein offizielles Git-Plugin, kein Plugin-System | **Belegt** (404 + Maintainer-Aussage in Discussion #3108) |
| Syncthing: `.stversions`, Zeitstempel-Namen, vier Modi, staggered-Buckets | **Belegt** (docs.syncthing.net) |
| Git: `gc.auto` = 6700, `gc.autoPackLimit` = 50, ≥3 lose Objekte pro Commit | **Belegt** (git-scm.com) |
| Git: „~2.200 Commits bis zum ersten Repack" | **Ableitung** aus den belegten Defaults |
| `gix` braucht keinen C-Compiler, `git2` schon | **Belegt** (docs.rs Feature-Doku, `libgit2-sys/build.rs`) |
| Ausgedünnte Zeitstempel-Historie ≈ 50–100 Fassungen pro Notiz nach einem Jahr | **Abschätzung**, nicht belegt |
| „Historie ist eine Schicht über dem Schreiben" | **Belegt am Beispiel Obsidian**, im Übrigen **Argumentation** |
| „Eine Schreibstelle ist die einzige echte Auflage" | **Argumentation**, nicht durch Quelle belegt — aber aus der Zerlegung in Abschnitt 1 zwingend |
| Zwei-Wahrheiten-Problem des Append-Logs | **Argumentation** aus den Projekt-Rahmenbedingungen (kein Filewatcher, externer Sync erwartet) |
| CRDT-Dokument **gemerged** ≈ 1,2–1,5× Rohtext; **Summe der Einzel-Updates ≈ 70× Rohtext** (B4-Trace) | **Belegt** (dmonad/crdt-benchmarks, Repo des Yjs-Autors) |
| Automerge: „Discarding the history is not currently possible without replacing the document" | **Belegt** (Maintainer-Zitat, Issue #799) |
| Automerge: shallow clone / history truncation seit 2023 angekündigt, bis heute nicht implementiert | **Belegt** (Blogpost + Issue-Verlauf + API-Oberfläche) |
| Yjs: Tombstones sind konstruktionsbedingt nicht sammelbar, Update-Merge räumt nicht auf | **Belegt** (README, `Item.js`, docs.yjs.dev) |
| Automerge-Cookbook: „The savings are not all that great" (Gegenargument) | **Belegt** — gilt für Kleppmanns Messfall (großes Dokument, wenige Actor-IDs) |
| Ob die Ökonomie beim Notiz-Profil (viele kleine Dokumente, viele Sitzungen) kippt | **Nicht belegt** — die 70×-Zahl legt es nahe, ist aber am B4-Trace gemessen |
| Obsidian Sync: „Files on-demand"-Platzhalter werden als gelöscht interpretiert | **Belegt** (help.obsidian.md) |
| Apple TN3150: Existenzprüfung/Verzeichnis-Enumeration kann Materialisierung und Watchdog-Crash auslösen | **Belegt** — Übertragung auf OneDrive Files On-Demand unter Windows ist **Vermutung** |
| Dass die bloße Verdopplung der Dateizahl in Sync-Ordnern schadet | **Nicht belegt** — die 300.000er-Limits sprechen eher dagegen |
---
## Quellenliste
**Obsidian**
- File recovery (Core-Plugin): <https://obsidian.md/help/plugins/file-recovery>
- How Obsidian stores data: <https://obsidian.md/help/data-storage>
- Vault API Guide: <https://docs.obsidian.md/Plugins/Vault>
- Vault TypeScript-Referenz: <https://docs.obsidian.md/Reference/TypeScript+API/Vault>
- `DataAdapter.write`: <https://docs.obsidian.md/Reference/TypeScript+API/DataAdapter/write>
- Sync Version history: <https://obsidian.md/help/sync/version-history>
- Changelog 1.13.4 (Save-Race-Condition): <https://obsidian.md/changelog/2026-07-30-desktop-v1.13.4/>
- Changelog 1.6.0 (externe Änderungen): <https://obsidian.md/changelog/2024-05-09-desktop-v1.6.0/>
- Feature-Request „Snapshots im Vault" (*Sekundärquelle*): <https://forum.obsidian.md/t/file-recovery-store-file-snapshots-in-the-vault-folder-instead-of-the-obsidians-system-folder/116677>
**Joplin**
- Note History: <https://joplinapp.org/help/apps/note_history/>
- User profile / Datenablage: <https://joplinapp.org/help/dev/spec/user_profile/>
- Import & Export: <https://joplinapp.org/help/apps/import_export/>
- `Revision.ts`: <https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Revision.ts>
- `RevisionService.ts`: <https://github.com/laurent22/joplin/blob/dev/packages/lib/services/RevisionService.ts>
- `builtInMetadata.ts` (Defaults): <https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts>
**Logseq** (alle Codebelege gegen Tag `0.10.9`)
- `backup_file.cljs` (bak-Ordner, Namensschema, Truncate): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs>
- `handler.cljs` (Schreiben, `.recycle`, `backupDbFile`): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs>
- `fs/node.cljs` (Vergleich vor dem Schreiben): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/node.cljs>
- `watcher_handler.cljs` (Backup bei externer Änderung): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/watcher_handler.cljs>
- `fs_watcher.cljs` (chokidar): <https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/fs_watcher.cljs>
- `components/diff.cljs` (Konflikt-Modal): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/diff.cljs>
- `undo_redo.cljs` (In-Memory-Undo): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/modules/editor/undo_redo.cljs>
- `page_menu.cljs` („View page history" via `git log`): <https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/page_menu.cljs>
- Doku „Undo and Redo": <https://github.com/logseq/docs/blob/master/pages/Undo%20and%20Redo.md>
- Issue #3370 (Maintainer: „It's a safe net"): <https://github.com/logseq/logseq/issues/3370>
- Issue #4819 (Gründer: „cleanup backups using some cron scripts"): <https://github.com/logseq/logseq/issues/4819>
- PR #4421 (Einführung des Truncate): <https://github.com/logseq/logseq/pull/4421>
- Forum: Wunsch nach Off-Schalter (*Sekundärquelle*): <https://discuss.logseq.com/t/add-option-to-disable-backups-logseq-bak-folder/33107>
**Zettlr** (Codebelege gegen `master` @ `86c6c56`)
- Workspaces / Backup-Empfehlung: <https://docs.zettlr.com/en/first-time-users/workspaces.html>
- Settings (Autosave, „Always load remote changes"): <https://docs.zettlr.com/en/reference/settings.html>
- `fsal/index.ts` (`writeTextFile`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/index.ts>
- `documents/index.ts` (`saveFile`, `handleRemoteChange`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts>
- `fsal-watchdog.ts` (chokidar): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-watchdog.ts>
- `fsal-directory.ts` (`isGitRepository`): <https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-directory.ts>
- Discussion #3108 (kein Plugin-System): <https://github.com/Zettlr/Zettlr/discussions/3108>
- Issue #1050 (Git seit 2020 offen): <https://github.com/Zettlr/Zettlr/issues/1050>
**Append-Log / CRDT (Automerge, Yjs)**
- Automerge Issue #799 (Maintainer pvh: „Discarding the history is not currently possible…"): <https://github.com/automerge/automerge/issues/799>
- Automerge Issue #348 (Orion Henry: „a total document reset every so often"): <https://github.com/automerge/automerge/issues/348>
- Automerge 2.0, Abschnitt „History Management": <https://automerge.org/blog/automerge-2/>
- Automerge Cookbook, „Modeling Data → Performance" (Kleppmann: „The savings are not all that great"): <https://automerge.org/docs/cookbook/modeling-data/>
- Automerge `hexane` Storage-Engine (Kompression, keine Truncation): <https://automerge.org/blog/2026-july/>
- Yjs README, „Yjs CRDT Algorithm" (Tombstones): <https://github.com/yjs/yjs/blob/main/README.md>
- Yjs `src/structs/Item.js` (`Item.gc()`): <https://github.com/yjs/yjs/blob/main/src/structs/Item.js>
- Yjs `src/utils/Snapshot.js` (GC muss aus sein): <https://github.com/yjs/yjs/blob/main/src/utils/Snapshot.js>
- Yjs Document Updates („doesn't garbage-collect deleted content"): <https://docs.yjs.dev/api/document-updates>
- Yjs FAQ („Size is very rarely a practical problem"): <https://docs.yjs.dev/api/faq>
- crdt-benchmarks (Kevin Jahns, Zahlen zu B4): <https://github.com/dmonad/crdt-benchmarks>
**Sidecar-Dateien in Sync-Ordnern**
- Obsidian Sync FAQ („interpret them as deleted"): <https://help.obsidian.md/sync/faq>
- Obsidian, Notizen zwischen Geräten synchronisieren: <https://help.obsidian.md/sync-notes>
- Apple TN3150 „Getting ready for dataless files": <https://developer.apple.com/documentation/technotes/tn3150-getting-ready-for-data-less-files>
- Apple iCloud Design Guide (File Packages statt Sidecars): <https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html>
- Dropbox-Limits (300.000 Dateien, 1 Mio. Writes/24 h): <https://help.dropbox.com/plans/large-deployments>
- OneDrive-Limits (300.000 Items): <https://support.microsoft.com/en-us/office/restrictions-and-limitations-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa>
**Syncthing**
- File Versioning: <https://docs.syncthing.net/users/versioning.html>
- Syncing / Conflict Handling: <https://docs.syncthing.net/users/syncing.html>
- Ignoring Files (`.stignore`): <https://docs.syncthing.net/users/ignoring.html>
**Git und Rust-Anbindung**
- Git Internals — Git Objects: <https://git-scm.com/book/en/v2/Git-Internals-Git-Objects>
- `git-config` (`gc.auto`, `gc.autoPackLimit`, `gc.pruneExpire`): <https://git-scm.com/docs/git-config>
- `git-gc`: <https://git-scm.com/docs/git-gc>
- `git2`: <https://crates.io/crates/git2> · <https://github.com/rust-lang/git2-rs>
- `gix` / gitoxide: <https://crates.io/crates/gix> · <https://github.com/GitoxideLabs/gitoxide> · <https://docs.rs/gix/>
**Windows-Schreibverfahren** (für [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19), hier nur als Randbefund)
- `std::fs::rename`: <https://doc.rust-lang.org/std/fs/fn.rename.html>
- `MoveFileExW`: <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>
- `ReplaceFileW` (Liste der erhaltenen Attribute): <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew>
- Deprecation of TxF (Microsofts Empfehlung für „document-like data"): <https://learn.microsoft.com/en-us/windows/win32/fileio/deprecation-of-txf>
- `CreateFileW` (`ERROR_SHARING_VIOLATION`, `FILE_SHARE_DELETE`): <https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew>
- `tempfile::NamedTempFile::persist`: <https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist>
- Dropbox „Conflicted copy" (Ursache: „applications with an auto-save feature"): <https://help.dropbox.com/organize/conflicted-copy>

575
docs/research/papierkorb.md Normal file
View File

@@ -0,0 +1,575 @@
# Papierkorb unter Rust: Crate, Netzlaufwerke, Kosten
**Gitea-Issue:** #21 · **Stand der Recherche:** 2026-08-28
**Umfeld:** DioNotes, Rust (edition 2021), Dioxus 0.7.1, Default-Feature `desktop`.
Entwicklung und Messung unter Windows 11 Enterprise 10.0.26200, cargo/rustc 1.96.0.
Alle Aussagen sind als **[belegt]** (Primärquelle mit URL, Quellcode, oder eigene reproduzierbare
Messung) oder **[Vermutung]** (Schlussfolgerung ohne deckende Quelle) markiert.
> **Besonderheit dieser Recherche:** Die Kernfrage — was auf Netzlaufwerken passiert — wurde
> **nicht erschlossen, sondern gemessen**. Auf dem Entwicklungsrechner sind SMB-Netzlaufwerke
> gemountet (`Z:` → `\\la-nas-01.ad.datawin.de\Scan`). Es wurden drei kleine Wegwerf-Programme
> außerhalb des Repos gebaut und ausgeführt. Die Rohausgaben stehen in Abschnitt 4.
> Im Repo wurde **keine Dependency hinzugefügt**; `Cargo.toml` und `Cargo.lock` sind unverändert.
---
## 1. Kurzantwort
**Die Crate ist `trash`, aktuell `5.2.6`** (veröffentlicht 2026-05-03). Sie ist der De-facto-Standard
und wird gepflegt.
**Aufruf:**
```rust
trash::delete(&pfad)?; // eine Datei
trash::delete_all(&pfade)?; // mehrere
```
**Kosten:** **+6 Pakete** im Windows-Graphen. Das ist **nicht** über Cargo-Features reduzierbar —
das im Ticket vermutete Gegenstück zum `rfd`/Wayland-Fall **gibt es hier nicht**. Fünf der sechs
Pakete sind eine **zweite, ältere Kopie der `windows`-Crate-Familie** (0.56 neben dem bereits
vorhandenen 0.61.3), weil `trash 5.2.6` noch auf `windows 0.56.0` festgenagelt ist.
**Netzlaufwerke — und hier wird es unangenehm, es sind zwei getrennte Probleme:**
1. **`trash 5.2.6` funktioniert auf Netzlaufwerken schlicht gar nicht.** Der Aufruf schlägt mit
einer irreführenden Meldung fehl (`0x80070002`, „Das System kann die angegebene Datei nicht
finden"), **die Datei bleibt unangetastet liegen**. Ursache ist ein Pfad-Bug in der Crate, kein
Papierkorb-Thema. Immerhin: **nichts geht verloren.**
2. **Wäre der Bug behoben, wäre das Verhalten gefährlich.** Ruft man dieselbe Windows-API mit
denselben Flags, aber unverbogenem Pfad auf, dann wird die Datei auf dem Netzlaufwerk
**endgültig und stillschweigend gelöscht** — `PerformOperations()` liefert `Ok(())`,
`GetAnyOperationsAborted()` liefert `false`. Am Rückgabewert ist der Fall **nicht** vom
erfolgreichen Papierkorb-Verschieben zu unterscheiden.
**Die Antwort auf die eigentliche Ticketfrage lautet also: Ja, die App braucht eine Vorab-Prüfung
bzw. Rückfrage.** Sich auf `Ok(())` zu verlassen, reicht nicht.
**Erkennen lässt sich der Fall zuverlässig** — aber nur über `IFileOperationProgressSink`, den
`trash` nicht durchreicht (die Crate übergibt hart `None`). Details in Abschnitt 5.
**Blockierend.** Die Dauer ist unabhängig von der Dateigröße (Verschieben innerhalb des Volumes);
gemessen: ~740 ms beim allerersten Aufruf (COM-Initialisierung), danach ~27 ms. Abschnitt 6.
---
## 2. Welche Crate, wird sie gepflegt?
### [belegt]
- **`trash`, aktuelle Version `5.2.6`**, veröffentlicht **2026-05-03**, nicht zurückgezogen.
2.683.466 Downloads gesamt, 724.229 „recent". Beschreibung: „A library for moving files and
folders to the Recycle Bin".
→ <https://crates.io/api/v1/crates/trash>
- Letzte Releases: 5.2.6 (2026-05-03), 5.2.5 (2025-10-25), 5.2.4 (2025-10-24), 5.2.3 (2025-08-15),
5.2.2 (2025-02-22). Also **regelmäßige Releases, zuletzt vor knapp vier Monaten**.
→ <https://crates.io/api/v1/crates/trash>
- Das in `Cargo.toml` eingetragene Repository ist `https://github.com/ArturKovacs/trash`; gepflegt
und released wird faktisch aus dem Fork **<https://github.com/Byron/trash-rs>** (251 Sterne), auf
den auch die CI-Badges und alle Changelog-Commit-Links zeigen.
→ `trash-5.2.6/README.md`, `trash-5.2.6/CHANGELOG.md`
- Unterstützte Plattformen laut README: „Windows, macOS, and all FreeDesktop Trash compliant
environments (including GNOME, KDE, XFCE, and more)".
→ `trash-5.2.6/README.md`
- `rust-version = "1.85.0"`, `edition = "2021"`, Lizenz MIT.
→ `trash-5.2.6/Cargo.toml`
- Das Changelog von 5.2.6 zeigt echte, inhaltliche Wartung (Panic durch sauberen Fehler ersetzt,
weil `cosmic-files` abstürzte) — die Crate hat reale, anspruchsvolle Nutzer.
→ `trash-5.2.6/CHANGELOG.md`
### [Vermutung]
Es gibt keine ernstzunehmende Alternative. `rrecycle` und `recyclebin` tauchen in der Suche auf,
sind aber ungleich kleiner und teils reine CLI-Werkzeuge. Für DioNotes ist `trash` gesetzt.
---
## 3. Was kostet das im Abhängigkeitsgraphen?
Das Ticket fragt gezielt nach einem Gegenstück zum `rfd`-Fall, wo sich neun Wayland-Pakete durch
Angleichen der Features an `dioxus-desktop` vollständig vermeiden ließen.
### [belegt] Antwort: Ein solches Gegenstück gibt es nicht.
Die Features von `trash 5.2.6` lauten wörtlich:
```toml
[features]
coinit_apartmentthreaded = []
coinit_disable_ole1dde = []
coinit_multithreaded = []
coinit_speed_over_memory = []
default = ["coinit_apartmentthreaded", "chrono"]
```
→ `trash-5.2.6/Cargo.toml`
**Alle vier `coinit_*`-Features haben leere Dependency-Listen** — es sind reine `cfg`-Schalter für
den COM-Initialisierungsmodus (`COINIT_APARTMENTTHREADED` vs. `COINIT_MULTITHREADED`), ohne
jede Wirkung auf den Graphen.
→ `trash-5.2.6/src/windows.rs`, `struct CoInitializer`
Das einzige dependency-wirksame Feature ist `chrono`, und das ist **ausschließlich unter
`cfg(all(unix, not(macos), not(ios), not(android)))`** deklariert. **Auf Windows ist es
wirkungslos.**
→ `trash-5.2.6/Cargo.toml`, Abschnitt `[target.'cfg(all(unix, …))'.dependencies.chrono]`
### [belegt] Gemessene Zahlen
Basis: `Cargo.lock` im Repo mit 745 Paketen; `cargo tree --edges normal` ergibt 419 eindeutige
Pakete für `x86_64-pc-windows-msvc` und 470 für `x86_64-unknown-linux-gnu`.
| Variante | Windows-Target | Linux-Target |
|---|---|---|
| `cargo add trash` (default) | **+6** (419 → 425) | +4 (470 → 474) |
| `cargo add trash --no-default-features` | **+6** (keine Ersparnis) | +2 (470 → 472) |
Die sechs neuen Pakete auf Windows:
```
trash v5.2.6
windows v0.56.0
windows-core v0.56.0
windows-implement v0.56.0 (proc-macro)
windows-interface v0.56.0 (proc-macro)
windows-result v0.1.2
```
### [belegt] Warum die `windows`-Pakete nicht „gratis" sind
Sie stehen zwar bereits namentlich im `Cargo.lock`, aber in **semver-inkompatiblen** Versionen,
werden also **nicht** dedupliziert:
| Crate | schon im Lock (via dioxus/wry) | von `trash` gefordert | dedupliziert? |
|---|---|---|---|
| `windows` | 0.61.3 | **0.56.0** | nein |
| `windows-core` | 0.61.2 | **0.56.0** | nein |
| `windows-implement` | 0.60.2 | **0.56.0** | nein |
| `windows-interface` | 0.59.3 | **0.56.0** | nein |
| `windows-result` | 0.3.4 | **0.1.2** | nein |
`cargo tree -i windows@0.56.0` bestätigt: `trash` ist der einzige Konsument dieser zweiten Kopie.
Wirklich gratis sind nur `log` (0.4.33) und `scopeguard` (1.2.0), die bereits passend im Graphen
stehen.
### [belegt] Auf Linux kostet es fast nichts
Die FreeDesktop-Implementierung ist reines Rust (`libc`, `once_cell`, `scopeguard`, `urlencoding`,
optional `chrono`). **Kein GTK, kein dbus, kein zbus** — der `rfd`-artige Fallstrick existiert hier
nicht.
→ `trash-5.2.6/Cargo.toml`
Mit `--no-default-features` fielen auf Linux `chrono` + `iana-time-zone` weg (+2 statt +4). Preis:
`trash` kann dann keinen `DeletionDate`-Zeitstempel in die `.trashinfo`-Dateien schreiben. Das ist
funktional relevant, nicht bloß kosmetisch. **[Vermutung]** Für DioNotes wäre der Verzicht auf zwei
Pakete den Verlust der Zeitstempel nicht wert.
### [Vermutung] Der einzige echte Hebel
Ein künftiges `trash`-Release, das auf `windows` ≥ 0.61 aktualisiert, würde die gesamte
`windows`-Familie mit dem bestehenden dioxus/wry-Graphen deduplizieren. Der Windows-Delta fiele
dann von 6 auf **1** (nur `trash` selbst). Das ist nichts, worauf man warten sollte, aber es ist der
Grund, die Version im Blick zu behalten.
---
## 4. Netzlaufwerke und USB-Sticks — die Kernfrage
Das Ticket fragt: Fällt der Aufruf still auf endgültiges Löschen zurück, oder schlägt er fehl?
**Die Antwort hat zwei Schichten, und sie sind unabhängig voneinander.**
### 4.1 [belegt] Schicht 1: `trash 5.2.6` schlägt auf Netzlaufwerken fehl — wegen eines Pfad-Bugs
Gemessen, Wegwerf-Programm gegen `trash 5.2.6`, Datei auf `Z:\` (SMB-Share):
```
=== C: Netzlaufwerk SMB, klein | dir=Z:\ | 1024 bytes ===
angelegt: Z:\dionotes-trashprobe-net-small.md
trash::delete -> Err(Os { code: -2147024894,
description: "windows error: Das System kann die angegebene Datei nicht finden. (0x80070002)" })
Dauer: 31.0407ms
Datei existiert noch? true
Papierkorb-Treffer: vorher=0 nachher=0 => IM PAPIERKORB: false
```
Zum Vergleich, dieselbe Operation lokal auf NTFS:
```
=== A: lokal NTFS, klein | dir=C:\…\Temp | 1024 bytes ===
trash::delete -> Ok(())
Dauer: 740.7595ms
Datei existiert noch? false
Papierkorb-Treffer: vorher=0 nachher=1 => IM PAPIERKORB: true
```
**Die Datei auf dem Netzlaufwerk bleibt liegen. Es geht nichts verloren, aber es passiert auch
nichts.** Das reproduziert exakt den seit 2022-08-07 offenen Bug
[trash-rs#55 „Unable to delete file on Windows shared folder"](https://github.com/Byron/trash-rs/issues/55)
(Label `help wanted`, bis heute ungelöst).
**Die Ursache, aus dem Quellcode und nachgemessen:**
`trash` kanonisiert jeden Pfad vor dem Löschen mit `std::fs::canonicalize`:
```rust
let canonical_parent = parent.canonicalize()
.map_err(|_| Error::CanonicalizePath { original: parent.to_owned() })?;
```
→ `trash-5.2.6/src/lib.rs`, `fn canonicalize_paths`
`windows.rs` schneidet danach stumpf die ersten vier UTF-16-Zeichen `\\?\` ab:
```rust
let path_prefix = ['\\' as u16, '\\' as u16, '?' as u16, '\\' as u16];
let wide_path_container = to_wide_path(full_path);
let wide_path_slice = if wide_path_container.starts_with(&path_prefix) {
&wide_path_container[path_prefix.len()..]
} else {
&wide_path_container[0..]
};
```
→ `trash-5.2.6/src/windows.rs`
Das geht für lokale Pfade gut und für UNC-Pfade schief. Gemessen:
```
### canonicalize-Verhalten fuer lokal C:
canonicalize() = "\\?\C:\Users\marvin.dietermann\AppData\Local\Temp"
nach dem Prefix-Stripping = "C:\Users\marvin.dietermann\AppData\Local\Temp" <- gültig
### canonicalize-Verhalten fuer Netzlaufwerk Z:
canonicalize() = "\\?\UNC\la-nas-01.ad.datawin.de\Scan"
nach dem Prefix-Stripping = "UNC\la-nas-01.ad.datawin.de\Scan" <- Unsinn
### canonicalize-Verhalten fuer UNC-Pfad \\la-nas-01.ad.datawin.de\Scan
canonicalize() = "\\?\UNC\la-nas-01.ad.datawin.de\Scan"
nach dem Prefix-Stripping = "UNC\la-nas-01.ad.datawin.de\Scan" <- Unsinn
```
Aus `\\?\UNC\server\share` wird `UNC\server\share` — ein Pfad, den es nicht gibt.
`SHCreateItemFromParsingName` scheitert daran mit `0x80070002`. Korrekt wäre die Rückübersetzung
nach `\\server\share`.
**Betroffen sind beide Schreibweisen** — der gemappte Laufwerksbuchstabe `Z:\` genauso wie der
direkte UNC-Pfad, weil `canonicalize` beide auf dieselbe `\\?\UNC\…`-Form abbildet.
### 4.2 [belegt] Schicht 2: Windows selbst löscht auf Netzlaufwerken still und endgültig
Um Crate-Bug von Betriebssystemverhalten zu trennen, wurde `IFileOperation` **direkt** aufgerufen —
mit exakt denselben Flags wie `trash` (`FOF_NO_UI | FOF_ALLOWUNDO | FOF_WANTNUKEWARNING`,
zusammen `0x00004654`), aber mit unverbogenem Pfad:
```
### IFileOperation direkt auf lokal C:
PerformOperations -> Ok(()) (Dauer 102.5266ms)
GetAnyOperationsAborted -> false
Datei existiert noch? false
Papierkorb-Treffer: 1 <- im Papierkorb
### IFileOperation direkt auf Netzlaufwerk Z:
PerformOperations -> Ok(()) (Dauer 405.4515ms)
GetAnyOperationsAborted -> false
Datei existiert noch? false
Papierkorb-Treffer: 0 <- WEG. endgueltig.
### IFileOperation direkt auf UNC-Pfad \\la-nas-01.ad.datawin.de\Scan
PerformOperations -> Ok(()) (Dauer 299.5401ms)
GetAnyOperationsAborted -> false
Datei existiert noch? false
Papierkorb-Treffer: 0 <- WEG. endgueltig.
```
**Das ist die eigentliche Warnung des Tickets.** Auf dem Netzlaufwerk wird die Datei endgültig
gelöscht, und die API meldet **denselben Erfolg** wie beim Verschieben in den Papierkorb:
`Ok(())` und `GetAnyOperationsAborted() == false`. **Am Rückgabewert ist der Unterschied nicht
erkennbar.**
Bemerkenswert: **`FOF_WANTNUKEWARNING` hat keinen Dialog ausgelöst.** Microsoft dokumentiert das
Flag als „Send a warning if a file or folder is being destroyed during a delete operation rather
than recycled. This flag partially overrides **FOF_NOCONFIRMATION**."
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperation-setoperationflags>
**[Vermutung]** Die Warnung unterbleibt, weil `trash` zusätzlich `FOF_SILENT` und `FOF_NOERRORUI`
setzt: `FOF_NO_UI` ist in der `windows`-Crate als `1556u32` = `0x614` definiert, also
`FOF_SILENT (0x4) | FOF_NOCONFIRMATION (0x10) | FOF_NOCONFIRMMKDIR (0x200) | FOF_NOERRORUI (0x400)`.
→ `windows-0.56.0/src/Windows/Win32/UI/Shell/mod.rs`, `pub const FOF_NO_UI`
Das „partially overrides" bezieht sich laut Doku nur auf `FOF_NOCONFIRMATION`, nicht auf
`FOF_SILENT`/`FOF_NOERRORUI`. Belegt ist jedenfalls das Ergebnis: **kein Dialog, keine Rückfrage,
Datei weg.**
### 4.3 USB-Sticks und andere Dateisysteme — [nicht belastbar beantwortet]
**Das konnte nicht gemessen werden.** Auf dem Testrechner sind zwar zwei Wechseldatenträger
(`D:`, `E:`) angemeldet, beide sind aber leer (`FileSystemType = Unknown`, `SizeRemaining = 0`) —
offensichtlich Kartenleser ohne Medium. Ohne echten FAT32-/exFAT-Stick lässt sich der Fall nicht
verifizieren.
**[Vermutung]**, ausdrücklich als solche gekennzeichnet: Lokale Wechseldatenträger verhalten sich
anders als Netzlaufwerke; Windows legt auf ihnen üblicherweise sehr wohl einen `$RECYCLE.BIN`-
bzw. `RECYCLER`-Ordner an. Der kritische Fall ist damit **[Vermutung]** primär das Netzlaufwerk.
Verlassen sollte man sich darauf nicht — die in Abschnitt 5 beschriebene Erkennung deckt beide
Fälle ab, ohne dass man vorher wissen muss, welcher vorliegt. **Vor der Implementierung sollte
dieser Fall mit einem echten USB-Stick nachgemessen werden.**
Ebenfalls **[Vermutung]**: Auch auf lokalem NTFS kann der Papierkorb fehlen — er lässt sich pro
Volume in den Papierkorb-Eigenschaften deaktivieren („Dateien sofort löschen"), und Dateien
oberhalb der konfigurierten Maximalgröße gehen ebenfalls direkt verloren. Eine Prüfung, die nur
auf den Laufwerkstyp schaut, greift deshalb zu kurz.
---
## 5. Wie erfährt das Programm, welcher Fall eingetreten ist?
### [belegt] Über den Rückgabewert von `trash`: gar nicht
`trash::delete` liefert `Result<(), Error>`. Die `Error`-Varianten sind `Unknown`, `Os`,
`FileSystem` (nur FreeDesktop), `TargetedRoot`, `CouldNotAccess`, `CanonicalizePath`,
`ConvertOsString`, `RestoreCollision`, `RestoreTwins`.
→ <https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
**Es gibt keine Variante und kein Erfolgs-Detail, das „endgültig gelöscht statt Papierkorb"
ausdrückt.** Wie Abschnitt 4.2 zeigt, kommt in diesem Fall schlicht `Ok(())` zurück.
Die Crate wertet lediglich `GetAnyOperationsAborted()` aus, und das war im Netzlaufwerk-Fall
`false`:
```rust
pfo.PerformOperations()?;
if pfo.GetAnyOperationsAborted()?.as_bool() {
// TODO: return the reason why the operation was aborted.
return Err(Error::Unknown { description: "Some operations were aborted".into() });
}
```
→ `trash-5.2.6/src/windows.rs`
### [belegt] Über `IFileOperationProgressSink`: zuverlässig — aber `trash` reicht ihn nicht durch
Microsoft dokumentiert für `IFileOperationProgressSink::PostDeleteItem` den Parameter
`psiNewlyCreated` wörtlich so:
> „A pointer to an **IShellItem** that specifies the deleted item, now in the Recycle Bin.
> **If the item was fully deleted, this value is NULL.**"
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
Genau das wurde nachgemessen — ein eigener Sink, angehängt an `DeleteItem`:
```
### lokal C:
[sink] PreDeleteItem dwFlags=0x00000282
[sink] PostDeleteItem dwFlags=0x00000282 hrDelete=0x00270008 psiNewlyCreated=Some
==> IM PAPIERKORB: true
neuerPfad=Some("C:\$Recycle.Bin\S-1-5-21-…-1668\$R1V9IFD.md")
### Netzlaufwerk Z:
[sink] PreDeleteItem dwFlags=0x00000202
[sink] PostDeleteItem dwFlags=0x00000202 hrDelete=0x00270008 psiNewlyCreated=NULL
==> IM PAPIERKORB: false
neuerPfad=None
```
**Zwei verwertbare Signale, beide belegt:**
1. **`psiNewlyCreated` in `PostDeleteItem`** — `Some` ⇒ im Papierkorb (inklusive konkretem Pfad im
`$Recycle.Bin`), `NULL` ⇒ endgültig gelöscht. Das ist die verlässliche Nachher-Auskunft.
`hrDelete` taugt **nicht** dafür: es war in beiden Fällen identisch `0x00270008`.
2. **`dwFlags` schon in `PreDeleteItem`, also *vor* dem Löschen.** Die Differenz zwischen `0x282`
und `0x202` ist genau `0x80` — und das ist `TSF_DELETE_RECYCLE_IF_POSSIBLE`:
```
pub const TSF_DELETE_RECYCLE_IF_POSSIBLE: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(128i32);
pub const TSF_COPY_LOCALIZED_NAME: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(512i32);
pub const TSF_OVERWRITE_EXIST: _TRANSFER_SOURCE_FLAGS = _TRANSFER_SOURCE_FLAGS(2i32);
```
→ `windows-0.56.0/src/Windows/Win32/UI/Shell/mod.rs`
Auf `C:` ist das Bit gesetzt, auf `Z:` fehlt es. **Windows sagt also im `PreDeleteItem`-Callback
von sich aus, ob es recyceln wird** — bevor irgendetwas passiert ist.
Und man kann an dieser Stelle abbrechen: Microsoft dokumentiert für die Sink-Rückgabewerte
„Returns S_OK if successful, or an error value otherwise. In the case of an error value, all
subsequent operations pending from the call to IFileOperation are canceled."
→ <https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
**[Vermutung]** Damit ließe sich aus `PreDeleteItem` heraus vetoen und stattdessen erst der
Nutzer fragen. Der Abbruch aus `PreDeleteItem` wurde **nicht** gemessen.
**Der Haken:** `trash` übergibt den Sink-Parameter hart als `None`:
```rust
pfo.DeleteItem(&shi, None)?;
```
→ `trash-5.2.6/src/windows.rs`
Über die Crate-API ist an diese Information **nicht** heranzukommen.
### [Vermutung] Konsequenz für DioNotes
Drei gangbare Wege, in aufsteigender Sauberkeit:
1. **Billig und grob:** vor dem Löschen prüfen, ob der Workspace-Pfad auf einem Netzlaufwerk liegt
(Laufwerkstyp `DRIVE_REMOTE` bzw. UNC-Pfad), und dann eine Rückfrage stellen. Erkennt den
Hauptfall, aber nicht den deaktivierten Papierkorb auf lokalem NTFS.
2. **Nachher-Prüfung:** nach `trash::delete` per `trash::os_limited::list()` nachsehen, ob der
Eintrag im Papierkorb aufgetaucht ist. Funktioniert (so wurden die Messungen oben verifiziert),
ist aber langsam und rennt gegen Race Conditions.
3. **Sauber:** `IFileOperation` mit eigenem Sink selbst aufrufen, statt `trash` zu benutzen — dann
bekommt man `TSF_DELETE_RECYCLE_IF_POSSIBLE` vorab und `psiNewlyCreated` hinterher, und der
UNC-Bug aus 4.1 entfällt ebenfalls, weil man `canonicalize` nicht braucht. Kostet dafür eine
eigene Windows-Implementierung und hilft auf Linux/macOS nicht.
Da `trash 5.2.6` auf Netzlaufwerken ohnehin nicht funktioniert, ist die Entscheidung ohne eine
bewusste Behandlung dieses Falls **nicht** vollständig — deshalb ist dieses Ticket die
Voraussetzung für die Lösch-Implementierung, nicht bloß eine Fußnote.
---
## 6. Blockierend oder asynchron? Wie lange dauert es?
### [belegt] Blockierend
`trash::delete` ist eine gewöhnliche synchrone Funktion; es gibt keine `async`-Variante in der
öffentlichen API. Der Windows-Pfad ruft `pfo.PerformOperations()` direkt im aufrufenden Thread auf,
ohne Thread zu spawnen.
→ `trash-5.2.6/src/windows.rs`, <https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
### [belegt] Gemessene Dauer
| Fall | Größe | Dauer |
|---|---|---|
| lokal NTFS, erster Aufruf im Prozess | 1 KiB | **740,8 ms** |
| lokal NTFS, zweiter Aufruf | **512 MiB** | **26,7 ms** |
| `IFileOperation` direkt, lokal | 5 B | 102,5 ms |
| `IFileOperation` direkt, Netzlaufwerk `Z:` | 5 B | 405,5 ms |
| `IFileOperation` direkt, UNC-Pfad | 5 B | 299,5 ms |
**Die Dateigröße spielt praktisch keine Rolle:** 512 MiB wurden in 26,7 ms erledigt — schneller
als die 1-KiB-Datei davor. Das Verschieben in den Papierkorb ist ein Rename **innerhalb desselben
Volumes**, es werden keine Daten kopiert.
Was kostet, ist der **erste** Aufruf im Prozess: die COM-Initialisierung
(`CoInitializeEx` + `CoCreateInstance` der `FileOperation`-Klasse) schlägt mit gut 0,7 s zu Buche.
`trash` initialisiert COM `thread_local` und einmalig.
→ `trash-5.2.6/src/windows.rs`, `thread_local! { static CO_INITIALIZER … }`
**[Vermutung]** Für DioNotes heißt das: der Aufruf gehört nicht in den UI-Thread. Ein
`spawn_blocking` bzw. der `tokio`-Threadpool genügt völlig; eine Fortschrittsanzeige braucht es
für einzelne `.md`-Dateien nicht. Netzlaufwerke sind mit ~0,3–0,4 s deutlich träger als lokale
Platten, aber immer noch weit unter jeder Schmerzgrenze.
**Achtung [belegt]:** `trash` initialisiert COM standardmäßig mit `COINIT_APARTMENTTHREADED`
(Feature `coinit_apartmentthreaded`, Teil der Defaults) und **panickt**, wenn `CoInitializeEx`
fehlschlägt:
```rust
panic!("Call to CoInitializeEx failed. HRESULT: {:?}. Consider using `trash` with the feature `coinit_multithreaded`", hr);
```
→ `trash-5.2.6/src/windows.rs`
**[Vermutung]** Ruft man `trash` aus einem Tokio-Worker-Thread auf, der bereits als MTA
initialisiert wurde, könnte das zuschlagen; dann ist `default-features = false` plus
`features = ["coinit_multithreaded"]` der Ausweg. Das wurde **nicht** gemessen.
---
## 7. macOS und Linux
### [belegt] macOS
`trash` bietet zwei Verfahren, wählbar über `TrashContext`:
- **`DeleteMethod::Finder` (Default)** — steuert den Finder per AppleScript.
- **`DeleteMethod::NsFileManager`** — ruft `trashItemAtURL` auf `NSFileManager` auf. Laut den
Doc-Kommentaren: „Somewhat faster than the `Finder` method", „Does *not* require additional
permissions", „Does *not* produce the sound that Finder usually makes", aber „Does *not* show the
'Put Back' option on some systems … This is a macOS bug."
→ `trash-5.2.6/src/macos/mod.rs`
Fehler von `trashItemAtURL` werden als `Error::Unknown` weitergereicht, nicht verschluckt.
Abhängigkeiten: `objc2`, `objc2-foundation`, `percent-encoding` — alle drei stehen **bereits im
`Cargo.lock`** (0.6.4 / 0.3.2 / 2.3.2), auf macOS wäre `trash` also nahezu gratis.
### [belegt] Linux / FreeDesktop
Implementiert die FreeDesktop-Trash-Spezifikation v1.0. Die Implementierung ist reines Rust, ohne
GTK/dbus. Wichtig für die Netzlaufwerk-Frage: Der Code **legt die Trash-Ordner bei Bedarf an** und
fällt bei einem geräteübergreifenden Verschieben explizit auf Kopieren-und-Löschen zurück:
```rust
let Err(e) = std::fs::rename(src, dst) else { return Ok(()) };
let needs_cross_device_copy = e.kind() == ErrorKind::CrossesDevices;
if !needs_cross_device_copy { return Err((src.to_owned(), e)); }
debug!("Cross-device move detected, falling back to copy+delete for {:?}", src);
```
→ `trash-5.2.6/src/freedesktop.rs`
**[Vermutung]** Damit ist Linux der unproblematischere Fall: Entweder die Datei landet in einem
Trash-Verzeichnis, oder es kommt ein `Error::FileSystem` zurück. Ein stilles endgültiges Löschen
wie unter Windows ist im Code nicht angelegt. Nicht gemessen — es stand keine Linux-Maschine zur
Verfügung.
---
## 8. Empfehlung
**[Vermutung]** — das ist eine Einschätzung, keine belegte Aussage:
1. **`trash 5.2.6` nehmen**, mit Default-Features. Die +6 Pakete sind der Preis; verhandeln lässt
sich daran nichts.
2. **Den Netzlaufwerk-Fall vor der ersten Löschfunktion klären, nicht danach.** `trash` schlägt
dort heute fehl (4.1) — die App muss diesen Fehler ohnehin abfangen und dem Nutzer erklären,
sonst wirkt das Löschen auf Netzlaufwerken schlicht kaputt.
3. **Eine Rückfrage vor dem Löschen ist nötig**, sobald der Workspace nicht auf einer lokalen
Platte liegt. Der Nutzer muss wissen, ob seine Notiz wiederherstellbar ist — und aus dem
Rückgabewert von `trash` erfährt er es nicht (5).
4. **Nicht im UI-Thread aufrufen** (6).
5. **Die `trash`-Version im Blick behalten**: sobald sie auf `windows` ≥ 0.61 geht, halbieren sich
die Kosten, und `trash-rs#55` könnte bei der Gelegenheit mit erledigt werden.
---
## 9. Reproduktion
Die Messungen stammen aus drei Wegwerf-Programmen, die **außerhalb des Repos** gebaut wurden
(Scratchpad, nicht eingecheckt):
- `main.rs` — `trash::delete` gegen lokal / Netzlaufwerk, mit Papierkorb-Gegenprobe über
`trash::os_limited::list()`
- `raw.rs` — `IFileOperation` direkt, mit denselben Flags, ohne `canonicalize`; zeigt zusätzlich
das Prefix-Stripping-Verhalten
- `sink.rs` — eigener `IFileOperationProgressSink` (`#[implement]` aus `windows 0.56`), protokolliert
`PreDeleteItem`/`PostDeleteItem`
Alle angelegten Testdateien wurden wieder entfernt, die Papierkorb-Einträge mit
`trash::os_limited::purge_all` endgültig gelöscht. Auf `Z:` sind keine Rückstände verblieben.
**Im Repo wurde nichts an `Cargo.toml` oder `Cargo.lock` geändert** — die Dependency-Messung lief
über `cargo add` mit anschließendem `git checkout --`, verifiziert mit leerem
`git status --porcelain`.
---
## 10. Quellen
- crates.io-API zu `trash`: <https://crates.io/api/v1/crates/trash>
- API-Doku: <https://docs.rs/trash/latest/trash/>
- Quelltext `lib.rs` (Error-Enum, `canonicalize_paths`):
<https://docs.rs/trash/5.2.6/src/trash/lib.rs.html>
- Quelltext `windows.rs`, `freedesktop.rs`, `macos/mod.rs`, `Cargo.toml`, `README.md`,
`CHANGELOG.md`: aus dem Crate-Tarball
<https://static.crates.io/crates/trash/5.2.6/download>
(auf docs.rs **nicht** einsehbar — docs.rs baut auf Linux, `windows.rs` ist dort wegcfg-t)
- Repository (gepflegter Fork): <https://github.com/Byron/trash-rs>
- Offener Bug zum Netzlaufwerk-Fall: <https://github.com/Byron/trash-rs/issues/55>
- `IFileOperation::SetOperationFlags` (FOF_-/FOFX_-Flags, `FOF_WANTNUKEWARNING`):
<https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperation-setoperationflags>
- `IFileOperationProgressSink::PostDeleteItem` (`psiNewlyCreated` ist NULL bei endgültiger Löschung):
<https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifileoperationprogresssink-postdeleteitem>
- Konstanten `FOF_NO_UI`, `TSF_DELETE_RECYCLE_IF_POSSIBLE` u. a.: generierte Bindings in
`windows 0.56.0`, `src/Windows/Win32/UI/Shell/mod.rs`
- FreeDesktop Trash Specification v1.0:
<https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html>

18
package-lock.json generated Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "dionotes-client",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"tailwindcss": "^4.3.3"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"tailwindcss": "^4.3.3"
}
}

View File

@@ -1,11 +1,14 @@
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;
@@ -14,6 +17,7 @@ const FAVICON: Asset = asset!("/assets/favicon.ico");
// Linked individually rather than via @import in a single entry file: asset!()
// only emits files it is given by name, so an @import chain would 404 at runtime.
// Order matters — tokens, then baseline, then layout, then components.
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const TOKENS_CSS: Asset = asset!("/assets/css/tokens.css");
const BASE_CSS: Asset = asset!("/assets/css/base.css");
const LAYOUT_CSS: Asset = asset!("/assets/css/layout.css");
@@ -29,9 +33,15 @@ 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 }
// Tailwind first: its preflight lives in @layer base, our unlayered
// rules win the cascade regardless, but this keeps source order honest.
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: TOKENS_CSS }
document::Link { rel: "stylesheet", href: BASE_CSS }
document::Link { rel: "stylesheet", href: LAYOUT_CSS }

View File

@@ -1,5 +1,4 @@
use dioxus::prelude::*;
use strum::EnumMessage;
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
use crate::views::workspace_selector::selector::Selector;
@@ -9,28 +8,17 @@ 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, EnumMessage)]
#[derive(Debug, Clone, Routable, PartialEq)]
#[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 {}];
}

194
src/tree.rs Normal file
View File

@@ -0,0 +1,194 @@
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,58 +1,64 @@
use dioxus::prelude::*;
use dioxus_code::Theme;
use dioxus_code_editor::{CodeEditor, Language};
use strum::EnumMessage;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::components::custom_link::custom_link_component::CustomLink;
use crate::route::Route;
use crate::tree::{read_tree, TreeNode};
use crate::workspace::Workspace;
const HEADER_SVG: Asset = asset!("/assets/header.svg");
/// Home page
/// 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.
#[component]
pub fn Home() -> Element {
rsx! {
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" }
}
div { class: "empty-state",
span { class: "empty-state-title", "Keine Notiz geöffnet" }
span { "Wähle links eine Notiz aus." }
}
}
}
#[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: Datei-Leiste links, Editorfläche rechts.
/// Die Editor-Hülle: Notiz-Baum 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,
@@ -68,25 +74,51 @@ 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",
// 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"
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(),
"⟳"
}
}
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",
@@ -95,3 +127,61 @@ 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

@@ -11,6 +11,7 @@ use crate::workspace_list::WorkspaceList;
#[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();
@@ -50,18 +51,32 @@ pub fn Selector() -> Element {
)));
}
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) => 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}"))),
};
// 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) => 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}"))),
};

View File

@@ -7,6 +7,35 @@ 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;
@@ -101,6 +130,12 @@ 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 {
@@ -133,6 +168,10 @@ 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)?;
@@ -141,6 +180,14 @@ 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 })
}
}
@@ -179,6 +226,26 @@ 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");

1
tailwind.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";