15 Commits

Author SHA1 Message Date
5b4012a1e4 Merge pull request 'Willkommensnotiz fuer frisch angelegte Workspaces' (#15) from feat/willkommensnotiz into main
Reviewed-on: #15
2026-08-28 09:00:42 +00:00
cd70e331da Merge branch 'main' into feat/willkommensnotiz 2026-08-28 09:00:36 +00:00
5013f4bcc4 Merge pull request 'Notiz-Baum in der Datei-Leiste' (#14) from feat/notiz-baum into main
Reviewed-on: #14
2026-08-28 09:00:28 +00:00
9d9f2357ef Merge branch 'main' into feat/notiz-baum 2026-08-28 09:00:20 +00:00
cf5baebc6f Merge pull request 'Selector-UI: Liste, Zustandsabzeichen, nebenlaeufige Pruefung' (#13) from feat/selector-ui into main
Reviewed-on: #13
2026-08-28 09:00:05 +00:00
2a27749d3d Merge branch 'main' into feat/selector-ui 2026-08-28 08:59:56 +00: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
dc68e70f02 Merge pull request 'Workspace-Liste laden und speichern' (#11) from feat/workspace-liste into main
Reviewed-on: #11
2026-08-28 07:58:31 +00:00
28ed8240f9 Merge pull request 'Ordner-Dialog und Anlegen-Flow' (#12) from feat/ordner-dialog into feat/workspace-liste
Reviewed-on: #12
2026-08-28 07:58:22 +00:00
89566627b5 Merge pull request 'Tailwind und die npm-Toolchain entfernen' (#9) from chore/tailwind-entfernen into main
Reviewed-on: #9
2026-08-28 07:29:27 +00:00
c2247faed4 Merge pull request 'Branch- und PR-Workflow dokumentieren' (#10) from docs/branch-workflow into main
Reviewed-on: #10
2026-08-28 07:29:15 +00:00
50ffab0252 docs: Branch- und PR-Workflow im Tracker-Doc festhalten
Ab jetzt laufen Aenderungen ueber eigene Branches und Pull Requests statt
direkt auf main, damit die logische Trennung sichtbar bleibt. Das gehoert
neben die tea-Kommandos, sonst kennt es nur die Sitzung, in der es vereinbart
wurde.

Festgehalten ist auch die Faustregel fuer den Zuschnitt: Wenn die
Commit-Nachricht "und ausserdem" braucht, sind es zwei Branches. Und dass das
Mergen dem Menschen gehoert, nicht der Umsetzung.
2026-08-28 09:24:00 +02:00
ea902b9d7e chore: generierte assets/tailwind.css aus dem Repo nehmen
Nachtrag zum Tailwind-Rauswurf: dx legt die Datei bei jedem vollen Build neu
an - auch nachdem die Eingabedatei geloescht wurde und main.rs sie nicht mehr
verlinkt. Offenbar aus einem eigenen Cache; ein Schalter dagegen war nicht zu
finden.

Statt dagegen anzukaempfen wandert sie in die .gitignore. Sie ist ein lokales
Nebenprodukt ohne Leser, und so taucht sie nicht bei jedem Build als
vermeintliche Aenderung auf.
2026-08-28 09:21:02 +02:00
a7d0ac15ee chore: Tailwind und die npm-Toolchain entfernen
Die App hat nie eine Tailwind-Utility benutzt - saemtliche Klassen im Code
sind eigene (btn, files-bar, empty-state, ...), und den Reset liefert
base.css selbst. Damit standen package.json, node_modules und ein
Build-Schritt fuer eine Stylesheet-Datei, die nichts beigetragen hat.

Aufgefallen ist es, weil der letzte Commit ungefragt zwoelf Zeilen in
assets/tailwind.css mitbrachte: Der Generator scannt auch docs/ und erzeugte
Utilities fuer Woerter, die er in Prosa fand.

Die npm-Abhaengigkeit war ohnehin wirkungslos - Dioxus 0.7 bringt Tailwind
selbst mit (erzeugt wurde 4.1.5, nicht die in package.json deklarierte 4.3.3).
Ohne die Eingabedatei tailwind.css im Projektwurzelverzeichnis erzeugt dx
nichts mehr; verifiziert durch einen vollstaendigen Build.

Das --dn-Praefix der Tokens bleibt: Es war urspruenglich Schutz vor Tailwinds
eigenen :root-Variablen, taugt aber weiterhin als Namensraum.

README auf den tatsaechlichen Projektstand gebracht - er beschrieb noch die
Dioxus-Vorlage mit einer einzelnen main.rs.
2026-08-28 09:19:36 +02:00
19 changed files with 506 additions and 444 deletions

7
.gitignore vendored
View File

@@ -9,8 +9,9 @@
# Dioxus build output # Dioxus build output
/dist /dist
# Node (tailwind toolchain)
node_modules/
# IDE # IDE
/.idea /.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

22
Cargo.lock generated
View File

@@ -2333,7 +2333,6 @@ dependencies = [
"rfd", "rfd",
"ron", "ron",
"serde", "serde",
"strum",
"tokio", "tokio",
] ]
@@ -6414,27 +6413,6 @@ dependencies = [
"quote", "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]] [[package]]
name = "subsecond" name = "subsecond"
version = "0.7.10" 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"] } rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
ron = "0.12.2" ron = "0.12.2"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] }
tokio = { version = "1.53.1", features = ["rt"] } tokio = { version = "1.53.1", features = ["rt"] }
[features] [features]

View File

@@ -1,51 +1,32 @@
# Development # DioNotes Client
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets. Lokale Markdown-Notizen-App auf Basis von [Dioxus 0.7](https://dioxuslabs.com/learn/0.7), Desktop.
``` ## 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 ```bash
npx @tailwindcss/cli -i ./input.css -o ./assets/tailwind.css --watch dx serve # Desktop-App starten (Standard-Plattform)
dx serve --platform web # im Browser
cargo test # Tests
``` ```
### Serving Your App ## Aufbau
Run the following command in the root of your project to start developing with the default platform: ```
assets/css/ Design-System: tokens → base → layout → components,
```bash in dieser Reihenfolge in main.rs verlinkt
dx serve 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
``` ```
To run for a different platform, use the `--platform platform` flag. E.g. Es gibt **kein** Tailwind und keine npm-Toolchain — das Styling liegt vollständig in
```bash `assets/css/` und arbeitet mit den `--dn-*`-Tokens.
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 { .file-selector {
position: relative; position: relative;
display: block; display: flex;
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1) var(--dn-space-3); 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); border-radius: var(--dn-radius-sm);
color: var(--dn-fg-muted); color: var(--dn-fg-muted);
font-family: var(--dn-font-code); font-family: var(--dn-font-code);
@@ -23,6 +33,19 @@
background-color var(--dn-dur-fast) var(--dn-ease); 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 { .file-selector::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -88,6 +111,12 @@
border-color: transparent; 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) { .btn-ghost:hover:not(:disabled) {
background: var(--dn-bg-raised); background: var(--dn-bg-raised);
border-color: transparent; border-color: transparent;

View File

@@ -80,6 +80,22 @@
overscroll-behavior: contain; 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 { .editor-container {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,275 +0,0 @@
/*! 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,6 +61,27 @@ Create a Gitea issue.
Run `tea issues <index>` plus `tea comments ls <index>`. 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 ## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.

18
package-lock.json generated
View File

@@ -1,18 +0,0 @@
{
"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"
}
}
}

View File

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

View File

@@ -1,11 +1,14 @@
mod components; mod components;
mod route; mod route;
mod tree;
mod views; mod views;
mod workspace; mod workspace;
mod workspace_list; mod workspace_list;
use dioxus::prelude::*; use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route; use crate::route::Route;
use crate::workspace::Workspace; use crate::workspace::Workspace;
@@ -14,7 +17,6 @@ const FAVICON: Asset = asset!("/assets/favicon.ico");
// Linked individually rather than via @import in a single entry file: asset!() // 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. // 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. // 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 TOKENS_CSS: Asset = asset!("/assets/css/tokens.css");
const BASE_CSS: Asset = asset!("/assets/css/base.css"); const BASE_CSS: Asset = asset!("/assets/css/base.css");
const LAYOUT_CSS: Asset = asset!("/assets/css/layout.css"); const LAYOUT_CSS: Asset = asset!("/assets/css/layout.css");
@@ -30,12 +32,12 @@ fn App() -> Element {
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem // gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
// Outlet, durch das sich keine Props durchreichen lassen. // Outlet, durch das sich keine Props durchreichen lassen.
use_context_provider(|| Signal::new(None::<Workspace>)); use_context_provider(|| Signal::new(None::<Workspace>));
// Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich
// ändert, ohne dass der Workspace wechselt.
use_context_provider(|| Signal::new(None::<PathBuf>));
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }
// 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: TOKENS_CSS }
document::Link { rel: "stylesheet", href: BASE_CSS } document::Link { rel: "stylesheet", href: BASE_CSS }
document::Link { rel: "stylesheet", href: LAYOUT_CSS } document::Link { rel: "stylesheet", href: LAYOUT_CSS }

View File

@@ -1,5 +1,4 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use strum::EnumMessage;
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home}; use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
use crate::views::workspace_selector::selector::Selector; 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 /// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine /// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
/// URL zu sehen bekommen, die er nicht kennt. /// URL zu sehen bekommen, die er nicht kennt.
#[derive(Debug, Clone, Routable, PartialEq, EnumMessage)] #[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip] #[rustfmt::skip]
pub enum Route { pub enum Route {
#[nest("/workspace")] #[nest("/workspace")]
#[layout(EditorShell)] #[layout(EditorShell)]
#[route("/")] #[route("/")]
#[strum(message = "Home")]
Home {}, Home {},
#[route("/editor")] #[route("/editor")]
#[strum(message = "Editor")]
Editor {}, Editor {},
#[end_layout] #[end_layout]
#[end_nest] #[end_nest]
#[route("/")] #[route("/")]
Selector {}, 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::prelude::*;
use dioxus_code::Theme; use dioxus_code::Theme;
use dioxus_code_editor::{CodeEditor, Language}; 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::route::Route;
use crate::tree::{read_tree, TreeNode};
use crate::workspace::Workspace; use crate::workspace::Workspace;
const HEADER_SVG: Asset = asset!("/assets/header.svg"); /// Was rechts steht, solange keine Notiz gewählt ist.
///
/// Home page /// 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] #[component]
pub fn Home() -> Element { pub fn Home() -> Element {
rsx! { rsx! {
Hero {} div { class: "empty-state",
} span { class: "empty-state-title", "Keine Notiz geöffnet" }
} span { "Wähle links eine Notiz aus." }
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
#[component]
pub fn Hero() -> Element {
rsx! {
div {
id: "hero",
img { src: HEADER_SVG, id: "header" }
div { id: "links",
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
}
} }
} }
} }
#[component] #[component]
pub fn Editor() -> Element { pub fn Editor() -> Element {
let note = use_context::<Signal<Option<PathBuf>>>();
let mut source = use_signal(String::new); let mut source = use_signal(String::new);
// Beim Wechsel der Notiz den Inhalt nachladen — auf einem eigenen Thread,
// damit eine große Datei auf langsamem Laufwerk nicht die Oberfläche hält.
use_effect(move || {
let path = note.read().clone();
spawn(async move {
let text = match path {
Some(path) => tokio::task::spawn_blocking(move || {
std::fs::read_to_string(path).unwrap_or_default()
})
.await
.unwrap_or_default(),
None => String::new(),
};
source.set(text);
});
});
rsx! { rsx! {
CodeEditor { CodeEditor {
class: "editor-class", class: "editor-class",
value: source(), value: source(),
language: Language::Markdown, language: Language::Markdown,
theme: Theme::CATPPUCCIN_MOCHA, theme: Theme::CATPPUCCIN_MOCHA,
// TODO: Änderungen landen bisher nirgends. Speichern, Autosave und
// der Umgang mit ungesicherten Änderungen gehören zum
// Notiz-Handling und damit in den nächsten Effort.
oninput: move |value| source.set(value), oninput: move |value| source.set(value),
} }
} }
} }
/// 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 /// 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, /// 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() { if workspace.read().is_none() {
return rsx! {}; return rsx! {};
} }
let nodes = tree.read().clone().unwrap_or_default();
rsx! { rsx! {
div { div {
class: "layout-container", class: "layout-container",
div { div {
class: "files-bar", class: "files-bar",
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen div { class: "files-bar-head",
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen. span { class: "field-label", "Notizen" }
for entry in Route::EDITOR_TABS { // Kein Filewatcher — externe Änderungen holt man sich hier.
CustomLink { button {
key: "{entry}", class: "btn btn-ghost btn-icon",
label: entry.get_message().unwrap_or_default(), title: "Neu einlesen",
href: entry.clone(), onclick: move |_| tree.restart(),
css: "file-selector" "⟳"
} }
} }
if nodes.is_empty() {
div { class: "files-bar-empty", "Keine Notizen in diesem Workspace." }
} else {
TreeLevel { nodes: nodes.clone(), expanded, depth: 0 }
}
} }
div { div {
class: "editor-container", 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] #[component]
pub fn Selector() -> Element { pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>(); let mut workspace = use_context::<Signal<Option<Workspace>>>();
let mut note = use_context::<Signal<Option<PathBuf>>>();
let mut list = use_signal(WorkspaceList::load); let mut list = use_signal(WorkspaceList::load);
let navigator = use_navigator(); let navigator = use_navigator();
@@ -50,18 +51,32 @@ pub fn Selector() -> Element {
))); )));
} }
workspace.set(Some(opened)); workspace.set(Some(opened));
// Mit Notiz direkt in den Editor, sonst auf den leeren Stand.
if note.read().is_some() {
navigator.push(Route::Editor {});
} else {
navigator.push(Route::Home {}); navigator.push(Route::Home {});
}
}; };
let mut create_at = move |root: PathBuf| match Workspace::create(root) { let mut create_at = move |root: PathBuf| match Workspace::create(root) {
Ok(opened) => accept(opened), Ok(opened) => {
// Ein frischer Workspace bringt eine Kurzanleitung mit; die wird
// gleich angezeigt, statt den Nutzer vor eine leere Fläche zu setzen.
note.set(opened.welcome_note());
accept(opened);
}
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))), Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
}; };
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt" // Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
// ohne eigenen Knopf abgedeckt. // ohne eigenen Knopf abgedeckt.
let mut open_entry = move |root: PathBuf| match Workspace::open(root) { let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
Ok(opened) => accept(opened), Ok(opened) => {
// Ein bestehender Workspace öffnet ohne vorgewählte Notiz.
note.set(None);
accept(opened);
}
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))), Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
}; };

View File

@@ -7,6 +7,35 @@ use std::path::{Path, PathBuf};
const MARKER_DIR: &str = ".dionotes"; const MARKER_DIR: &str = ".dionotes";
const MARKER_FILE: &str = "workspace.ron"; const MARKER_FILE: &str = "workspace.ron";
/// Kurzanleitung, die ein frisch angelegter Workspace mitbekommt.
pub const WELCOME_FILE: &str = "Willkommen.md";
const WELCOME_TEXT: &str = "# Willkommen bei DioNotes
Dieser Ordner ist jetzt ein **Workspace** — eine Sammlung von Notizen, die als
gewöhnliche Markdown-Dateien nebeneinander liegen. Du kannst sie jederzeit mit
jedem anderen Programm öffnen, sichern oder synchronisieren.
## Wie es aufgebaut ist
- Jede Notiz ist eine `.md`-Datei in diesem Ordner.
- **Unterordner funktionieren.** Sie erscheinen links als aufklappbarer Baum.
- Der versteckte Ordner `.dionotes` macht diesen Ordner zum Workspace. Dort
landen später auch die Einstellungen. Lösche ihn nicht — ohne ihn erkennt
DioNotes den Ordner nicht wieder.
- Alles, was mit einem Punkt beginnt, bleibt in der Liste unsichtbar. Ein
`.git`-Ordner stört also nicht.
## Gut zu wissen
DioNotes liest den Ordner beim Öffnen einmal ein. Änderst du etwas von außen —
etwa über einen Sync-Dienst —, hol dir den neuen Stand mit dem Knopf **⟳** über
der Notizliste.
Diese Datei ist eine ganz normale Notiz. Du kannst sie überschreiben oder
löschen, sobald du sie nicht mehr brauchst.
";
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen, /// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
/// die eine ältere App nicht bloß ignorieren kann. /// die eine ältere App nicht bloß ignorieren kann.
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;
@@ -101,6 +130,12 @@ impl Workspace {
root.join(MARKER_DIR).join(MARKER_FILE) root.join(MARKER_DIR).join(MARKER_FILE)
} }
/// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat.
pub fn welcome_note(&self) -> Option<PathBuf> {
let path = self.root.join(WELCOME_FILE);
path.is_file().then_some(path)
}
/// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt /// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist. /// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
pub fn has_marker(root: &Path) -> bool { pub fn has_marker(root: &Path) -> bool {
@@ -133,6 +168,10 @@ impl Workspace {
if !root.is_dir() { if !root.is_dir() {
return Err(OpenError::Unreachable); return Err(OpenError::Unreachable);
} }
// Zuerst prüfen: Sobald `.dionotes` angelegt ist, ist der Ordner
// nie mehr leer.
let was_empty = dir_is_empty(&root).unwrap_or(false);
let marker_path = Self::marker_path(&root); let marker_path = Self::marker_path(&root);
if let Some(dir) = marker_path.parent() { if let Some(dir) = marker_path.parent() {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
@@ -141,6 +180,14 @@ impl Workspace {
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default()) let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(&marker_path, text)?; fs::write(&marker_path, text)?;
// Nur in einen frischen Workspace. Wer einen Ordner voller Notizen
// importiert, will keine fremde Datei dazwischen — er kennt das
// Programm dann meist schon.
if was_empty {
let _ = fs::write(root.join(WELCOME_FILE), WELCOME_TEXT);
}
Ok(Self { root }) Ok(Self { root })
} }
} }
@@ -179,6 +226,26 @@ mod tests {
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created); assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
} }
#[test]
fn a_fresh_workspace_gets_a_welcome_note() {
let dir = scratch("welcome-fresh");
let ws = Workspace::create(dir.clone()).expect("anlegbar");
let note = ws.welcome_note().expect("Willkommensnotiz vorhanden");
assert!(note.ends_with("Willkommen.md"));
assert!(fs::read_to_string(&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] #[test]
fn create_leaves_existing_files_alone() { fn create_leaves_existing_files_alone() {
let dir = scratch("create-keeps-files"); let dir = scratch("create-keeps-files");

View File

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