1 Commits

Author SHA1 Message Date
60f087e813 chore: Selector-UI-Prototyp als Primaerquelle sichern
Drei Varianten des Selector-Bildschirms, umschaltbar per ?variant= und
?state=, gebaut zur Beantwortung von Issue #8. Gewonnen hat B - eine Ansicht,
in der die Workspace-Liste fuehrt und das Hinzufuegen Nebenaktion ist.

Wegwerfcode, absichtlich nicht auf main: A und C haben ihren Zweck mit der
Entscheidung erfuellt, wuerden auf main aber verrotten. Der Prototyp bindet
die echte Projekt-CSS ein, ist also ohne Build im Browser zu oeffnen.
2026-08-28 08:45:08 +02:00
21 changed files with 707 additions and 897 deletions

7
.gitignore vendored
View File

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

26
Cargo.lock generated
View File

@@ -2329,12 +2329,8 @@ dependencies = [
"dioxus",
"dioxus-code",
"dioxus-code-editor",
"dirs",
"rfd",
"ron",
"serde",
"strum",
"tokio",
"strum_macros",
]
[[package]]
@@ -5891,20 +5887,6 @@ 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"
@@ -7031,12 +7013,6 @@ 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,15 +10,8 @@ edition = "2021"
dioxus = { version = "0.7.1", features = ["router"] }
dioxus-code = "0.1.2"
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
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"] }
strum = { version = "0.28.0", features = ["strum_macros", "derive"] }
strum_macros = "0.28.0"
[features]
default = ["desktop"]

View File

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

View File

@@ -49,6 +49,30 @@
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 {
@@ -167,142 +191,6 @@
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,50 +10,38 @@
}
/* --- Workspace selector ----------------------------------------------------
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.
The pre-editor screen: pick or create a workspace. Centered, deliberately
sparse — it has exactly one job.
-------------------------------------------------------------------------- */
.selector-shell {
.workspace-selector-layout {
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;
gap: var(--dn-space-5);
}
.selector-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--dn-space-4);
}
.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);
justify-content: center;
gap: var(--dn-space-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;
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);
}
/* --- Editor shell ----------------------------------------------------------

View File

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

263
assets/tailwind.css Normal file
View File

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

View File

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

18
package-lock.json generated Normal file
View File

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

5
package.json Normal file
View File

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

236
prototype-selector-ui.html Normal file
View File

@@ -0,0 +1,236 @@
<!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,19 +1,16 @@
mod components;
mod route;
mod views;
mod workspace;
mod workspace_list;
mod components;
use dioxus::prelude::*;
use crate::route::Route;
use crate::workspace::Workspace;
use crate::views::editor::dionotes_editor::EditorRoute;
use crate::views::workspace_selector::selector::WorkspaceRoute;
const FAVICON: Asset = asset!("/assets/favicon.ico");
// Linked individually rather than via @import in a single entry file: asset!()
// only emits files it is given by name, so an @import chain would 404 at runtime.
// Order matters — tokens, then baseline, then layout, then components.
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const TOKENS_CSS: Asset = asset!("/assets/css/tokens.css");
const BASE_CSS: Asset = asset!("/assets/css/base.css");
const LAYOUT_CSS: Asset = asset!("/assets/css/layout.css");
@@ -25,17 +22,22 @@ fn main() {
#[component]
fn App() -> Element {
// 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>));
let workspace_selected: Signal<bool> = use_signal(|| false);
rsx! {
document::Link { rel: "icon", href: FAVICON }
// Tailwind first: its preflight lives in @layer base, our unlayered
// rules win the cascade regardless, but this keeps source order honest.
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: TOKENS_CSS }
document::Link { rel: "stylesheet", href: BASE_CSS }
document::Link { rel: "stylesheet", href: LAYOUT_CSS }
document::Link { rel: "stylesheet", href: COMPONENTS_CSS }
Router::<Route> {}
if workspace_selected() {
Router::<EditorRoute> {}
} else {
Router::<WorkspaceRoute> {}
}
}
}

View File

@@ -1,36 +0,0 @@
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,24 +1,32 @@
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 crate::route::Route;
use crate::workspace::Workspace;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
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]
pub fn Home() -> Element {
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! {
@@ -38,7 +46,7 @@ pub fn Hero() -> Element {
}
#[component]
pub fn Editor() -> Element {
fn Editor() -> Element {
let mut source = use_signal(String::new);
rsx! {
@@ -52,34 +60,15 @@ pub fn Editor() -> Element {
}
}
/// 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.
/// Shared navbar component.
#[component]
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! {};
}
fn Navbar() -> Element {
rsx! {
div {
class: "layout-container",
div {
class: "files-bar",
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
for entry in Route::EDITOR_TABS {
for entry in EditorRoute::iter() {
CustomLink {
key: "{entry}",
label: entry.get_message().unwrap_or_default(),
@@ -90,8 +79,8 @@ pub fn EditorShell() -> Element {
}
div {
class: "editor-container",
Outlet::<Route> { }
Outlet::<EditorRoute> { }
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
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 +1,2 @@
pub mod selector;
pub mod create_new_workspace;

View File

@@ -1,220 +1,61 @@
use dioxus::prelude::*;
use std::path::PathBuf;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
use crate::components::custom_link::custom_link_component::CustomLink;
use crate::route::Route;
use crate::workspace::{dir_is_empty, OpenError, Workspace};
use crate::workspace_list::WorkspaceList;
#[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 {},
}
/// 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.
/// Page to create new Workspace
#[component]
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()
}
});
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();
fn NewWorkspace() -> Element {
rsx! {
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: "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),
}
div {
"New Workspace",
}
}
}
/// 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.
/// Page to open available Workspaces
#[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()));
fn OpenWorkspace() -> Element {
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}" }
}
div {
"Open Workspace",
}
}
}
/// 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.
/// Workspace Layout
#[component]
fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler<PathBuf>, oncancel: EventHandler<()>) -> Element {
let shown = root.display().to_string();
let confirmed = root.clone();
fn Workspace() -> Element {
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"
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-view",
Outlet::<WorkspaceRoute> { }
}
}
}
}

View File

@@ -1,233 +0,0 @@
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());
}
}

View File

@@ -1,130 +0,0 @@
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());
}
}

1
tailwind.css Normal file
View File

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