Compare commits
4 Commits
2a27749d3d
...
docs/einst
| Author | SHA1 | Date | |
|---|---|---|---|
| e5990b9da4 | |||
| 35dbb72cb6 | |||
| c8cf525ec5 | |||
| 7917fa08cc |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -264,6 +264,22 @@ The initial UI rendered by the component on the client must be identical to the
|
||||
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
|
||||
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
|
||||
|
||||
## Woran gerade gearbeitet wird
|
||||
|
||||
**Bevor du irgendetwas anderes tust:** Laufende Vorhaben liegen als *Wayfinder-Map* im
|
||||
Issue-Tracker, nicht in diesem Repo. Die offene Map findest du mit
|
||||
|
||||
```bash
|
||||
tea issues ls --labels wayfinder:map --state open
|
||||
```
|
||||
|
||||
Lies ihren Beschreibungstext (Destination, Notes, Decisions so far) **und ihren letzten
|
||||
Kommentar** — dort steht der Arbeitsstand samt Einstiegspunkt. Erst danach lohnt der Blick
|
||||
in den Code.
|
||||
|
||||
Ist keine Map offen, gibt es kein laufendes Planungsvorhaben; dann gilt die normale
|
||||
Auftragslage aus dem Gespräch.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -2333,7 +2333,6 @@ dependencies = [
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde",
|
||||
"strum",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -6414,27 +6413,6 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsecond"
|
||||
version = "0.7.10"
|
||||
|
||||
@@ -17,7 +17,6 @@ dirs = "6.0.0"
|
||||
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]
|
||||
|
||||
61
README.md
61
README.md
@@ -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/).
|
||||
|
||||
@@ -9,8 +9,18 @@
|
||||
|
||||
.file-selector {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1) var(--dn-space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--dn-space-1);
|
||||
/* Einrückung pro Baumebene — --depth setzt die Komponente je Knoten. */
|
||||
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1)
|
||||
calc(var(--dn-space-3) + var(--depth, 0) * var(--dn-space-4));
|
||||
/* Als <button> im Baum: Browser-Vorgaben zurücknehmen. */
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--dn-radius-sm);
|
||||
color: var(--dn-fg-muted);
|
||||
font-family: var(--dn-font-code);
|
||||
@@ -23,6 +33,19 @@
|
||||
background-color var(--dn-dur-fast) var(--dn-ease);
|
||||
}
|
||||
|
||||
/* Das Dreieck hält seinen Platz auch bei Dateien, damit Ordner- und
|
||||
Dateinamen derselben Ebene bündig stehen. */
|
||||
.tree-caret {
|
||||
flex: none;
|
||||
width: 0.9em;
|
||||
color: var(--dn-fg-faint);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.file-selector.is-folder {
|
||||
color: var(--dn-fg);
|
||||
}
|
||||
|
||||
.file-selector::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -88,6 +111,12 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Quadratischer Knopf für ein einzelnes Zeichen — Refresh, Schließen. */
|
||||
.btn-icon {
|
||||
padding: var(--dn-space-1) var(--dn-space-2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--dn-bg-raised);
|
||||
border-color: transparent;
|
||||
|
||||
@@ -80,6 +80,22 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Kopfzeile der Datei-Leiste: Beschriftung und Neu-einlesen. Bleibt stehen,
|
||||
während der Baum darunter scrollt. */
|
||||
.files-bar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--dn-space-2);
|
||||
padding: var(--dn-space-1) var(--dn-space-1) var(--dn-space-1) var(--dn-space-2);
|
||||
}
|
||||
|
||||
.files-bar-empty {
|
||||
padding: var(--dn-space-3) var(--dn-space-2);
|
||||
color: var(--dn-fg-faint);
|
||||
font-size: var(--dn-text-xs);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB |
275
assets/tailwind.css
Normal file
275
assets/tailwind.css
Normal file
@@ -0,0 +1,275 @@
|
||||
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
}
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
}
|
||||
@property --tw-rotate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-rotate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-rotate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-skew-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-skew-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-border-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-rotate-x: initial;
|
||||
--tw-rotate-y: initial;
|
||||
--tw-rotate-z: initial;
|
||||
--tw-skew-x: initial;
|
||||
--tw-skew-y: initial;
|
||||
--tw-border-style: solid;
|
||||
--tw-outline-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,19 @@ Issues and specs for this repo live as **Gitea** issues at
|
||||
<https://gitlab.hive-brain.de/RDietermann/dionotes-client>.
|
||||
|
||||
> The host is named `gitlab.hive-brain.de`, but the server is **Gitea 1.25.5**, not GitLab.
|
||||
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (v0.15 installed).
|
||||
> `glab` will not work. Use the [`tea`](https://gitea.com/gitea/tea) CLI (developed against v0.15).
|
||||
|
||||
**Auf einem frischen Gerät** ist `tea` erst einzurichten — ohne das schlägt jedes Kommando
|
||||
unten fehl:
|
||||
|
||||
```bash
|
||||
tea login add --name hive --url https://gitlab.hive-brain.de --token <token>
|
||||
tea login list # prueft, ob es sitzt
|
||||
```
|
||||
|
||||
Der Token braucht mindestens `write:issue`, `write:repository` und `write:user`. Der hier
|
||||
verwendete hat **kein** `read:admin` und **kein** `write:organization` — Wartungs-API und
|
||||
Anlegen unter einer Organisation scheitern damit.
|
||||
|
||||
Run `tea` from inside the clone — it resolves owner/repo from `git remote`. It prints
|
||||
`NOTE: no login matched this repository, falling back to login 'hive'` on every call;
|
||||
@@ -61,27 +73,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
18
package-lock.json
generated
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.3.3"
|
||||
}
|
||||
}
|
||||
10
src/main.rs
10
src/main.rs
@@ -1,11 +1,14 @@
|
||||
mod components;
|
||||
mod route;
|
||||
mod tree;
|
||||
mod views;
|
||||
mod workspace;
|
||||
mod workspace_list;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::route::Route;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
@@ -14,6 +17,7 @@ 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");
|
||||
@@ -29,9 +33,15 @@ fn App() -> Element {
|
||||
// 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>));
|
||||
// Die im Editor geöffnete Notiz. Getrennt vom Workspace, weil sie sich
|
||||
// ändert, ohne dass der Workspace wechselt.
|
||||
use_context_provider(|| Signal::new(None::<PathBuf>));
|
||||
|
||||
rsx! {
|
||||
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 }
|
||||
|
||||
14
src/route.rs
14
src/route.rs
@@ -1,5 +1,4 @@
|
||||
use dioxus::prelude::*;
|
||||
use strum::EnumMessage;
|
||||
|
||||
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
|
||||
use crate::views::workspace_selector::selector::Selector;
|
||||
@@ -9,28 +8,17 @@ use crate::views::workspace_selector::selector::Selector;
|
||||
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
|
||||
/// 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)]
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[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 {}];
|
||||
}
|
||||
|
||||
194
src/tree.rs
Normal file
194
src/tree.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Ein Knoten im Notiz-Baum.
|
||||
///
|
||||
/// Verschachtelt statt flache Arena: Der Baum wird beim Öffnen einmal
|
||||
/// aufgebaut und danach nur gelesen — die Indirektion einer Arena zahlt sich
|
||||
/// erst aus, wenn Knoten wandern oder sich gegenseitig referenzieren.
|
||||
///
|
||||
/// Als Enum statt als Struct mit `children: Vec<_>`, weil damit unmöglich
|
||||
/// wird, was ein Struct erlaubt: eine Datei mit Kindern.
|
||||
///
|
||||
/// Eine einzige `File`-Variante für alle Dateitypen. Wer wissen will, ob es
|
||||
/// eine Notiz ist, fragt `path.extension()` — das ist eine Zeile an der
|
||||
/// Stelle, die es braucht, statt einer Klassifizierung, die der Baum beim
|
||||
/// Aufbau festschreibt.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TreeNode {
|
||||
File {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
Folder {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
children: Vec<TreeNode>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TreeNode {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::File { name, .. } | Self::Folder { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
match self {
|
||||
Self::File { path, .. } | Self::Folder { path, .. } => path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_folder(&self) -> bool {
|
||||
matches!(self, Self::Folder { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest den Inhalt eines Ordners rekursiv ein und liefert seine Kinder.
|
||||
///
|
||||
/// `std::fs::read_dir` mit eigener Rekursion statt `walkdir`: Letzteres liegt
|
||||
/// zwar bereits im Abhängigkeitsgraph, iteriert aber flach — daraus einen
|
||||
/// Baum zusammenzusetzen wäre mehr Code, nicht weniger.
|
||||
pub fn read_tree(root: &Path) -> io::Result<Vec<TreeNode>> {
|
||||
let mut nodes = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
// Alles mit führendem Punkt bleibt draußen. Das erledigt `.dionotes`
|
||||
// und `.git` in einem Aufwasch, ohne eine Ausnahmeliste, die bei
|
||||
// `.obsidian` oder `.vscode` wieder wachsen müsste.
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
// `symlink_metadata` folgt dem Link nicht, `metadata` schon. Symlinks
|
||||
// zu folgen hieße, einen Zyklusschutz über kanonisierte Pfade
|
||||
// mitzuführen — realer Aufwand für einen Fall, den in einem
|
||||
// Notizordner kaum jemand hat, während ein Hänger beim Öffnen für den
|
||||
// Nutzer schwer zu deuten wäre.
|
||||
let Ok(meta) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
// Ein Unterordner ohne Leserecht wird leer dargestellt, statt den
|
||||
// ganzen Baum scheitern zu lassen.
|
||||
let children = read_tree(&path).unwrap_or_default();
|
||||
nodes.push(TreeNode::Folder { name, path, children });
|
||||
} else {
|
||||
nodes.push(TreeNode::File { name, path });
|
||||
}
|
||||
}
|
||||
|
||||
sort(&mut nodes);
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Ordner zuerst, dann Dateien, jeweils alphabetisch ohne Rücksicht auf
|
||||
/// Groß-/Kleinschreibung — die Konvention aus jedem Dateibrowser. `read_dir`
|
||||
/// liefert plattformabhängig unsortiert.
|
||||
fn sort(nodes: &mut [TreeNode]) {
|
||||
nodes.sort_by(|a, b| {
|
||||
b.is_folder()
|
||||
.cmp(&a.is_folder())
|
||||
.then_with(|| a.name().to_lowercase().cmp(&b.name().to_lowercase()))
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{read_tree, TreeNode};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dionotes-tree-{name}"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
|
||||
dir
|
||||
}
|
||||
|
||||
fn names(nodes: &[TreeNode]) -> Vec<&str> {
|
||||
nodes.iter().map(|n| n.name()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_come_first_then_files_each_alphabetically() {
|
||||
let dir = scratch("sorting");
|
||||
fs::write(dir.join("zebra.md"), "").unwrap();
|
||||
fs::write(dir.join("Apfel.md"), "").unwrap();
|
||||
fs::create_dir(dir.join("Werkzeug")).unwrap();
|
||||
fs::create_dir(dir.join("archiv")).unwrap();
|
||||
|
||||
let tree = read_tree(&dir).unwrap();
|
||||
// Gross-/Kleinschreibung darf die Reihenfolge nicht bestimmen.
|
||||
assert_eq!(names(&tree), ["archiv", "Werkzeug", "Apfel.md", "zebra.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_file_type_is_included_not_just_markdown() {
|
||||
let dir = scratch("all-files");
|
||||
fs::write(dir.join("notiz.md"), "").unwrap();
|
||||
fs::write(dir.join("bild.png"), "").unwrap();
|
||||
fs::write(dir.join("ohne-endung"), "").unwrap();
|
||||
|
||||
let tree = read_tree(&dir).unwrap();
|
||||
assert_eq!(names(&tree), ["bild.png", "notiz.md", "ohne-endung"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_names_are_skipped_files_and_folders_alike() {
|
||||
let dir = scratch("hidden");
|
||||
fs::create_dir_all(dir.join(".dionotes")).unwrap();
|
||||
fs::create_dir_all(dir.join(".git").join("objects")).unwrap();
|
||||
fs::write(dir.join(".gitignore"), "").unwrap();
|
||||
fs::write(dir.join("sichtbar.md"), "").unwrap();
|
||||
|
||||
assert_eq!(names(&read_tree(&dir).unwrap()), ["sichtbar.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_folders_are_shown() {
|
||||
let dir = scratch("empty-folder");
|
||||
fs::create_dir(dir.join("leer")).unwrap();
|
||||
|
||||
let tree = read_tree(&dir).unwrap();
|
||||
match &tree[..] {
|
||||
[TreeNode::Folder { name, children, .. }] => {
|
||||
assert_eq!(name, "leer");
|
||||
assert!(children.is_empty());
|
||||
}
|
||||
other => panic!("erwartet ein leerer Ordner, war {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_recursive() {
|
||||
let dir = scratch("nested");
|
||||
fs::create_dir_all(dir.join("a").join("b")).unwrap();
|
||||
fs::write(dir.join("a").join("b").join("tief.md"), "").unwrap();
|
||||
|
||||
let tree = read_tree(&dir).unwrap();
|
||||
let TreeNode::Folder { children: a, .. } = &tree[0] else {
|
||||
panic!("erwartet Ordner a");
|
||||
};
|
||||
let TreeNode::Folder { children: b, .. } = &a[0] else {
|
||||
panic!("erwartet Ordner b");
|
||||
};
|
||||
assert_eq!(names(b), ["tief.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_is_an_error_not_an_empty_tree() {
|
||||
let dir = scratch("no-root").join("gibtsnicht");
|
||||
assert!(read_tree(&dir).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,64 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_code::Theme;
|
||||
use dioxus_code_editor::{CodeEditor, Language};
|
||||
use strum::EnumMessage;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::components::custom_link::custom_link_component::CustomLink;
|
||||
use crate::route::Route;
|
||||
use crate::tree::{read_tree, TreeNode};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
||||
|
||||
/// Home page
|
||||
/// Was rechts steht, solange keine Notiz gewählt ist.
|
||||
///
|
||||
/// TODO: Sobald ein Klick im Baum eine Notiz öffnet, ist das der Ort für den
|
||||
/// zuletzt bearbeiteten Stand oder eine Kurzhilfe. Gehört zum Notiz-Handling.
|
||||
#[component]
|
||||
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! {
|
||||
div {
|
||||
id: "hero",
|
||||
img { src: HEADER_SVG, id: "header" }
|
||||
div { id: "links",
|
||||
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
|
||||
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
|
||||
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
|
||||
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
|
||||
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
|
||||
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
|
||||
}
|
||||
div { class: "empty-state",
|
||||
span { class: "empty-state-title", "Keine Notiz geöffnet" }
|
||||
span { "Wähle links eine Notiz aus." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Editor() -> Element {
|
||||
let note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let mut source = use_signal(String::new);
|
||||
|
||||
// Beim Wechsel der Notiz den Inhalt nachladen — auf einem eigenen Thread,
|
||||
// damit eine große Datei auf langsamem Laufwerk nicht die Oberfläche hält.
|
||||
use_effect(move || {
|
||||
let path = note.read().clone();
|
||||
spawn(async move {
|
||||
let text = match path {
|
||||
Some(path) => tokio::task::spawn_blocking(move || {
|
||||
std::fs::read_to_string(path).unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
source.set(text);
|
||||
});
|
||||
});
|
||||
|
||||
rsx! {
|
||||
CodeEditor {
|
||||
class: "editor-class",
|
||||
value: source(),
|
||||
language: Language::Markdown,
|
||||
theme: Theme::CATPPUCCIN_MOCHA,
|
||||
// TODO: Änderungen landen bisher nirgends. Speichern, Autosave und
|
||||
// der Umgang mit ungesicherten Änderungen gehören zum
|
||||
// Notiz-Handling und damit in den nächsten Effort.
|
||||
oninput: move |value| source.set(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
|
||||
/// Die Editor-Hülle: Notiz-Baum links, Editorfläche rechts.
|
||||
///
|
||||
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
|
||||
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
|
||||
@@ -68,25 +74,51 @@ pub fn EditorShell() -> Element {
|
||||
}
|
||||
});
|
||||
|
||||
// Offene Ordner liegen **neben** dem Baum, nicht darin: Es gibt keinen
|
||||
// Filewatcher, der Nutzer liest bei Bedarf neu ein — steckte `expanded`
|
||||
// im Knoten, klappte jeder Refresh sämtliche Ordner zu.
|
||||
let expanded = use_signal(HashSet::<PathBuf>::new);
|
||||
|
||||
// Einlesen auf einem eigenen Thread: Ein Workspace auf einem langsamen
|
||||
// Laufwerk würde die Oberfläche sonst blockieren.
|
||||
let mut tree = use_resource(move || {
|
||||
let root = workspace.read().as_ref().map(|ws| ws.root.clone());
|
||||
async move {
|
||||
let Some(root) = root else {
|
||||
return Vec::new();
|
||||
};
|
||||
tokio::task::spawn_blocking(move || read_tree(&root).unwrap_or_default())
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
});
|
||||
|
||||
if workspace.read().is_none() {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let nodes = tree.read().clone().unwrap_or_default();
|
||||
|
||||
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 {
|
||||
CustomLink {
|
||||
key: "{entry}",
|
||||
label: entry.get_message().unwrap_or_default(),
|
||||
href: entry.clone(),
|
||||
css: "file-selector"
|
||||
div { class: "files-bar-head",
|
||||
span { class: "field-label", "Notizen" }
|
||||
// Kein Filewatcher — externe Änderungen holt man sich hier.
|
||||
button {
|
||||
class: "btn btn-ghost btn-icon",
|
||||
title: "Neu einlesen",
|
||||
onclick: move |_| tree.restart(),
|
||||
"⟳"
|
||||
}
|
||||
}
|
||||
if nodes.is_empty() {
|
||||
div { class: "files-bar-empty", "Keine Notizen in diesem Workspace." }
|
||||
} else {
|
||||
TreeLevel { nodes: nodes.clone(), expanded, depth: 0 }
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "editor-container",
|
||||
@@ -95,3 +127,61 @@ pub fn EditorShell() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Ebene des Baums. Ruft sich für aufgeklappte Ordner selbst auf.
|
||||
#[component]
|
||||
fn TreeLevel(nodes: Vec<TreeNode>, expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element {
|
||||
rsx! {
|
||||
for node in nodes {
|
||||
TreeEntry { key: "{node.path().display()}", node: node.clone(), expanded, depth }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TreeEntry(node: TreeNode, mut expanded: Signal<HashSet<PathBuf>>, depth: usize) -> Element {
|
||||
let mut note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let navigator = use_navigator();
|
||||
let path = node.path().to_path_buf();
|
||||
let name = node.name().to_string();
|
||||
let is_open = expanded.read().contains(&path);
|
||||
let is_current = note.read().as_deref() == Some(path.as_path());
|
||||
|
||||
match node {
|
||||
TreeNode::Folder { children, .. } => {
|
||||
let toggle_path = path.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "file-selector is-folder",
|
||||
style: "--depth: {depth}",
|
||||
onclick: move |_| {
|
||||
let mut open = expanded.write();
|
||||
if !open.remove(&toggle_path) {
|
||||
open.insert(toggle_path.clone());
|
||||
}
|
||||
},
|
||||
span { class: "tree-caret", if is_open { "▾" } else { "▸" } }
|
||||
"{name}"
|
||||
}
|
||||
if is_open {
|
||||
TreeLevel { nodes: children, expanded, depth: depth + 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeNode::File { .. } => {
|
||||
let opened = path.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: if is_current { "file-selector active" } else { "file-selector" },
|
||||
style: "--depth: {depth}",
|
||||
onclick: move |_| {
|
||||
note.set(Some(opened.clone()));
|
||||
navigator.push(Route::Editor {});
|
||||
},
|
||||
span { class: "tree-caret" }
|
||||
"{name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::workspace_list::WorkspaceList;
|
||||
#[component]
|
||||
pub fn Selector() -> Element {
|
||||
let mut workspace = use_context::<Signal<Option<Workspace>>>();
|
||||
let mut note = use_context::<Signal<Option<PathBuf>>>();
|
||||
let mut list = use_signal(WorkspaceList::load);
|
||||
let navigator = use_navigator();
|
||||
|
||||
@@ -50,18 +51,32 @@ pub fn Selector() -> Element {
|
||||
)));
|
||||
}
|
||||
workspace.set(Some(opened));
|
||||
navigator.push(Route::Home {});
|
||||
// Mit Notiz direkt in den Editor, sonst auf den leeren Stand.
|
||||
if note.read().is_some() {
|
||||
navigator.push(Route::Editor {});
|
||||
} else {
|
||||
navigator.push(Route::Home {});
|
||||
}
|
||||
};
|
||||
|
||||
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
|
||||
Ok(opened) => accept(opened),
|
||||
Ok(opened) => {
|
||||
// Ein frischer Workspace bringt eine Kurzanleitung mit; die wird
|
||||
// gleich angezeigt, statt den Nutzer vor eine leere Fläche zu setzen.
|
||||
note.set(opened.welcome_note());
|
||||
accept(opened);
|
||||
}
|
||||
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
// 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),
|
||||
Ok(opened) => {
|
||||
// Ein bestehender Workspace öffnet ohne vorgewählte Notiz.
|
||||
note.set(None);
|
||||
accept(opened);
|
||||
}
|
||||
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,35 @@ use std::path::{Path, PathBuf};
|
||||
const MARKER_DIR: &str = ".dionotes";
|
||||
const MARKER_FILE: &str = "workspace.ron";
|
||||
|
||||
/// Kurzanleitung, die ein frisch angelegter Workspace mitbekommt.
|
||||
pub const WELCOME_FILE: &str = "Willkommen.md";
|
||||
|
||||
const WELCOME_TEXT: &str = "# Willkommen bei DioNotes
|
||||
|
||||
Dieser Ordner ist jetzt ein **Workspace** — eine Sammlung von Notizen, die als
|
||||
gewöhnliche Markdown-Dateien nebeneinander liegen. Du kannst sie jederzeit mit
|
||||
jedem anderen Programm öffnen, sichern oder synchronisieren.
|
||||
|
||||
## Wie es aufgebaut ist
|
||||
|
||||
- Jede Notiz ist eine `.md`-Datei in diesem Ordner.
|
||||
- **Unterordner funktionieren.** Sie erscheinen links als aufklappbarer Baum.
|
||||
- Der versteckte Ordner `.dionotes` macht diesen Ordner zum Workspace. Dort
|
||||
landen später auch die Einstellungen. Lösche ihn nicht — ohne ihn erkennt
|
||||
DioNotes den Ordner nicht wieder.
|
||||
- Alles, was mit einem Punkt beginnt, bleibt in der Liste unsichtbar. Ein
|
||||
`.git`-Ordner stört also nicht.
|
||||
|
||||
## Gut zu wissen
|
||||
|
||||
DioNotes liest den Ordner beim Öffnen einmal ein. Änderst du etwas von außen —
|
||||
etwa über einen Sync-Dienst —, hol dir den neuen Stand mit dem Knopf **⟳** über
|
||||
der Notizliste.
|
||||
|
||||
Diese Datei ist eine ganz normale Notiz. Du kannst sie überschreiben oder
|
||||
löschen, sobald du sie nicht mehr brauchst.
|
||||
";
|
||||
|
||||
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
|
||||
/// die eine ältere App nicht bloß ignorieren kann.
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
@@ -101,6 +130,12 @@ impl Workspace {
|
||||
root.join(MARKER_DIR).join(MARKER_FILE)
|
||||
}
|
||||
|
||||
/// Die Willkommensnotiz, falls dieser Workspace eine bekommen hat.
|
||||
pub fn welcome_note(&self) -> Option<PathBuf> {
|
||||
let path = self.root.join(WELCOME_FILE);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
/// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
|
||||
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
|
||||
pub fn has_marker(root: &Path) -> bool {
|
||||
@@ -133,6 +168,10 @@ impl Workspace {
|
||||
if !root.is_dir() {
|
||||
return Err(OpenError::Unreachable);
|
||||
}
|
||||
// Zuerst prüfen: Sobald `.dionotes` angelegt ist, ist der Ordner
|
||||
// nie mehr leer.
|
||||
let was_empty = dir_is_empty(&root).unwrap_or(false);
|
||||
|
||||
let marker_path = Self::marker_path(&root);
|
||||
if let Some(dir) = marker_path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
@@ -141,6 +180,14 @@ impl Workspace {
|
||||
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
fs::write(&marker_path, text)?;
|
||||
|
||||
// Nur in einen frischen Workspace. Wer einen Ordner voller Notizen
|
||||
// importiert, will keine fremde Datei dazwischen — er kennt das
|
||||
// Programm dann meist schon.
|
||||
if was_empty {
|
||||
let _ = fs::write(root.join(WELCOME_FILE), WELCOME_TEXT);
|
||||
}
|
||||
|
||||
Ok(Self { root })
|
||||
}
|
||||
}
|
||||
@@ -179,6 +226,26 @@ mod tests {
|
||||
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_workspace_gets_a_welcome_note() {
|
||||
let dir = scratch("welcome-fresh");
|
||||
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||
|
||||
let note = ws.welcome_note().expect("Willkommensnotiz vorhanden");
|
||||
assert!(note.ends_with("Willkommen.md"));
|
||||
assert!(fs::read_to_string(¬e).unwrap().contains("Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_folder_gets_no_welcome_note() {
|
||||
let dir = scratch("welcome-import");
|
||||
fs::write(dir.join("eigene-notiz.md"), "# Meins").unwrap();
|
||||
|
||||
let ws = Workspace::create(dir.clone()).expect("anlegbar");
|
||||
assert!(ws.welcome_note().is_none(), "Import darf keine fremde Datei bekommen");
|
||||
assert!(!dir.join("Willkommen.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_leaves_existing_files_alone() {
|
||||
let dir = scratch("create-keeps-files");
|
||||
|
||||
1
tailwind.css
Normal file
1
tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user