2 Commits

Author SHA1 Message Date
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 300 additions and 367 deletions

24
Cargo.lock generated
View File

@@ -2329,8 +2329,10 @@ dependencies = [
"dioxus",
"dioxus-code",
"dioxus-code-editor",
"dirs",
"ron",
"serde",
"strum",
"strum_macros",
]
[[package]]
@@ -5887,6 +5889,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 +7029,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,10 @@ 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"
ron = "0.12.2"
serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] }
[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 {

View File

@@ -25,15 +25,6 @@
padding: var(--dn-space-6);
}
.workspace-selector-links {
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);
}
.workspace-selector-view {
width: 100%;
max-width: 34rem;

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,61 +1,26 @@
use dioxus::prelude::*;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
use crate::components::custom_link::custom_link_component::CustomLink;
#[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 {},
}
/// Page to create new Workspace
/// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen
/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt,
/// das Hinzufügen ist Nebenaktion.
#[component]
fn NewWorkspace() -> Element {
pub fn Selector() -> Element {
rsx! {
div {
"New Workspace",
}
}
}
/// Page to open available Workspaces
#[component]
fn OpenWorkspace() -> Element {
rsx! {
div {
"Open Workspace",
}
}
}
/// 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: "workspace-selector-layout",
div { class: "workspace-selector-view",
// TODO: Workspace-Liste aus workspaces.ron laden und darstellen
// (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur
// der leere Zustand.
div { class: "empty-state",
span { class: "empty-state-title", "Noch kein Workspace" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
// TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und
// den Workspace in den Context schreiben.
button { class: "btn btn-primary", disabled: true,
"Workspace hinzufügen…"
}
}
}
div {
class: "workspace-selector-view",
Outlet::<WorkspaceRoute> { }
}
}
}
}

34
src/workspace.rs Normal file
View File

@@ -0,0 +1,34 @@
use std::path::PathBuf;
/// 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,
}
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())
}
}
#[cfg(test)]
mod tests {
use super::Workspace;
use std::path::PathBuf;
#[test]
fn name_is_the_folder_name() {
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
assert_eq!(ws.name(), "Notizen");
}
}

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());
}
}