Compare commits
7 Commits
91fd351d59
...
docs/einst
| Author | SHA1 | Date | |
|---|---|---|---|
| e5990b9da4 | |||
| 35dbb72cb6 | |||
| c8cf525ec5 | |||
| 7917fa08cc | |||
| 570c804d3f | |||
| 276bc67dbe | |||
| 37a7bab30f |
16
AGENTS.md
16
AGENTS.md
@@ -264,6 +264,22 @@ The initial UI rendered by the component on the client must be identical to the
|
|||||||
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
|
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
|
||||||
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
|
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
|
||||||
|
|
||||||
|
## Woran gerade gearbeitet wird
|
||||||
|
|
||||||
|
**Bevor du irgendetwas anderes tust:** Laufende Vorhaben liegen als *Wayfinder-Map* im
|
||||||
|
Issue-Tracker, nicht in diesem Repo. Die offene Map findest du mit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea issues ls --labels wayfinder:map --state open
|
||||||
|
```
|
||||||
|
|
||||||
|
Lies ihren Beschreibungstext (Destination, Notes, Decisions so far) **und ihren letzten
|
||||||
|
Kommentar** — dort steht der Arbeitsstand samt Einstiegspunkt. Erst danach lohnt der Blick
|
||||||
|
in den Code.
|
||||||
|
|
||||||
|
Ist keine Map offen, gibt es kein laufendes Planungsvorhaben; dann gilt die normale
|
||||||
|
Auftragslage aus dem Gespräch.
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
### Issue tracker
|
### Issue tracker
|
||||||
|
|||||||
47
Cargo.lock
generated
47
Cargo.lock
generated
@@ -2329,7 +2329,11 @@ dependencies = [
|
|||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-code",
|
"dioxus-code",
|
||||||
"dioxus-code-editor",
|
"dioxus-code-editor",
|
||||||
"strum",
|
"dirs",
|
||||||
|
"rfd",
|
||||||
|
"ron",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5886,6 +5890,20 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ron"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"typeid",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6395,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"
|
||||||
@@ -7012,6 +7009,12 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ edition = "2021"
|
|||||||
dioxus = { version = "0.7.1", features = ["router"] }
|
dioxus = { version = "0.7.1", features = ["router"] }
|
||||||
dioxus-code = "0.1.2"
|
dioxus-code = "0.1.2"
|
||||||
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
|
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
|
||||||
strum = { version = "0.28.0", features = ["derive"] }
|
dirs = "6.0.0"
|
||||||
|
# Dieselbe Konfiguration, die dioxus-desktop ohnehin zieht. Mit den
|
||||||
|
# Default-Features kaemen neun Wayland-Pakete dazu, die xdg-portal nicht
|
||||||
|
# braucht — der Dialog laeuft unter Linux ueber das Portal.
|
||||||
|
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||||
|
ron = "0.12.2"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
tokio = { version = "1.53.1", features = ["rt"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["desktop"]
|
default = ["desktop"]
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -167,6 +196,142 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Workspace-Listeneintrag ---------------------------------------------------
|
||||||
|
Ordnername, darunter der Pfad, rechts bei Bedarf ein Statusabzeichen. Ein
|
||||||
|
defekter Eintrag wird gedämpft, bleibt aber anklickbar: Ein Klick versucht
|
||||||
|
es erneut, womit „Stick wieder eingesteckt" ohne eigenen Knopf abgedeckt ist.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.ws-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--dn-space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--dn-space-3);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--dn-radius);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--dn-dur-fast) var(--dn-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item:hover {
|
||||||
|
background: var(--dn-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item-name {
|
||||||
|
color: var(--dn-fg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bei Überlänge vorne kürzen: Das Ende eines Pfades sagt mehr als sein Anfang. */
|
||||||
|
.ws-item-path {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--dn-fg-faint);
|
||||||
|
font-family: var(--dn-font-code);
|
||||||
|
font-size: var(--dn-text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item.is-broken .ws-item-name {
|
||||||
|
color: var(--dn-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-badge {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px var(--dn-space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--dn-text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drei Zustände, drei Farben — sie führen zu verschiedenen Handlungen:
|
||||||
|
Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. */
|
||||||
|
.ws-badge.unreachable {
|
||||||
|
background: color-mix(in oklab, var(--dn-warning) 18%, transparent);
|
||||||
|
color: var(--dn-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-badge.no-marker {
|
||||||
|
background: color-mix(in oklab, var(--dn-fg-faint) 22%, transparent);
|
||||||
|
color: var(--dn-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-badge.damaged {
|
||||||
|
background: color-mix(in oklab, var(--dn-danger) 18%, transparent);
|
||||||
|
color: var(--dn-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dialog --------------------------------------------------------------------
|
||||||
|
Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline
|
||||||
|
an ihren Ort, nicht in ein zweites Fenster.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.dialog-scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--dn-space-4);
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--dn-space-4);
|
||||||
|
padding: var(--dn-space-5);
|
||||||
|
border: 1px solid var(--dn-border-strong);
|
||||||
|
border-radius: var(--dn-radius-lg);
|
||||||
|
background: var(--dn-bg-sunken);
|
||||||
|
box-shadow: var(--dn-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog h2 {
|
||||||
|
font-size: var(--dn-text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog p {
|
||||||
|
color: var(--dn-fg-muted);
|
||||||
|
line-height: var(--dn-leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog code {
|
||||||
|
font-family: var(--dn-font-code);
|
||||||
|
font-size: var(--dn-text-sm);
|
||||||
|
color: var(--dn-fg);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--dn-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Utilities ---------------------------------------------------------------- */
|
/* --- Utilities ---------------------------------------------------------------- */
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|||||||
@@ -10,29 +10,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Workspace selector ----------------------------------------------------
|
/* --- Workspace selector ----------------------------------------------------
|
||||||
The pre-editor screen: pick or create a workspace. Centered, deliberately
|
The pre-editor screen. One view, no tabs: the list of known workspaces
|
||||||
sparse — it has exactly one job.
|
leads, adding one is the secondary action in the footer. Opening is the
|
||||||
|
everyday case; adding happens once per workspace.
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
.workspace-selector-layout {
|
.selector-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--dn-space-7) var(--dn-space-5);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: var(--dn-space-5);
|
||||||
justify-content: center;
|
|
||||||
gap: var(--dn-space-6);
|
|
||||||
padding: var(--dn-space-6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-selector-view {
|
.selector-head {
|
||||||
width: 100%;
|
display: flex;
|
||||||
max-width: 34rem;
|
align-items: baseline;
|
||||||
padding: var(--dn-space-6);
|
justify-content: space-between;
|
||||||
background: var(--dn-bg-sunken);
|
gap: var(--dn-space-4);
|
||||||
border: 1px solid var(--dn-border);
|
}
|
||||||
border-radius: var(--dn-radius-lg);
|
|
||||||
box-shadow: var(--dn-shadow);
|
.selector-title {
|
||||||
|
font-size: var(--dn-text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nur die Liste scrollt — Kopf und Fuß bleiben stehen. */
|
||||||
|
.selector-list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--dn-space-3);
|
||||||
|
padding-top: var(--dn-space-4);
|
||||||
|
border-top: 1px solid var(--dn-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Editor shell ----------------------------------------------------------
|
/* --- Editor shell ----------------------------------------------------------
|
||||||
@@ -59,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;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB |
@@ -4,7 +4,19 @@ Issues and specs for this repo live as **Gitea** issues at
|
|||||||
<https://gitlab.hive-brain.de/RDietermann/dionotes-client>.
|
<https://gitlab.hive-brain.de/RDietermann/dionotes-client>.
|
||||||
|
|
||||||
> The host is named `gitlab.hive-brain.de`, but the server is **Gitea 1.25.5**, not GitLab.
|
> The host is named `gitlab.hive-brain.de`, but the server is **Gitea 1.25.5**, not GitLab.
|
||||||
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (v0.15 installed).
|
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (developed against v0.15).
|
||||||
|
|
||||||
|
**Auf einem frischen Gerät** ist `tea` erst einzurichten — ohne das schlägt jedes Kommando
|
||||||
|
unten fehl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea login add --name hive --url https://gitlab.hive-brain.de --token <token>
|
||||||
|
tea login list # prueft, ob es sitzt
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Token braucht mindestens `write:issue`, `write:repository` und `write:user`. Der hier
|
||||||
|
verwendete hat **kein** `read:admin` und **kein** `write:organization` — Wartungs-API und
|
||||||
|
Anlegen unter einer Organisation scheitern damit.
|
||||||
|
|
||||||
Run `tea` from inside the clone — it resolves owner/repo from `git remote`. It prints
|
Run `tea` from inside the clone — it resolves owner/repo from `git remote`. It prints
|
||||||
`NOTE: no login matched this repository, falling back to login 'hive'` on every call;
|
`NOTE: no login matched this repository, falling back to login 'hive'` on every call;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
mod components;
|
mod components;
|
||||||
mod route;
|
mod route;
|
||||||
|
mod tree;
|
||||||
mod views;
|
mod views;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -29,6 +33,9 @@ fn App() -> Element {
|
|||||||
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
|
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
|
||||||
// Outlet, durch das sich keine Props durchreichen lassen.
|
// Outlet, durch das sich keine Props durchreichen lassen.
|
||||||
use_context_provider(|| Signal::new(None::<Workspace>));
|
use_context_provider(|| Signal::new(None::<Workspace>));
|
||||||
|
// Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich
|
||||||
|
// ändert, ohne dass der Workspace wechselt.
|
||||||
|
use_context_provider(|| Signal::new(None::<PathBuf>));
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
document::Link { rel: "icon", href: FAVICON }
|
document::Link { rel: "icon", href: FAVICON }
|
||||||
|
|||||||
14
src/route.rs
14
src/route.rs
@@ -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
194
src/tree.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,234 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen
|
use crate::route::Route;
|
||||||
/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt,
|
use crate::workspace::{dir_is_empty, OpenError, Workspace};
|
||||||
/// das Hinzufügen ist Nebenaktion.
|
use crate::workspace_list::WorkspaceList;
|
||||||
|
|
||||||
|
/// Der Bildschirm vor dem Editor. Eine Ansicht, keine Reiter: Die Liste der
|
||||||
|
/// bekannten Workspaces führt, das Hinzufügen ist Nebenaktion in der Fußzeile.
|
||||||
|
/// Öffnen ist der Alltagsfall, Hinzufügen passiert pro Workspace einmal.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Selector() -> Element {
|
pub fn Selector() -> Element {
|
||||||
|
let mut workspace = use_context::<Signal<Option<Workspace>>>();
|
||||||
|
let mut note = use_context::<Signal<Option<PathBuf>>>();
|
||||||
|
let mut list = use_signal(WorkspaceList::load);
|
||||||
|
let navigator = use_navigator();
|
||||||
|
|
||||||
|
let mut pending = use_signal(|| None::<PathBuf>);
|
||||||
|
let mut error = use_signal(|| None::<String>);
|
||||||
|
|
||||||
|
// Zustandsprüfung der Einträge, nebenläufig und auf einem eigenen Thread:
|
||||||
|
// Auf einem nicht eingehängten Netzlaufwerk läuft schon `is_dir()` in ein
|
||||||
|
// Timeout von Sekunden, und das würde sonst den Selector einfrieren —
|
||||||
|
// ausgerechnet den Bildschirm, den jeder Start zuerst zeigt.
|
||||||
|
//
|
||||||
|
// spawn_blocking setzt die Tokio-Runtime voraus, die dioxus-desktop
|
||||||
|
// mitbringt. Für die web-/mobile-Features müsste das anders laufen —
|
||||||
|
// die sind laut Entscheidung in Issue #1 aber ausdrücklich nicht Ziel.
|
||||||
|
let probed = use_resource(move || {
|
||||||
|
let paths = list.read().entries().to_vec();
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let state = Workspace::open(path.clone()).err();
|
||||||
|
(path, state)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut accept = move |opened: Workspace| {
|
||||||
|
list.write().promote(opened.root.clone());
|
||||||
|
if let Err(err) = list.read().save() {
|
||||||
|
error.set(Some(format!(
|
||||||
|
"Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
workspace.set(Some(opened));
|
||||||
|
// Mit Notiz direkt in den Editor, sonst auf den leeren Stand.
|
||||||
|
if note.read().is_some() {
|
||||||
|
navigator.push(Route::Editor {});
|
||||||
|
} else {
|
||||||
|
navigator.push(Route::Home {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
|
||||||
|
Ok(opened) => {
|
||||||
|
// Ein frischer Workspace bringt eine Kurzanleitung mit; die wird
|
||||||
|
// gleich angezeigt, statt den Nutzer vor eine leere Fläche zu setzen.
|
||||||
|
note.set(opened.welcome_note());
|
||||||
|
accept(opened);
|
||||||
|
}
|
||||||
|
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
|
||||||
|
// ohne eigenen Knopf abgedeckt.
|
||||||
|
let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
|
||||||
|
Ok(opened) => {
|
||||||
|
// Ein bestehender Workspace öffnet ohne vorgewählte Notiz.
|
||||||
|
note.set(None);
|
||||||
|
accept(opened);
|
||||||
|
}
|
||||||
|
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let choose = move |_| async move {
|
||||||
|
// Abbruch liefert None und ist kein Fehler — keine Meldung.
|
||||||
|
let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let root = handle.path().to_path_buf();
|
||||||
|
error.set(None);
|
||||||
|
|
||||||
|
// Ein vorhandener Workspace wird geöffnet, nicht abgelehnt: derselbe
|
||||||
|
// Knopf nimmt neue und wiedergefundene Workspaces auf.
|
||||||
|
if Workspace::has_marker(&root) {
|
||||||
|
open_entry(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match dir_is_empty(&root) {
|
||||||
|
Ok(true) => create_at(root),
|
||||||
|
// Nicht leer ist beim Import der Normalfall, keine Ausnahme.
|
||||||
|
Ok(false) => pending.set(Some(root)),
|
||||||
|
Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let paths = list.read().entries().to_vec();
|
||||||
|
let states = probed.read().clone();
|
||||||
|
// Solange die Prüfung läuft, bleiben die Einträge unmarkiert. Die lineare
|
||||||
|
// Suche ist bei einer Workspace-Liste billiger als eine Map aufzubauen.
|
||||||
|
let state_of = |path: &PathBuf| -> Option<OpenError> {
|
||||||
|
states
|
||||||
|
.as_ref()?
|
||||||
|
.iter()
|
||||||
|
.find(|(known, _)| known == path)
|
||||||
|
.and_then(|(_, state)| state.clone())
|
||||||
|
};
|
||||||
|
let is_empty = paths.is_empty();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "workspace-selector-layout",
|
div { class: "selector-shell",
|
||||||
div { class: "workspace-selector-view",
|
div { class: "selector-head",
|
||||||
// TODO: Workspace-Liste aus workspaces.ron laden und darstellen
|
span { class: "selector-title", "Workspace öffnen" }
|
||||||
// (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur
|
if !is_empty {
|
||||||
// der leere Zustand.
|
span { class: "field-hint", "{paths.len()} bekannt" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "selector-list-wrap",
|
||||||
|
if is_empty {
|
||||||
div { class: "empty-state",
|
div { class: "empty-state",
|
||||||
span { class: "empty-state-title", "Noch kein Workspace" }
|
span { class: "empty-state-title", "Hier ist noch nichts" }
|
||||||
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
|
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
|
||||||
// TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und
|
}
|
||||||
// den Workspace in den Context schreiben.
|
} else {
|
||||||
button { class: "btn btn-primary", disabled: true,
|
div { class: "ws-list",
|
||||||
|
for path in paths.iter() {
|
||||||
|
WorkspaceEntry {
|
||||||
|
key: "{path.display()}",
|
||||||
|
path: path.clone(),
|
||||||
|
state: state_of(path),
|
||||||
|
onopen: move |path| open_entry(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "selector-foot",
|
||||||
|
span { class: "field-hint", "Ein Workspace ist ein Ordner mit Notizen." }
|
||||||
|
button {
|
||||||
|
class: if is_empty { "btn btn-primary" } else { "btn" },
|
||||||
|
onclick: choose,
|
||||||
"Workspace hinzufügen…"
|
"Workspace hinzufügen…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(message) = error() {
|
||||||
|
span { class: "field-error", "{message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(root) = pending() {
|
||||||
|
NotEmptyDialog {
|
||||||
|
root: root.clone(),
|
||||||
|
onconfirm: move |root| {
|
||||||
|
pending.set(None);
|
||||||
|
create_at(root);
|
||||||
|
},
|
||||||
|
oncancel: move |_| pending.set(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag der Workspace-Liste: Ordnername, darunter der Pfad, rechts bei
|
||||||
|
/// Bedarf das Statusabzeichen. Ein defekter Eintrag bleibt anklickbar und wird
|
||||||
|
/// nie automatisch entfernt — meist fehlt nur gerade das Laufwerk.
|
||||||
|
#[component]
|
||||||
|
fn WorkspaceEntry(path: PathBuf, state: Option<OpenError>, onopen: EventHandler<PathBuf>) -> Element {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||||
|
let full = path.display().to_string();
|
||||||
|
let clicked = path.clone();
|
||||||
|
// Klasse und Beschriftung des Abzeichens vorab, damit rsx! den Zustand
|
||||||
|
// nicht zweimal gleichzeitig braucht.
|
||||||
|
let broken = state.is_some();
|
||||||
|
let badge = state.as_ref().map(|state| (state.badge_class(), state.to_string()));
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: if broken { "ws-item is-broken" } else { "ws-item" },
|
||||||
|
onclick: move |_| onopen.call(clicked.clone()),
|
||||||
|
span { class: "ws-item-body",
|
||||||
|
span { class: "ws-item-name", "{name}" }
|
||||||
|
span { class: "ws-item-path", "{full}" }
|
||||||
|
}
|
||||||
|
if let Some((class, label)) = badge {
|
||||||
|
span { class: "ws-badge {class}", "{label}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält.
|
||||||
|
/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da,
|
||||||
|
/// dass nichts Vorhandenes verändert wird.
|
||||||
|
#[component]
|
||||||
|
fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler<PathBuf>, oncancel: EventHandler<()>) -> Element {
|
||||||
|
let shown = root.display().to_string();
|
||||||
|
let confirmed = root.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "dialog-scrim",
|
||||||
|
div { class: "dialog",
|
||||||
|
h2 { "Ordner ist nicht leer" }
|
||||||
|
p {
|
||||||
|
code { "{shown}" }
|
||||||
|
" enthält bereits Dateien. DioNotes legt darin nur den Ordner "
|
||||||
|
code { ".dionotes" }
|
||||||
|
" an und verändert nichts Vorhandenes."
|
||||||
|
}
|
||||||
|
div { class: "dialog-actions",
|
||||||
|
button { class: "btn btn-ghost", onclick: move |_| oncancel.call(()), "Abbrechen" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| onconfirm.call(confirmed.clone()),
|
||||||
|
"Workspace anlegen"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
270
src/workspace.rs
270
src/workspace.rs
@@ -1,4 +1,55 @@
|
|||||||
use std::path::PathBuf;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const MARKER_DIR: &str = ".dionotes";
|
||||||
|
const MARKER_FILE: &str = "workspace.ron";
|
||||||
|
|
||||||
|
/// Kurzanleitung, die ein frisch angelegter Workspace mitbekommt.
|
||||||
|
pub const WELCOME_FILE: &str = "Willkommen.md";
|
||||||
|
|
||||||
|
const WELCOME_TEXT: &str = "# Willkommen bei DioNotes
|
||||||
|
|
||||||
|
Dieser Ordner ist jetzt ein **Workspace** — eine Sammlung von Notizen, die als
|
||||||
|
gewöhnliche Markdown-Dateien nebeneinander liegen. Du kannst sie jederzeit mit
|
||||||
|
jedem anderen Programm öffnen, sichern oder synchronisieren.
|
||||||
|
|
||||||
|
## Wie es aufgebaut ist
|
||||||
|
|
||||||
|
- Jede Notiz ist eine `.md`-Datei in diesem Ordner.
|
||||||
|
- **Unterordner funktionieren.** Sie erscheinen links als aufklappbarer Baum.
|
||||||
|
- Der versteckte Ordner `.dionotes` macht diesen Ordner zum Workspace. Dort
|
||||||
|
landen später auch die Einstellungen. Lösche ihn nicht — ohne ihn erkennt
|
||||||
|
DioNotes den Ordner nicht wieder.
|
||||||
|
- Alles, was mit einem Punkt beginnt, bleibt in der Liste unsichtbar. Ein
|
||||||
|
`.git`-Ordner stört also nicht.
|
||||||
|
|
||||||
|
## Gut zu wissen
|
||||||
|
|
||||||
|
DioNotes liest den Ordner beim Öffnen einmal ein. Änderst du etwas von außen —
|
||||||
|
etwa über einen Sync-Dienst —, hol dir den neuen Stand mit dem Knopf **⟳** über
|
||||||
|
der Notizliste.
|
||||||
|
|
||||||
|
Diese Datei ist eine ganz normale Notiz. Du kannst sie überschreiben oder
|
||||||
|
löschen, sobald du sie nicht mehr brauchst.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
|
||||||
|
/// die eine ältere App nicht bloß ignorieren kann.
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Inhalt von `.dionotes/workspace.ron`.
|
||||||
|
///
|
||||||
|
/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das
|
||||||
|
/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim
|
||||||
|
/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem
|
||||||
|
/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct Marker {
|
||||||
|
schema_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
|
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
|
||||||
///
|
///
|
||||||
@@ -10,6 +61,61 @@ pub struct Workspace {
|
|||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Warum ein Ordner sich nicht als Workspace öffnen ließ.
|
||||||
|
///
|
||||||
|
/// Die ersten drei Varianten sind genau die Zustände, mit denen ein
|
||||||
|
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
|
||||||
|
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OpenError {
|
||||||
|
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
|
||||||
|
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
|
||||||
|
/// aus der Liste entfernt.
|
||||||
|
Unreachable,
|
||||||
|
/// Der Ordner ist da, trägt aber keinen Marker.
|
||||||
|
NotAWorkspace,
|
||||||
|
/// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht**
|
||||||
|
/// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein
|
||||||
|
/// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den
|
||||||
|
/// Schutz auszuhebeln.
|
||||||
|
Damaged,
|
||||||
|
/// Der Marker stammt aus einer neueren Fassung der App.
|
||||||
|
TooNew { found: u32 },
|
||||||
|
Io(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for OpenError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Unreachable => write!(f, "nicht erreichbar"),
|
||||||
|
Self::NotAWorkspace => write!(f, "kein Workspace mehr"),
|
||||||
|
Self::Damaged => write!(f, "beschädigt"),
|
||||||
|
Self::TooNew { found } => write!(
|
||||||
|
f,
|
||||||
|
"mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})"
|
||||||
|
),
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for OpenError {
|
||||||
|
fn from(err: io::Error) -> Self {
|
||||||
|
Self::Io(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenError {
|
||||||
|
/// CSS-Klasse fuer das Statusabzeichen am Listeneintrag.
|
||||||
|
pub fn badge_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unreachable => "unreachable",
|
||||||
|
Self::NotAWorkspace => "no-marker",
|
||||||
|
Self::Damaged | Self::TooNew { .. } | Self::Io(_) => "damaged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
|
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
|
||||||
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
|
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
|
||||||
@@ -19,16 +125,176 @@ impl Workspace {
|
|||||||
.map(|n| n.to_string_lossy().into_owned())
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
|
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn marker_path(root: &Path) -> PathBuf {
|
||||||
|
root.join(MARKER_DIR).join(MARKER_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat.
|
||||||
|
pub fn welcome_note(&self) -> Option<PathBuf> {
|
||||||
|
let path = self.root.join(WELCOME_FILE);
|
||||||
|
path.is_file().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
|
||||||
|
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
|
||||||
|
pub fn has_marker(root: &Path) -> bool {
|
||||||
|
Self::marker_path(root).is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einen bestehenden Workspace öffnen.
|
||||||
|
pub fn open(root: PathBuf) -> Result<Self, OpenError> {
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(OpenError::Unreachable);
|
||||||
|
}
|
||||||
|
let marker_path = Self::marker_path(&root);
|
||||||
|
if !marker_path.is_file() {
|
||||||
|
return Err(OpenError::NotAWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?;
|
||||||
|
let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?;
|
||||||
|
if marker.schema_version > SCHEMA_VERSION {
|
||||||
|
return Err(OpenError::TooNew { found: marker.schema_version });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen.
|
||||||
|
///
|
||||||
|
/// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv.
|
||||||
|
pub fn create(root: PathBuf) -> Result<Self, OpenError> {
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(OpenError::Unreachable);
|
||||||
|
}
|
||||||
|
// Zuerst prüfen: Sobald `.dionotes` angelegt ist, ist der Ordner
|
||||||
|
// nie mehr leer.
|
||||||
|
let was_empty = dir_is_empty(&root).unwrap_or(false);
|
||||||
|
|
||||||
|
let marker_path = Self::marker_path(&root);
|
||||||
|
if let Some(dir) = marker_path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let marker = Marker { schema_version: SCHEMA_VERSION };
|
||||||
|
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||||
|
fs::write(&marker_path, text)?;
|
||||||
|
|
||||||
|
// Nur in einen frischen Workspace. Wer einen Ordner voller Notizen
|
||||||
|
// importiert, will keine fremde Datei dazwischen — er kennt das
|
||||||
|
// Programm dann meist schon.
|
||||||
|
if was_empty {
|
||||||
|
let _ = fs::write(root.join(WELCOME_FILE), WELCOME_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen.
|
||||||
|
pub fn dir_is_empty(path: &Path) -> io::Result<bool> {
|
||||||
|
Ok(fs::read_dir(path)?.next().is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Workspace;
|
use super::{dir_is_empty, OpenError, Workspace};
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb
|
||||||
|
/// braucht es keine Zufallszahl und keine tempfile-Crate.
|
||||||
|
fn scratch(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dionotes-test-{name}"));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn name_is_the_folder_name() {
|
fn name_is_the_folder_name() {
|
||||||
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
|
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
|
||||||
assert_eq!(ws.name(), "Notizen");
|
assert_eq!(ws.name(), "Notizen");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_then_open_roundtrip() {
|
||||||
|
let dir = scratch("create-open");
|
||||||
|
let created = Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
assert!(Workspace::has_marker(&dir));
|
||||||
|
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_workspace_gets_a_welcome_note() {
|
||||||
|
let dir = scratch("welcome-fresh");
|
||||||
|
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
|
||||||
|
let note = ws.welcome_note().expect("Willkommensnotiz vorhanden");
|
||||||
|
assert!(note.ends_with("Willkommen.md"));
|
||||||
|
assert!(fs::read_to_string(¬e).unwrap().contains("Workspace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_imported_folder_gets_no_welcome_note() {
|
||||||
|
let dir = scratch("welcome-import");
|
||||||
|
fs::write(dir.join("eigene-notiz.md"), "# Meins").unwrap();
|
||||||
|
|
||||||
|
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
assert!(ws.welcome_note().is_none(), "Import darf keine fremde Datei bekommen");
|
||||||
|
assert!(!dir.join("Willkommen.md").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_leaves_existing_files_alone() {
|
||||||
|
let dir = scratch("create-keeps-files");
|
||||||
|
fs::write(dir.join("notiz.md"), "# Hallo").unwrap();
|
||||||
|
Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_folder_is_unreachable() {
|
||||||
|
let dir = scratch("gone").join("weg");
|
||||||
|
assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_without_marker_is_not_a_workspace() {
|
||||||
|
let dir = scratch("no-marker");
|
||||||
|
assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_marker_is_damaged_and_not_recreated() {
|
||||||
|
let dir = scratch("damaged");
|
||||||
|
Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
let marker = dir.join(".dionotes").join("workspace.ron");
|
||||||
|
fs::write(&marker, "kaputt").unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged)));
|
||||||
|
// Der Marker wurde nicht stillschweigend ersetzt.
|
||||||
|
assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_schema_is_refused_with_the_version() {
|
||||||
|
let dir = scratch("too-new");
|
||||||
|
Workspace::create(dir.clone()).expect("anlegbar");
|
||||||
|
fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap();
|
||||||
|
|
||||||
|
match Workspace::open(dir) {
|
||||||
|
Err(OpenError::TooNew { found }) => assert_eq!(found, 99),
|
||||||
|
other => panic!("erwartet TooNew, war {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_folder_is_recognised() {
|
||||||
|
let dir = scratch("emptiness");
|
||||||
|
assert!(dir_is_empty(&dir).unwrap());
|
||||||
|
fs::write(dir.join("etwas.txt"), "x").unwrap();
|
||||||
|
assert!(!dir_is_empty(&dir).unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/workspace_list.rs
Normal file
130
src/workspace_list.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Verzeichnisname der App. Überall kleingeschrieben — unter Linux ist das
|
||||||
|
/// Konvention, unter Windows in `%LOCALAPPDATA%` unauffällig. Eine Crate nur
|
||||||
|
/// für diese Normalisierung (`directories`) wäre der teurere Weg gewesen.
|
||||||
|
const APP_DIR: &str = "dionotes";
|
||||||
|
const FILE_NAME: &str = "workspaces.ron";
|
||||||
|
|
||||||
|
/// Die anwendungsweite Liste bekannter Workspaces.
|
||||||
|
///
|
||||||
|
/// Sie enthält nur Pfade: Der Anzeigename ist der Ordnername und wäre als
|
||||||
|
/// eigenes Feld nach dem ersten Umbenennen falsch. Auch keinen Zeitstempel —
|
||||||
|
/// die **Reihenfolge ist die Sortierung**, zuletzt geöffnet steht vorn.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkspaceList {
|
||||||
|
workspaces: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceList {
|
||||||
|
pub fn entries(&self) -> &[PathBuf] {
|
||||||
|
&self.workspaces
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hebt einen Workspace an die Spitze — beim Öffnen wie beim Hinzufügen.
|
||||||
|
/// Weil vorher gefiltert wird, kann derselbe Pfad nicht doppelt vorkommen.
|
||||||
|
pub fn promote(&mut self, path: PathBuf) {
|
||||||
|
self.workspaces.retain(|known| known != &path);
|
||||||
|
self.workspaces.insert(0, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wo die Liste liegt.
|
||||||
|
///
|
||||||
|
/// Ein Ausdruck für alle Plattformen: `state_dir()` liefert nur unter Linux
|
||||||
|
/// `Some` — dort führt die XDG-Spec „recently used files" ausdrücklich als
|
||||||
|
/// State, gehört also nach `$XDG_STATE_HOME` und nicht nach `~/.config`.
|
||||||
|
/// Windows und macOS fallen auf das lokale Konfigurationsverzeichnis durch;
|
||||||
|
/// unter Windows bewusst `%LOCALAPPDATA%` statt Roaming, weil absolute
|
||||||
|
/// Workspace-Pfade maschinenspezifisch sind.
|
||||||
|
pub fn path() -> Option<PathBuf> {
|
||||||
|
let base = dirs::state_dir().or_else(dirs::config_local_dir)?;
|
||||||
|
Some(base.join(APP_DIR).join(FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lesen. Eine fehlende oder unlesbare Datei ergibt eine leere Liste statt
|
||||||
|
/// eines Fehlers: Sie enthält nichts Unwiederbringliches — der Nutzer wählt
|
||||||
|
/// seine Ordner einmal neu, seine Notizen sind nicht betroffen. Deshalb
|
||||||
|
/// braucht die Datei auch keine Schema-Version.
|
||||||
|
pub fn load() -> Self {
|
||||||
|
Self::path()
|
||||||
|
.and_then(|path| fs::read_to_string(path).ok())
|
||||||
|
.map(|text| Self::parse(&text))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> io::Result<()> {
|
||||||
|
let path = Self::path().ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::NotFound, "kein Konfigurationsverzeichnis")
|
||||||
|
})?;
|
||||||
|
// Das Basisverzeichnis existiert garantiert, das der App nicht.
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
fs::write(path, self.to_ron()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(text: &str) -> Self {
|
||||||
|
ron::from_str(text).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_ron(&self) -> io::Result<String> {
|
||||||
|
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::WorkspaceList;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn list_of(paths: &[&str]) -> WorkspaceList {
|
||||||
|
let mut list = WorkspaceList::default();
|
||||||
|
// rückwärts, damit die Reihenfolge am Ende der Eingabe entspricht
|
||||||
|
for p in paths.iter().rev() {
|
||||||
|
list.promote(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn promote_puts_new_entries_first() {
|
||||||
|
let mut list = list_of(&["a", "b"]);
|
||||||
|
list.promote(PathBuf::from("c"));
|
||||||
|
assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn promote_moves_known_entries_without_duplicating() {
|
||||||
|
let mut list = list_of(&["a", "b", "c"]);
|
||||||
|
list.promote(PathBuf::from("c"));
|
||||||
|
assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn survives_a_ron_roundtrip_with_windows_paths() {
|
||||||
|
let list = list_of(&[r"C:\Users\marvin\Notizen", r"D:\Projekte\Test"]);
|
||||||
|
let text = list.to_ron().expect("serialisierbar");
|
||||||
|
assert_eq!(WorkspaceList::parse(&text), list);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_lands_in_the_app_directory() {
|
||||||
|
let path = WorkspaceList::path().expect("Konfigurationsverzeichnis vorhanden");
|
||||||
|
assert!(
|
||||||
|
path.ends_with("dionotes/workspaces.ron"),
|
||||||
|
"unerwarteter Ablageort: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
println!("Ablageort auf dieser Plattform: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_content_yields_an_empty_list() {
|
||||||
|
assert_eq!(WorkspaceList::parse("kein RON, nur Text"), WorkspaceList::default());
|
||||||
|
assert_eq!(WorkspaceList::parse(""), WorkspaceList::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user