4 Commits

Author SHA1 Message Date
570c804d3f feat: Selector-UI nach der gewaehlten Prototyp-Variante
Setzt Issue #8 um: eine Ansicht statt Reiter. Die Liste der bekannten
Workspaces fuehrt, das Hinzufuegen sitzt als Nebenaktion in der Fusszeile und
wird nur beim leeren Erststart zur Primaeraktion. Oeffnen ist der
Alltagsfall, Hinzufuegen passiert pro Workspace genau einmal - zwei
gleichrangige Reiter haetten bei jedem Start eine Entscheidung verlangt, die
fast immer dieselbe ist.

Ein Eintrag zeigt den Ordnernamen, darunter den Pfad in der Code-Schrift. Der
Pfad wird bei Ueberlaenge vorne gekuerzt, weil sein Ende mehr aussagt als sein
Anfang.

Die Zustandspruefung laeuft nebenlaeufig ueber use_resource und
spawn_blocking. Ohne das wuerde ein nicht eingehaengtes Netzlaufwerk den
Selector einfrieren: dort laeuft schon is_dir() in ein Timeout von Sekunden,
und zwar auf dem Bildschirm, den jeder Start zuerst zeigt. Solange die
Pruefung laeuft, bleiben die Eintraege unmarkiert.

Die drei Zustaende aus #6 bekommen eigene Farben und Formulierungen - nicht
erreichbar, kein Workspace mehr, beschaedigt -, weil sie zu verschiedenen
Handlungen fuehren. Ein defekter Eintrag wird gedaempft, bleibt aber
anklickbar und verschwindet nie automatisch: Meist fehlt nur gerade das
Laufwerk, und ein Klick versucht es erneut.

OpenError ist dafuer Clone und PartialEq geworden; Io traegt jetzt die
Meldung als String statt io::Error, der beides nicht kann.
2026-08-28 10:38:13 +02:00
276bc67dbe feat: Ordner-Dialog und Anlegen-Flow
Setzt die Entscheidungen aus #2, #4 und #6 um: Der Knopf "Workspace
hinzufuegen..." oeffnet den nativen Ordner-Dialog, legt den Marker an und
uebernimmt den Workspace in Context, Liste und Editor.

Der Marker ist .dionotes/workspace.ron mit genau (schema_version: 1). Er wird
additiv angelegt, vorhandene Dateien bleiben unberuehrt - ein Test haelt das
fest. Ein beschaedigter Marker fuehrt dazu, dass der Workspace nicht
geoeffnet und der Marker nicht neu geschrieben wird; auch das ist getestet,
denn sobald dort ein Passwort-Hash liegt, waere stillschweigendes Neuanlegen
ein Weg, den Schutz auszuhebeln.

OpenError unterscheidet die drei Zustaende aus #6 - nicht erreichbar, kein
Workspace mehr, beschaedigt - plus den Fall eines zu neuen Schemas. Die
Display-Impl liefert genau die Formulierungen, mit denen spaeter die
Listeneintraege markiert werden.

Der Flow folgt der Regel aus #6: Ein abgebrochener Dialog ist kein Fehler und
erzeugt keine Meldung. Ein Ordner, der bereits Workspace ist, wird geoeffnet
statt abgelehnt - derselbe Knopf nimmt neue und wiedergefundene Workspaces
auf, was seine Beschriftung erst rechtfertigt. Ein nicht leerer Ordner loest
eine Rueckfrage aus, die ausdruecklich sagt, dass nichts Vorhandenes
veraendert wird; das ist beim Import der Normalfall, keine Ausnahme.

rfd wird mit denselben Features eingebunden, die dioxus-desktop ohnehin zieht
(xdg-portal statt default). Mit den Default-Features waeren neun
Wayland-Pakete dazugekommen, die das Portal nicht braucht - so kostet die
explizite Abhaengigkeit keinen einzigen zusaetzlichen Eintrag.
2026-08-28 09:56:33 +02:00
37a7bab30f feat: Workspace-Liste laden und speichern
Die anwendungsweite Liste bekannter Workspaces, so wie sie in den Issues #3
und #4 festgelegt wurde. Bewusst ohne Oberfläche, damit dieser Teil sich mit
echten Tests pruefen laesst statt nur mit cargo check.

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

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

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

Neu im Abhaengigkeitsgraph ist allein ron; dirs und serde lagen bereits
transitiv vor. directories waere die teurere Variante gewesen - sein einziger
Mehrwert waere die Kleinschreibung des Ordnernamens, die hier eine Konstante
erledigt.
2026-08-28 09:39:56 +02:00
91fd351d59 refactor: Router zusammenfuehren und offenen Workspace im Context fuehren
Die App hatte zwei Routable-Enums, zwischen denen ein Signal<bool> umschaltete.
Der Schalter wurde nie gesetzt - beim ersten Umlegen waere aufgefallen, dass
sich beide Router dieselbe History teilen und beide "/" belegen: Wer im
Selector auf "Open Workspace" klickt, steht auf /ws_open, und der
Editor-Router kennt nur / und /editor.

Jetzt ein Enum, der Editor-Teil unter #[nest("/workspace")]. An die Stelle des
bool tritt Signal<Option<Workspace>> in einem Context-Provider - Props
scheiden aus, weil sich durch ein Outlet keine durchreichen lassen. Fehlt der
Workspace, schickt ein Guard im EditorShell-Layout zurueck zum Selector; der
Fall tritt real bei Hot-Reload auf, wo der Context faellt und die Route bleibt.

Der Selector verliert seine Reiter und wird eine einzige Route. Das folgt aus
dem UI-Prototyp in Issue #8: Oeffnen ist der Alltagsfall, Hinzufuegen passiert
pro Workspace einmal - zwei gleichrangige Reiter verlangten bei jedem Start
eine Entscheidung, die fast immer dieselbe ist. Damit entfallen der leere
Stub create_new_workspace.rs, die Tab-Konstante fuer den Selector und die
zugehoerigen CSS-Klassen.

Weil EnumIter nicht mehr gebraucht wird, faellt strum_macros als direkte
Abhaengigkeit weg; strum re-exportiert die Derives bereits.

Entscheidungen dazu in den Issues #5, #6 und #8.
2026-08-28 08:57:19 +02:00
14 changed files with 863 additions and 373 deletions

26
Cargo.lock generated
View File

@@ -2329,8 +2329,12 @@ dependencies = [
"dioxus",
"dioxus-code",
"dioxus-code-editor",
"dirs",
"rfd",
"ron",
"serde",
"strum",
"strum_macros",
"tokio",
]
[[package]]
@@ -5887,6 +5891,20 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "1.1.0"
@@ -7013,6 +7031,12 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.1"

View File

@@ -10,8 +10,15 @@ edition = "2021"
dioxus = { version = "0.7.1", features = ["router"] }
dioxus-code = "0.1.2"
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
strum = { version = "0.28.0", features = ["strum_macros", "derive"] }
strum_macros = "0.28.0"
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"] }
strum = { version = "0.28.0", features = ["derive"] }
tokio = { version = "1.53.1", features = ["rt"] }
[features]
default = ["desktop"]

View File

@@ -49,30 +49,6 @@
transform: scaleY(1);
}
/* --- Workspace selector tab ------------------------------------------------- */
.workspace-selector {
padding: var(--dn-space-2) var(--dn-space-4);
border-radius: var(--dn-radius);
color: var(--dn-fg-muted);
font-size: var(--dn-text-base);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: color var(--dn-dur-fast) var(--dn-ease),
background-color var(--dn-dur-fast) var(--dn-ease);
}
.workspace-selector:hover {
color: var(--dn-fg);
background: var(--dn-bg-raised);
}
.workspace-selector.active {
color: var(--dn-fg-on-accent);
background: var(--dn-accent);
}
/* --- Buttons ---------------------------------------------------------------- */
.btn {
@@ -191,6 +167,142 @@
font-weight: 600;
}
/* --- Workspace-Listeneintrag ---------------------------------------------------
Ordnername, darunter der Pfad, rechts bei Bedarf ein Statusabzeichen. Ein
defekter Eintrag wird gedämpft, bleibt aber anklickbar: Ein Klick versucht
es erneut, womit „Stick wieder eingesteckt" ohne eigenen Knopf abgedeckt ist.
-------------------------------------------------------------------------- */
.ws-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-item {
display: flex;
align-items: center;
gap: var(--dn-space-3);
width: 100%;
padding: var(--dn-space-3);
border: 0;
border-radius: var(--dn-radius);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color var(--dn-dur-fast) var(--dn-ease);
}
.ws-item:hover {
background: var(--dn-bg-raised);
}
.ws-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ws-item-name {
color: var(--dn-fg);
font-weight: 500;
}
/* Bei Überlänge vorne kürzen: Das Ende eines Pfades sagt mehr als sein Anfang. */
.ws-item-path {
direction: rtl;
text-align: left;
color: var(--dn-fg-faint);
font-family: var(--dn-font-code);
font-size: var(--dn-text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-item.is-broken .ws-item-name {
color: var(--dn-fg-muted);
}
.ws-badge {
flex: none;
padding: 2px var(--dn-space-2);
border-radius: 999px;
font-size: var(--dn-text-xs);
white-space: nowrap;
}
/* Drei Zustände, drei Farben — sie führen zu verschiedenen Handlungen:
Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. */
.ws-badge.unreachable {
background: color-mix(in oklab, var(--dn-warning) 18%, transparent);
color: var(--dn-warning);
}
.ws-badge.no-marker {
background: color-mix(in oklab, var(--dn-fg-faint) 22%, transparent);
color: var(--dn-fg-muted);
}
.ws-badge.damaged {
background: color-mix(in oklab, var(--dn-danger) 18%, transparent);
color: var(--dn-danger);
}
/* --- Dialog --------------------------------------------------------------------
Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline
an ihren Ort, nicht in ein zweites Fenster.
-------------------------------------------------------------------------- */
.dialog-scrim {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: var(--dn-space-4);
background: rgb(0 0 0 / 0.55);
z-index: 50;
}
.dialog {
width: min(28rem, 100%);
display: flex;
flex-direction: column;
gap: var(--dn-space-4);
padding: var(--dn-space-5);
border: 1px solid var(--dn-border-strong);
border-radius: var(--dn-radius-lg);
background: var(--dn-bg-sunken);
box-shadow: var(--dn-shadow);
}
.dialog h2 {
font-size: var(--dn-text-md);
font-weight: 600;
}
.dialog p {
color: var(--dn-fg-muted);
line-height: var(--dn-leading-normal);
}
.dialog code {
font-family: var(--dn-font-code);
font-size: var(--dn-text-sm);
color: var(--dn-fg);
overflow-wrap: anywhere;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--dn-space-2);
}
/* --- Utilities ---------------------------------------------------------------- */
.visually-hidden {

View File

@@ -10,38 +10,50 @@
}
/* --- Workspace selector ----------------------------------------------------
The pre-editor screen: pick or create a workspace. Centered, deliberately
sparse — it has exactly one job.
The pre-editor screen. One view, no tabs: the list of known workspaces
leads, adding one is the secondary action in the footer. Opening is the
everyday case; adding happens once per workspace.
-------------------------------------------------------------------------- */
.workspace-selector-layout {
.selector-shell {
flex: 1;
min-height: 0;
width: 100%;
max-width: 40rem;
margin: 0 auto;
padding: var(--dn-space-7) var(--dn-space-5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--dn-space-6);
padding: var(--dn-space-6);
gap: var(--dn-space-5);
}
.workspace-selector-links {
.selector-head {
display: flex;
gap: var(--dn-space-1);
padding: var(--dn-space-1);
background: var(--dn-bg-sunken);
border: 1px solid var(--dn-border);
border-radius: var(--dn-radius-lg);
align-items: baseline;
justify-content: space-between;
gap: var(--dn-space-4);
}
.workspace-selector-view {
width: 100%;
max-width: 34rem;
padding: var(--dn-space-6);
background: var(--dn-bg-sunken);
border: 1px solid var(--dn-border);
border-radius: var(--dn-radius-lg);
box-shadow: var(--dn-shadow);
.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 ----------------------------------------------------------

View File

@@ -198,9 +198,18 @@
.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;
@@ -208,6 +217,9 @@
.text-wrap {
text-wrap: wrap;
}
.underline {
text-decoration-line: underline;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;

View File

@@ -1,236 +0,0 @@
<!doctype html>
<!--
PROTOTYP — WEGWERFCODE. Nicht in die App übernehmen.
Drei Varianten des Selector-Bildschirms, umschaltbar über ?variant=A|B|C,
dazu die Zustände über ?state=filled|empty|dialog.
Frage, die er beantwortet: Gitea-Issue #8 "Selector-UI: Anlegen und Öffnen".
Doppelklicken zum Öffnen. Bindet die echte CSS des Projekts ein, damit
Tokens und Komponenten nicht auseinanderlaufen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<title>DioNotes — Selector-Prototyp</title>
<link rel="stylesheet" href="assets/css/tokens.css">
<link rel="stylesheet" href="assets/css/base.css">
<link rel="stylesheet" href="assets/css/layout.css">
<link rel="stylesheet" href="assets/css/components.css">
<style>
/* ---- Nur für den Prototyp. Was hier steht, ist noch kein Projekt-CSS. ---- */
body { margin: 0; background: var(--dn-bg-app); color: var(--dn-fg);
font-family: var(--dn-font-ui); font-size: var(--dn-text-base); }
/* Listeneintrag — in allen Varianten gleich, damit die Varianten sich in der
Struktur unterscheiden und nicht in der Zeilengestaltung. */
.ws-item { display: flex; align-items: center; gap: var(--dn-space-3);
width: 100%; padding: var(--dn-space-3); border: 1px solid transparent;
border-radius: var(--dn-radius); background: transparent; color: inherit;
text-align: left; cursor: pointer; font: inherit;
transition: background-color var(--dn-dur-fast) var(--dn-ease); }
.ws-item:hover { background: var(--dn-bg-raised); }
.ws-item-body { min-width: 0; flex: 1; }
.ws-item-name { color: var(--dn-fg); font-weight: 500; }
.ws-item-path { 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; direction: rtl; text-align: left; }
.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; }
.ws-badge.unreachable { background: color-mix(in oklab, var(--dn-warning) 18%, transparent); color: var(--dn-warning); }
.ws-badge.nomarker { 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); }
.ws-list { display: flex; flex-direction: column; gap: 2px; }
/* Variante B */
.vb-shell { max-width: 40rem; margin: 0 auto; padding: var(--dn-space-7) var(--dn-space-5);
display: flex; flex-direction: column; gap: var(--dn-space-5); height: 100%;
box-sizing: border-box; }
.vb-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--dn-space-4); }
.vb-title { font-size: var(--dn-text-xl); font-weight: 600; }
.vb-list-wrap { flex: 1; min-height: 0; overflow-y: auto; }
.vb-foot { border-top: 1px solid var(--dn-border); padding-top: var(--dn-space-4);
display: flex; justify-content: space-between; align-items: center; gap: var(--dn-space-3); }
/* Variante C */
.vc-shell { display: grid; grid-template-columns: 1fr 20rem; height: 100%; }
.vc-main { padding: var(--dn-space-7); display: flex; flex-direction: column;
gap: var(--dn-space-4); min-height: 0; }
.vc-side { background: var(--dn-bg-sunken); border-left: 1px solid var(--dn-border);
padding: var(--dn-space-6) var(--dn-space-5); display: flex; flex-direction: column;
gap: var(--dn-space-5); }
.vc-brand { font-size: var(--dn-text-xl); font-weight: 600; }
.vc-tag { color: var(--dn-fg-faint); font-size: var(--dn-text-xs);
letter-spacing: var(--dn-tracking-caps); text-transform: uppercase; }
.vc-list-wrap { flex: 1; min-height: 0; overflow-y: auto; }
.vc-drop { border: 1px dashed var(--dn-border-strong); border-radius: var(--dn-radius-lg);
padding: var(--dn-space-5); text-align: center; color: var(--dn-fg-muted);
font-size: var(--dn-text-sm); }
@media (max-width: 720px) { .vc-shell { grid-template-columns: 1fr; }
.vc-side { border-left: 0; border-top: 1px solid var(--dn-border); } }
/* Dialog — in allen Varianten identisch, er ist selbst kein Variantenthema */
.pt-scrim { position: fixed; inset: 0; background: rgb(0 0 0 / 0.55);
display: grid; place-items: center; z-index: 50; }
.pt-dialog { width: min(28rem, calc(100vw - 2rem)); background: var(--dn-bg-sunken);
border: 1px solid var(--dn-border-strong); border-radius: var(--dn-radius-lg);
box-shadow: var(--dn-shadow); padding: var(--dn-space-5);
display: flex; flex-direction: column; gap: var(--dn-space-4); }
.pt-dialog h2 { margin: 0; font-size: var(--dn-text-md); }
.pt-dialog p { margin: 0; color: var(--dn-fg-muted); line-height: var(--dn-leading-normal); }
.pt-dialog code { font-family: var(--dn-font-code); font-size: var(--dn-text-sm);
color: var(--dn-fg); }
.pt-actions { display: flex; justify-content: flex-end; gap: var(--dn-space-2); }
/* Umschalter — bewusst hässlich, damit er nicht als Teil des Entwurfs gelesen wird */
.pt-bar { position: fixed; bottom: var(--dn-space-4); left: 50%;
transform: translateX(-50%); z-index: 99; display: flex; align-items: center;
gap: var(--dn-space-1); padding: var(--dn-space-1); border-radius: 999px;
background: #000; border: 1px solid #555; box-shadow: 0 6px 24px rgb(0 0 0 / .5);
font-family: var(--dn-font-code); font-size: var(--dn-text-xs); }
.pt-bar button { border: 0; background: transparent; color: #fff; cursor: pointer;
padding: 6px 10px; border-radius: 999px; font: inherit; }
.pt-bar button:hover { background: #333; }
.pt-bar .pt-label { padding: 0 var(--dn-space-3); color: #fff; white-space: nowrap; }
.pt-bar .pt-sep { width: 1px; align-self: stretch; background: #444; margin: 0 4px; }
.pt-bar button.on { background: #fff; color: #000; }
</style>
</head>
<body>
<div id="main"><div id="stage"></div></div>
<div class="pt-bar">
<button id="prev" title="Vorherige Variante (←)">◀</button>
<span class="pt-label" id="label"></span>
<button id="next" title="Nächste Variante (→)">▶</button>
<span class="pt-sep"></span>
<button data-state="filled">Liste</button>
<button data-state="empty">Erststart</button>
<button data-state="dialog">Rückfrage</button>
</div>
<script>
const WS = [
{ name: "Notizen", path: "C:\\Users\\marvin\\Notizen" },
{ name: "DioNotes-Testdaten", path: "D:\\Projekte\\DioNotes-Testdaten" },
{ name: "Archiv 2024", path: "E:\\Backup\\Archiv 2024", badge: "unreachable", text: "nicht erreichbar" },
{ name: "Ideen", path: "C:\\Users\\marvin\\Ideen", badge: "nomarker", text: "kein Workspace mehr" },
{ name: "Rezepte", path: "C:\\Users\\marvin\\Rezepte",badge: "damaged", text: "beschädigt" },
];
const esc = s => s.replace(/[&<>"]/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));
const item = w => `
<button class="ws-item ${w.badge ? "is-broken" : ""}">
<span class="ws-item-body">
<span class="ws-item-name">${esc(w.name)}</span>
<span class="ws-item-path">${esc(w.path)}</span>
</span>
${w.badge ? `<span class="ws-badge ${w.badge}">${esc(w.text)}</span>` : ""}
</button>`;
const list = () => `<div class="ws-list">${WS.map(item).join("")}</div>`;
const dialog = `
<div class="pt-scrim"><div class="pt-dialog">
<h2>Ordner ist nicht leer</h2>
<p><code>D:\\Notizen-Import</code> enthält bereits 34 Dateien. DioNotes legt darin
nur den Ordner <code>.dionotes/</code> an und verändert nichts Vorhandenes.</p>
<div class="pt-actions">
<button class="btn btn-ghost">Abbrechen</button>
<button class="btn btn-primary">Workspace anlegen</button>
</div>
</div></div>`;
/* ---- Variante A — Reiter, wie der heutige Code es anlegt ---------------- */
const A = state => `
<div class="workspace-selector-layout">
<div class="workspace-selector-links">
<a class="workspace-selector" href="#">Create new Workspace</a>
<a class="workspace-selector active" href="#">Open Workspace</a>
</div>
<div class="workspace-selector-view">
${state === "empty" ? `
<div class="empty-state">
<span class="empty-state-title">Noch kein Workspace</span>
<span>Lege einen an, um loszulegen.</span>
<button class="btn btn-primary">Ordner wählen…</button>
</div>` : list()}
</div>
</div>`;
/* ---- Variante B — eine Ansicht, Liste führt, Anlegen ist Nebenaktion ---- */
const B = state => `
<div class="vb-shell">
<div class="vb-head">
<span class="vb-title">Workspace öffnen</span>
<span class="field-hint">${state === "empty" ? "" : WS.length + " bekannt"}</span>
</div>
<div class="vb-list-wrap">
${state === "empty" ? `
<div class="empty-state">
<span class="empty-state-title">Hier ist noch nichts</span>
<span>Der erste Workspace ist ein Ordner deiner Wahl.</span>
</div>` : list()}
</div>
<div class="vb-foot">
<span class="field-hint">Ein Workspace ist ein Ordner mit Notizen.</span>
<button class="btn ${state === "empty" ? "btn-primary" : ""}">Neuen anlegen…</button>
</div>
</div>`;
/* ---- Variante C — Startbildschirm, zwei Spalten ------------------------- */
const C = state => `
<div class="vc-shell">
<div class="vc-main">
<span class="vc-tag">Zuletzt verwendet</span>
<div class="vc-list-wrap">
${state === "empty" ? `
<div class="vc-drop">Noch keine Workspaces.<br>Rechts einen Ordner wählen.</div>`
: list()}
</div>
</div>
<aside class="vc-side">
<div>
<div class="vc-brand">DioNotes</div>
<span class="field-hint">Markdown-Notizen, lokal.</span>
</div>
<div class="field">
<span class="field-label">Neuer Workspace</span>
<button class="btn btn-primary">Ordner wählen…</button>
<span class="field-hint">Legt <code>.dionotes/</code> im gewählten Ordner an.</span>
</div>
</aside>
</div>`;
const V = { A: [A, "Reiter (heutiger Code)"], B: [B, "Liste führt"], C: [C, "Startbildschirm"] };
const keys = Object.keys(V);
const p = new URLSearchParams(location.search);
let variant = keys.includes(p.get("variant")) ? p.get("variant") : "A";
let state = ["filled","empty","dialog"].includes(p.get("state")) ? p.get("state") : "filled";
function render() {
const [fn, name] = V[variant];
document.getElementById("stage").innerHTML =
fn(state === "dialog" ? "filled" : state) + (state === "dialog" ? dialog : "");
document.getElementById("label").textContent = `${variant} — ${name}`;
document.querySelectorAll("[data-state]").forEach(b =>
b.classList.toggle("on", b.dataset.state === state));
history.replaceState(null, "", `?variant=${variant}&state=${state}`);
}
const cycle = d => { variant = keys[(keys.indexOf(variant) + d + keys.length) % keys.length]; render(); };
document.getElementById("prev").onclick = () => cycle(-1);
document.getElementById("next").onclick = () => cycle(1);
document.querySelectorAll("[data-state]").forEach(b =>
b.onclick = () => { state = b.dataset.state; render(); });
addEventListener("keydown", e => {
if (/^(INPUT|TEXTAREA)$/.test(e.target.tagName) || e.target.isContentEditable) return;
if (e.key === "ArrowLeft") cycle(-1);
if (e.key === "ArrowRight") cycle(1);
});
render();
</script>
</body>
</html>

View File

@@ -1,9 +1,13 @@
mod views;
mod components;
mod route;
mod views;
mod workspace;
mod workspace_list;
use dioxus::prelude::*;
use crate::views::editor::dionotes_editor::EditorRoute;
use crate::views::workspace_selector::selector::WorkspaceRoute;
use crate::route::Route;
use crate::workspace::Workspace;
const FAVICON: Asset = asset!("/assets/favicon.ico");
@@ -22,7 +26,11 @@ fn main() {
#[component]
fn App() -> Element {
let workspace_selected: Signal<bool> = use_signal(|| false);
// Der offene Workspace als Context, nicht als Prop: Er wird im Selector
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
// Outlet, durch das sich keine Props durchreichen lassen.
use_context_provider(|| Signal::new(None::<Workspace>));
rsx! {
document::Link { rel: "icon", href: FAVICON }
// Tailwind first: its preflight lives in @layer base, our unlayered
@@ -32,12 +40,6 @@ fn App() -> Element {
document::Link { rel: "stylesheet", href: BASE_CSS }
document::Link { rel: "stylesheet", href: LAYOUT_CSS }
document::Link { rel: "stylesheet", href: COMPONENTS_CSS }
if workspace_selected() {
Router::<EditorRoute> {}
} else {
Router::<WorkspaceRoute> {}
}
Router::<Route> {}
}
}

36
src/route.rs Normal file
View File

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

View File

@@ -1,32 +1,24 @@
use dioxus::prelude::*;
use dioxus_code::Theme;
use dioxus_code_editor::{CodeEditor, Language};
use strum::EnumMessage;
use crate::components::custom_link::custom_link_component::CustomLink;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
use crate::route::Route;
use crate::workspace::Workspace;
const HEADER_SVG: Asset = asset!("/assets/header.svg");
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)]
#[rustfmt::skip]
pub enum EditorRoute {
#[layout(Navbar)]
#[route("/")]
#[strum(message = "Home")]
Home {},
#[route("/editor")]
#[strum(message = "Editor")]
Editor {},
}
/// Home page
#[component]
fn Home() -> Element {
pub fn Home() -> Element {
rsx! {
Hero {}
}
}
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
#[component]
pub fn Hero() -> Element {
rsx! {
@@ -46,7 +38,7 @@ pub fn Hero() -> Element {
}
#[component]
fn Editor() -> Element {
pub fn Editor() -> Element {
let mut source = use_signal(String::new);
rsx! {
@@ -60,15 +52,34 @@ fn Editor() -> Element {
}
}
/// Shared navbar component.
/// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
///
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
/// während die Route bestehen bleibt.
#[component]
fn Navbar() -> Element {
pub fn EditorShell() -> Element {
let workspace = use_context::<Signal<Option<Workspace>>>();
let navigator = use_navigator();
use_effect(move || {
if workspace.read().is_none() {
navigator.replace(Route::Selector {});
}
});
if workspace.read().is_none() {
return rsx! {};
}
rsx! {
div {
class: "layout-container",
div {
class: "files-bar",
for entry in EditorRoute::iter() {
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
for entry in Route::EDITOR_TABS {
CustomLink {
key: "{entry}",
label: entry.get_message().unwrap_or_default(),
@@ -79,8 +90,8 @@ fn Navbar() -> Element {
}
div {
class: "editor-container",
Outlet::<EditorRoute> { }
Outlet::<Route> { }
}
}
}
}
}

View File

@@ -1,11 +0,0 @@
use dioxus::prelude::*;
/// Create new Workspace Layout
// TODO: Platzhalter — Formular zum Anlegen eines Workspace (Name, Pfad) und
// Verdrahtung als Ziel von WorkspaceRoute::NewWorkspace fehlen noch.
#[component]
fn NewWorkspaceLayout() -> Element {
rsx! {
div {}
}
}

View File

@@ -1,2 +1 @@
pub mod selector;
pub mod create_new_workspace;

View File

@@ -1,60 +1,219 @@
use dioxus::prelude::*;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
use crate::components::custom_link::custom_link_component::CustomLink;
use std::path::PathBuf;
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)]
#[rustfmt::skip]
pub enum WorkspaceRoute {
#[layout(Workspace)]
#[route("/")]
#[strum(message = "Create new Workspace")]
NewWorkspace {},
#[route("/ws_open")]
#[strum(message = "Open Workspace")]
OpenWorkspace {},
}
use crate::route::Route;
use crate::workspace::{dir_is_empty, OpenError, Workspace};
use crate::workspace_list::WorkspaceList;
/// Page to create new Workspace
/// 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]
fn NewWorkspace() -> Element {
rsx! {
div {
"New Workspace",
pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>();
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()
}
}
}
});
/// Page to open available Workspaces
#[component]
fn OpenWorkspace() -> Element {
rsx! {
div {
"Open Workspace",
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));
navigator.push(Route::Home {});
};
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
Ok(opened) => accept(opened),
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
};
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
// ohne eigenen Knopf abgedeckt.
let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
Ok(opened) => 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();
/// Workspace Layout
#[component]
fn Workspace() -> Element {
rsx! {
div {
class: "workspace-selector-layout",
div {
class: "workspace-selector-links",
for route in WorkspaceRoute::iter() {
CustomLink {
key: "{route}",
label: route.get_message().unwrap_or_default(),
href: route.clone(),
css: "workspace-selector",
div { class: "selector-shell",
div { class: "selector-head",
span { class: "selector-title", "Workspace öffnen" }
if !is_empty {
span { class: "field-hint", "{paths.len()} bekannt" }
}
}
div { class: "selector-list-wrap",
if is_empty {
div { class: "empty-state",
span { class: "empty-state-title", "Hier ist noch nichts" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
}
} else {
div { class: "ws-list",
for path in paths.iter() {
WorkspaceEntry {
key: "{path.display()}",
path: path.clone(),
state: state_of(path),
onopen: move |path| open_entry(path),
}
}
}
}
}
div {
class: "workspace-selector-view",
Outlet::<WorkspaceRoute> { }
div { class: "selector-foot",
span { class: "field-hint", "Ein Workspace ist ein Ordner mit Notizen." }
button {
class: if is_empty { "btn btn-primary" } else { "btn" },
onclick: choose,
"Workspace hinzufügen…"
}
}
if let Some(message) = error() {
span { class: "field-error", "{message}" }
}
}
if let Some(root) = pending() {
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"
}
}
}
}
}

233
src/workspace.rs Normal file
View File

@@ -0,0 +1,233 @@
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";
/// 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.
///
/// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil
/// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus
/// `.dionotes/workspace.ron` hier andocken werden.
#[derive(Debug, Clone, PartialEq)]
pub struct Workspace {
pub root: PathBuf,
}
/// Warum ein Ordner sich nicht als Workspace öffnen ließ.
///
/// Die ersten drei Varianten sind genau die Zustände, mit denen ein
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
#[derive(Debug, Clone, PartialEq)]
pub enum OpenError {
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
/// aus der Liste entfernt.
Unreachable,
/// Der Ordner ist da, trägt aber keinen Marker.
NotAWorkspace,
/// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht**
/// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein
/// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den
/// Schutz auszuhebeln.
Damaged,
/// Der Marker stammt aus einer neueren Fassung der App.
TooNew { found: u32 },
Io(String),
}
impl fmt::Display for OpenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unreachable => write!(f, "nicht erreichbar"),
Self::NotAWorkspace => write!(f, "kein Workspace mehr"),
Self::Damaged => write!(f, "beschädigt"),
Self::TooNew { found } => write!(
f,
"mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})"
),
Self::Io(err) => write!(f, "{err}"),
}
}
}
impl From<io::Error> for OpenError {
fn from(err: io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl OpenError {
/// CSS-Klasse fuer das Statusabzeichen am Listeneintrag.
pub fn badge_class(&self) -> &'static str {
match self {
Self::Unreachable => "unreachable",
Self::NotAWorkspace => "no-marker",
Self::Damaged | Self::TooNew { .. } | Self::Io(_) => "damaged",
}
}
}
impl Workspace {
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
pub fn name(&self) -> String {
self.root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
}
fn marker_path(root: &Path) -> PathBuf {
root.join(MARKER_DIR).join(MARKER_FILE)
}
/// 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);
}
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)?;
Ok(Self { root })
}
}
/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen.
pub fn dir_is_empty(path: &Path) -> io::Result<bool> {
Ok(fs::read_dir(path)?.next().is_none())
}
#[cfg(test)]
mod tests {
use super::{dir_is_empty, OpenError, Workspace};
use std::fs;
use std::path::PathBuf;
/// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb
/// braucht es keine Zufallszahl und keine tempfile-Crate.
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dionotes-test-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
dir
}
#[test]
fn name_is_the_folder_name() {
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
assert_eq!(ws.name(), "Notizen");
}
#[test]
fn create_then_open_roundtrip() {
let dir = scratch("create-open");
let created = Workspace::create(dir.clone()).expect("anlegbar");
assert!(Workspace::has_marker(&dir));
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
}
#[test]
fn 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
View 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());
}
}