Compare commits
4 Commits
prototype/
...
570c804d3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 570c804d3f | |||
| 276bc67dbe | |||
| 37a7bab30f | |||
| 91fd351d59 |
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -2329,8 +2329,12 @@ dependencies = [
|
||||
"dioxus",
|
||||
"dioxus-code",
|
||||
"dioxus-code-editor",
|
||||
"dirs",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5887,6 +5891,20 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -7013,6 +7031,12 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -10,8 +10,15 @@ edition = "2021"
|
||||
dioxus = { version = "0.7.1", features = ["router"] }
|
||||
dioxus-code = "0.1.2"
|
||||
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
|
||||
strum = { version = "0.28.0", features = ["strum_macros", "derive"] }
|
||||
strum_macros = "0.28.0"
|
||||
dirs = "6.0.0"
|
||||
# Dieselbe Konfiguration, die dioxus-desktop ohnehin zieht. Mit den
|
||||
# Default-Features kaemen neun Wayland-Pakete dazu, die xdg-portal nicht
|
||||
# braucht — der Dialog laeuft unter Linux ueber das Portal.
|
||||
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||
ron = "0.12.2"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
tokio = { version = "1.53.1", features = ["rt"] }
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
|
||||
@@ -49,30 +49,6 @@
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
/* --- Workspace selector tab ------------------------------------------------- */
|
||||
|
||||
.workspace-selector {
|
||||
padding: var(--dn-space-2) var(--dn-space-4);
|
||||
border-radius: var(--dn-radius);
|
||||
color: var(--dn-fg-muted);
|
||||
font-size: var(--dn-text-base);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color var(--dn-dur-fast) var(--dn-ease),
|
||||
background-color var(--dn-dur-fast) var(--dn-ease);
|
||||
}
|
||||
|
||||
.workspace-selector:hover {
|
||||
color: var(--dn-fg);
|
||||
background: var(--dn-bg-raised);
|
||||
}
|
||||
|
||||
.workspace-selector.active {
|
||||
color: var(--dn-fg-on-accent);
|
||||
background: var(--dn-accent);
|
||||
}
|
||||
|
||||
/* --- Buttons ---------------------------------------------------------------- */
|
||||
|
||||
.btn {
|
||||
@@ -191,6 +167,142 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Workspace-Listeneintrag ---------------------------------------------------
|
||||
Ordnername, darunter der Pfad, rechts bei Bedarf ein Statusabzeichen. Ein
|
||||
defekter Eintrag wird gedämpft, bleibt aber anklickbar: Ein Klick versucht
|
||||
es erneut, womit „Stick wieder eingesteckt" ohne eigenen Knopf abgedeckt ist.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.ws-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--dn-space-3);
|
||||
width: 100%;
|
||||
padding: var(--dn-space-3);
|
||||
border: 0;
|
||||
border-radius: var(--dn-radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dn-dur-fast) var(--dn-ease);
|
||||
}
|
||||
|
||||
.ws-item:hover {
|
||||
background: var(--dn-bg-raised);
|
||||
}
|
||||
|
||||
.ws-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ws-item-name {
|
||||
color: var(--dn-fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Bei Überlänge vorne kürzen: Das Ende eines Pfades sagt mehr als sein Anfang. */
|
||||
.ws-item-path {
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
color: var(--dn-fg-faint);
|
||||
font-family: var(--dn-font-code);
|
||||
font-size: var(--dn-text-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ws-item.is-broken .ws-item-name {
|
||||
color: var(--dn-fg-muted);
|
||||
}
|
||||
|
||||
.ws-badge {
|
||||
flex: none;
|
||||
padding: 2px var(--dn-space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--dn-text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Drei Zustände, drei Farben — sie führen zu verschiedenen Handlungen:
|
||||
Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. */
|
||||
.ws-badge.unreachable {
|
||||
background: color-mix(in oklab, var(--dn-warning) 18%, transparent);
|
||||
color: var(--dn-warning);
|
||||
}
|
||||
|
||||
.ws-badge.no-marker {
|
||||
background: color-mix(in oklab, var(--dn-fg-faint) 22%, transparent);
|
||||
color: var(--dn-fg-muted);
|
||||
}
|
||||
|
||||
.ws-badge.damaged {
|
||||
background: color-mix(in oklab, var(--dn-danger) 18%, transparent);
|
||||
color: var(--dn-danger);
|
||||
}
|
||||
|
||||
/* --- Dialog --------------------------------------------------------------------
|
||||
Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline
|
||||
an ihren Ort, nicht in ein zweites Fenster.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.dialog-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--dn-space-4);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(28rem, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dn-space-4);
|
||||
padding: var(--dn-space-5);
|
||||
border: 1px solid var(--dn-border-strong);
|
||||
border-radius: var(--dn-radius-lg);
|
||||
background: var(--dn-bg-sunken);
|
||||
box-shadow: var(--dn-shadow);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
font-size: var(--dn-text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
color: var(--dn-fg-muted);
|
||||
line-height: var(--dn-leading-normal);
|
||||
}
|
||||
|
||||
.dialog code {
|
||||
font-family: var(--dn-font-code);
|
||||
font-size: var(--dn-text-sm);
|
||||
color: var(--dn-fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--dn-space-2);
|
||||
}
|
||||
|
||||
/* --- Utilities ---------------------------------------------------------------- */
|
||||
|
||||
.visually-hidden {
|
||||
|
||||
@@ -10,38 +10,50 @@
|
||||
}
|
||||
|
||||
/* --- Workspace selector ----------------------------------------------------
|
||||
The pre-editor screen: pick or create a workspace. Centered, deliberately
|
||||
sparse — it has exactly one job.
|
||||
The pre-editor screen. One view, no tabs: the list of known workspaces
|
||||
leads, adding one is the secondary action in the footer. Opening is the
|
||||
everyday case; adding happens once per workspace.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.workspace-selector-layout {
|
||||
.selector-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--dn-space-7) var(--dn-space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--dn-space-6);
|
||||
padding: var(--dn-space-6);
|
||||
gap: var(--dn-space-5);
|
||||
}
|
||||
|
||||
.workspace-selector-links {
|
||||
.selector-head {
|
||||
display: flex;
|
||||
gap: var(--dn-space-1);
|
||||
padding: var(--dn-space-1);
|
||||
background: var(--dn-bg-sunken);
|
||||
border: 1px solid var(--dn-border);
|
||||
border-radius: var(--dn-radius-lg);
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--dn-space-4);
|
||||
}
|
||||
|
||||
.workspace-selector-view {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
padding: var(--dn-space-6);
|
||||
background: var(--dn-bg-sunken);
|
||||
border: 1px solid var(--dn-border);
|
||||
border-radius: var(--dn-radius-lg);
|
||||
box-shadow: var(--dn-shadow);
|
||||
.selector-title {
|
||||
font-size: var(--dn-text-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Nur die Liste scrollt — Kopf und Fuß bleiben stehen. */
|
||||
.selector-list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.selector-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--dn-space-3);
|
||||
padding-top: var(--dn-space-4);
|
||||
border-top: 1px solid var(--dn-border);
|
||||
}
|
||||
|
||||
/* --- Editor shell ----------------------------------------------------------
|
||||
|
||||
@@ -198,9 +198,18 @@
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
@@ -208,6 +217,9 @@
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
|
||||
24
src/main.rs
24
src/main.rs
@@ -1,9 +1,13 @@
|
||||
mod views;
|
||||
mod components;
|
||||
mod route;
|
||||
mod views;
|
||||
mod workspace;
|
||||
mod workspace_list;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use crate::views::editor::dionotes_editor::EditorRoute;
|
||||
use crate::views::workspace_selector::selector::WorkspaceRoute;
|
||||
|
||||
use crate::route::Route;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
|
||||
@@ -22,7 +26,11 @@ fn main() {
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
let workspace_selected: Signal<bool> = use_signal(|| false);
|
||||
// Der offene Workspace als Context, nicht als Prop: Er wird im Selector
|
||||
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
|
||||
// Outlet, durch das sich keine Props durchreichen lassen.
|
||||
use_context_provider(|| Signal::new(None::<Workspace>));
|
||||
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
// Tailwind first: its preflight lives in @layer base, our unlayered
|
||||
@@ -32,12 +40,6 @@ fn App() -> Element {
|
||||
document::Link { rel: "stylesheet", href: BASE_CSS }
|
||||
document::Link { rel: "stylesheet", href: LAYOUT_CSS }
|
||||
document::Link { rel: "stylesheet", href: COMPONENTS_CSS }
|
||||
if workspace_selected() {
|
||||
Router::<EditorRoute> {}
|
||||
} else {
|
||||
Router::<WorkspaceRoute> {}
|
||||
}
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
36
src/route.rs
Normal file
36
src/route.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use dioxus::prelude::*;
|
||||
use strum::EnumMessage;
|
||||
|
||||
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
|
||||
use crate::views::workspace_selector::selector::Selector;
|
||||
|
||||
/// Alle Routen der App in einem Enum.
|
||||
///
|
||||
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
|
||||
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
|
||||
/// URL zu sehen bekommen, die er nicht kennt.
|
||||
#[derive(Debug, Clone, Routable, PartialEq, EnumMessage)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[nest("/workspace")]
|
||||
#[layout(EditorShell)]
|
||||
#[route("/")]
|
||||
#[strum(message = "Home")]
|
||||
Home {},
|
||||
#[route("/editor")]
|
||||
#[strum(message = "Editor")]
|
||||
Editor {},
|
||||
#[end_layout]
|
||||
#[end_nest]
|
||||
#[route("/")]
|
||||
Selector {},
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// Die Einträge der Datei-Leiste im Editor.
|
||||
///
|
||||
/// Explizit statt über `EnumIter`, weil das Enum jetzt auch den Selector
|
||||
/// enthält und der in dieser Leiste nichts zu suchen hat. Wermutstropfen:
|
||||
/// eine neue Editor-Route hier einzutragen erzwingt der Compiler nicht.
|
||||
pub const EDITOR_TABS: [Self; 2] = [Self::Home {}, Self::Editor {}];
|
||||
}
|
||||
@@ -1,32 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_code::Theme;
|
||||
use dioxus_code_editor::{CodeEditor, Language};
|
||||
use strum::EnumMessage;
|
||||
|
||||
use crate::components::custom_link::custom_link_component::CustomLink;
|
||||
use strum::{EnumMessage, IntoEnumIterator};
|
||||
use strum_macros::EnumIter;
|
||||
use crate::route::Route;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
||||
|
||||
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)]
|
||||
#[rustfmt::skip]
|
||||
pub enum EditorRoute {
|
||||
#[layout(Navbar)]
|
||||
#[route("/")]
|
||||
#[strum(message = "Home")]
|
||||
Home {},
|
||||
#[route("/editor")]
|
||||
#[strum(message = "Editor")]
|
||||
Editor {},
|
||||
}
|
||||
|
||||
/// Home page
|
||||
#[component]
|
||||
fn Home() -> Element {
|
||||
pub fn Home() -> Element {
|
||||
rsx! {
|
||||
Hero {}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
|
||||
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
|
||||
#[component]
|
||||
pub fn Hero() -> Element {
|
||||
rsx! {
|
||||
@@ -46,7 +38,7 @@ pub fn Hero() -> Element {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Editor() -> Element {
|
||||
pub fn Editor() -> Element {
|
||||
let mut source = use_signal(String::new);
|
||||
|
||||
rsx! {
|
||||
@@ -60,15 +52,34 @@ fn Editor() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared navbar component.
|
||||
/// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
|
||||
///
|
||||
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
|
||||
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
|
||||
/// während die Route bestehen bleibt.
|
||||
#[component]
|
||||
fn Navbar() -> Element {
|
||||
pub fn EditorShell() -> Element {
|
||||
let workspace = use_context::<Signal<Option<Workspace>>>();
|
||||
let navigator = use_navigator();
|
||||
|
||||
use_effect(move || {
|
||||
if workspace.read().is_none() {
|
||||
navigator.replace(Route::Selector {});
|
||||
}
|
||||
});
|
||||
|
||||
if workspace.read().is_none() {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "layout-container",
|
||||
div {
|
||||
class: "files-bar",
|
||||
for entry in EditorRoute::iter() {
|
||||
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
|
||||
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
|
||||
for entry in Route::EDITOR_TABS {
|
||||
CustomLink {
|
||||
key: "{entry}",
|
||||
label: entry.get_message().unwrap_or_default(),
|
||||
@@ -79,8 +90,8 @@ fn Navbar() -> Element {
|
||||
}
|
||||
div {
|
||||
class: "editor-container",
|
||||
Outlet::<EditorRoute> { }
|
||||
Outlet::<Route> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Create new Workspace Layout
|
||||
// TODO: Platzhalter — Formular zum Anlegen eines Workspace (Name, Pfad) und
|
||||
// Verdrahtung als Ziel von WorkspaceRoute::NewWorkspace fehlen noch.
|
||||
#[component]
|
||||
fn NewWorkspaceLayout() -> Element {
|
||||
rsx! {
|
||||
div {}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod selector;
|
||||
pub mod create_new_workspace;
|
||||
@@ -1,60 +1,219 @@
|
||||
use dioxus::prelude::*;
|
||||
use strum::{EnumMessage, IntoEnumIterator};
|
||||
use strum_macros::EnumIter;
|
||||
use crate::components::custom_link::custom_link_component::CustomLink;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)]
|
||||
#[rustfmt::skip]
|
||||
pub enum WorkspaceRoute {
|
||||
#[layout(Workspace)]
|
||||
#[route("/")]
|
||||
#[strum(message = "Create new Workspace")]
|
||||
NewWorkspace {},
|
||||
#[route("/ws_open")]
|
||||
#[strum(message = "Open Workspace")]
|
||||
OpenWorkspace {},
|
||||
}
|
||||
use crate::route::Route;
|
||||
use crate::workspace::{dir_is_empty, OpenError, Workspace};
|
||||
use crate::workspace_list::WorkspaceList;
|
||||
|
||||
/// Page to create new Workspace
|
||||
/// Der Bildschirm vor dem Editor. Eine Ansicht, keine Reiter: Die Liste der
|
||||
/// bekannten Workspaces führt, das Hinzufügen ist Nebenaktion in der Fußzeile.
|
||||
/// Öffnen ist der Alltagsfall, Hinzufügen passiert pro Workspace einmal.
|
||||
#[component]
|
||||
fn NewWorkspace() -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
"New Workspace",
|
||||
pub fn Selector() -> Element {
|
||||
let mut workspace = use_context::<Signal<Option<Workspace>>>();
|
||||
let mut list = use_signal(WorkspaceList::load);
|
||||
let navigator = use_navigator();
|
||||
|
||||
let mut pending = use_signal(|| None::<PathBuf>);
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
|
||||
// Zustandsprüfung der Einträge, nebenläufig und auf einem eigenen Thread:
|
||||
// Auf einem nicht eingehängten Netzlaufwerk läuft schon `is_dir()` in ein
|
||||
// Timeout von Sekunden, und das würde sonst den Selector einfrieren —
|
||||
// ausgerechnet den Bildschirm, den jeder Start zuerst zeigt.
|
||||
//
|
||||
// spawn_blocking setzt die Tokio-Runtime voraus, die dioxus-desktop
|
||||
// mitbringt. Für die web-/mobile-Features müsste das anders laufen —
|
||||
// die sind laut Entscheidung in Issue #1 aber ausdrücklich nicht Ziel.
|
||||
let probed = use_resource(move || {
|
||||
let paths = list.read().entries().to_vec();
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let state = Workspace::open(path.clone()).err();
|
||||
(path, state)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// Page to open available Workspaces
|
||||
#[component]
|
||||
fn OpenWorkspace() -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
"Open Workspace",
|
||||
let mut accept = move |opened: Workspace| {
|
||||
list.write().promote(opened.root.clone());
|
||||
if let Err(err) = list.read().save() {
|
||||
error.set(Some(format!(
|
||||
"Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
workspace.set(Some(opened));
|
||||
navigator.push(Route::Home {});
|
||||
};
|
||||
|
||||
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
|
||||
Ok(opened) => accept(opened),
|
||||
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
|
||||
// ohne eigenen Knopf abgedeckt.
|
||||
let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
|
||||
Ok(opened) => accept(opened),
|
||||
Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))),
|
||||
};
|
||||
|
||||
let choose = move |_| async move {
|
||||
// Abbruch liefert None und ist kein Fehler — keine Meldung.
|
||||
let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else {
|
||||
return;
|
||||
};
|
||||
let root = handle.path().to_path_buf();
|
||||
error.set(None);
|
||||
|
||||
// Ein vorhandener Workspace wird geöffnet, nicht abgelehnt: derselbe
|
||||
// Knopf nimmt neue und wiedergefundene Workspaces auf.
|
||||
if Workspace::has_marker(&root) {
|
||||
open_entry(root);
|
||||
return;
|
||||
}
|
||||
|
||||
match dir_is_empty(&root) {
|
||||
Ok(true) => create_at(root),
|
||||
// Nicht leer ist beim Import der Normalfall, keine Ausnahme.
|
||||
Ok(false) => pending.set(Some(root)),
|
||||
Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))),
|
||||
}
|
||||
};
|
||||
|
||||
let paths = list.read().entries().to_vec();
|
||||
let states = probed.read().clone();
|
||||
// Solange die Prüfung läuft, bleiben die Einträge unmarkiert. Die lineare
|
||||
// Suche ist bei einer Workspace-Liste billiger als eine Map aufzubauen.
|
||||
let state_of = |path: &PathBuf| -> Option<OpenError> {
|
||||
states
|
||||
.as_ref()?
|
||||
.iter()
|
||||
.find(|(known, _)| known == path)
|
||||
.and_then(|(_, state)| state.clone())
|
||||
};
|
||||
let is_empty = paths.is_empty();
|
||||
|
||||
/// Workspace Layout
|
||||
#[component]
|
||||
fn Workspace() -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
class: "workspace-selector-layout",
|
||||
div {
|
||||
class: "workspace-selector-links",
|
||||
for route in WorkspaceRoute::iter() {
|
||||
CustomLink {
|
||||
key: "{route}",
|
||||
label: route.get_message().unwrap_or_default(),
|
||||
href: route.clone(),
|
||||
css: "workspace-selector",
|
||||
div { class: "selector-shell",
|
||||
div { class: "selector-head",
|
||||
span { class: "selector-title", "Workspace öffnen" }
|
||||
if !is_empty {
|
||||
span { class: "field-hint", "{paths.len()} bekannt" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "selector-list-wrap",
|
||||
if is_empty {
|
||||
div { class: "empty-state",
|
||||
span { class: "empty-state-title", "Hier ist noch nichts" }
|
||||
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
|
||||
}
|
||||
} else {
|
||||
div { class: "ws-list",
|
||||
for path in paths.iter() {
|
||||
WorkspaceEntry {
|
||||
key: "{path.display()}",
|
||||
path: path.clone(),
|
||||
state: state_of(path),
|
||||
onopen: move |path| open_entry(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "workspace-selector-view",
|
||||
Outlet::<WorkspaceRoute> { }
|
||||
|
||||
div { class: "selector-foot",
|
||||
span { class: "field-hint", "Ein Workspace ist ein Ordner mit Notizen." }
|
||||
button {
|
||||
class: if is_empty { "btn btn-primary" } else { "btn" },
|
||||
onclick: choose,
|
||||
"Workspace hinzufügen…"
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(message) = error() {
|
||||
span { class: "field-error", "{message}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(root) = pending() {
|
||||
NotEmptyDialog {
|
||||
root: root.clone(),
|
||||
onconfirm: move |root| {
|
||||
pending.set(None);
|
||||
create_at(root);
|
||||
},
|
||||
oncancel: move |_| pending.set(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Eintrag der Workspace-Liste: Ordnername, darunter der Pfad, rechts bei
|
||||
/// Bedarf das Statusabzeichen. Ein defekter Eintrag bleibt anklickbar und wird
|
||||
/// nie automatisch entfernt — meist fehlt nur gerade das Laufwerk.
|
||||
#[component]
|
||||
fn WorkspaceEntry(path: PathBuf, state: Option<OpenError>, onopen: EventHandler<PathBuf>) -> Element {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
let full = path.display().to_string();
|
||||
let clicked = path.clone();
|
||||
// Klasse und Beschriftung des Abzeichens vorab, damit rsx! den Zustand
|
||||
// nicht zweimal gleichzeitig braucht.
|
||||
let broken = state.is_some();
|
||||
let badge = state.as_ref().map(|state| (state.badge_class(), state.to_string()));
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: if broken { "ws-item is-broken" } else { "ws-item" },
|
||||
onclick: move |_| onopen.call(clicked.clone()),
|
||||
span { class: "ws-item-body",
|
||||
span { class: "ws-item-name", "{name}" }
|
||||
span { class: "ws-item-path", "{full}" }
|
||||
}
|
||||
if let Some((class, label)) = badge {
|
||||
span { class: "ws-badge {class}", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält.
|
||||
/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da,
|
||||
/// dass nichts Vorhandenes verändert wird.
|
||||
#[component]
|
||||
fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler<PathBuf>, oncancel: EventHandler<()>) -> Element {
|
||||
let shown = root.display().to_string();
|
||||
let confirmed = root.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "dialog-scrim",
|
||||
div { class: "dialog",
|
||||
h2 { "Ordner ist nicht leer" }
|
||||
p {
|
||||
code { "{shown}" }
|
||||
" enthält bereits Dateien. DioNotes legt darin nur den Ordner "
|
||||
code { ".dionotes" }
|
||||
" an und verändert nichts Vorhandenes."
|
||||
}
|
||||
div { class: "dialog-actions",
|
||||
button { class: "btn btn-ghost", onclick: move |_| oncancel.call(()), "Abbrechen" }
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| onconfirm.call(confirmed.clone()),
|
||||
"Workspace anlegen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
233
src/workspace.rs
Normal file
233
src/workspace.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MARKER_DIR: &str = ".dionotes";
|
||||
const MARKER_FILE: &str = "workspace.ron";
|
||||
|
||||
/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen,
|
||||
/// die eine ältere App nicht bloß ignorieren kann.
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Inhalt von `.dionotes/workspace.ron`.
|
||||
///
|
||||
/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das
|
||||
/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim
|
||||
/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem
|
||||
/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct Marker {
|
||||
schema_version: u32,
|
||||
}
|
||||
|
||||
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
|
||||
///
|
||||
/// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil
|
||||
/// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus
|
||||
/// `.dionotes/workspace.ron` hier andocken werden.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Workspace {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// Warum ein Ordner sich nicht als Workspace öffnen ließ.
|
||||
///
|
||||
/// Die ersten drei Varianten sind genau die Zustände, mit denen ein
|
||||
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
|
||||
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OpenError {
|
||||
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
|
||||
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
|
||||
/// aus der Liste entfernt.
|
||||
Unreachable,
|
||||
/// Der Ordner ist da, trägt aber keinen Marker.
|
||||
NotAWorkspace,
|
||||
/// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht**
|
||||
/// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein
|
||||
/// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den
|
||||
/// Schutz auszuhebeln.
|
||||
Damaged,
|
||||
/// Der Marker stammt aus einer neueren Fassung der App.
|
||||
TooNew { found: u32 },
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for OpenError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Unreachable => write!(f, "nicht erreichbar"),
|
||||
Self::NotAWorkspace => write!(f, "kein Workspace mehr"),
|
||||
Self::Damaged => write!(f, "beschädigt"),
|
||||
Self::TooNew { found } => write!(
|
||||
f,
|
||||
"mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})"
|
||||
),
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for OpenError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::Io(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenError {
|
||||
/// CSS-Klasse fuer das Statusabzeichen am Listeneintrag.
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unreachable => "unreachable",
|
||||
Self::NotAWorkspace => "no-marker",
|
||||
Self::Damaged | Self::TooNew { .. } | Self::Io(_) => "damaged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
|
||||
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
|
||||
pub fn name(&self) -> String {
|
||||
self.root
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn marker_path(root: &Path) -> PathBuf {
|
||||
root.join(MARKER_DIR).join(MARKER_FILE)
|
||||
}
|
||||
|
||||
/// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt
|
||||
/// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist.
|
||||
pub fn has_marker(root: &Path) -> bool {
|
||||
Self::marker_path(root).is_file()
|
||||
}
|
||||
|
||||
/// Einen bestehenden Workspace öffnen.
|
||||
pub fn open(root: PathBuf) -> Result<Self, OpenError> {
|
||||
if !root.is_dir() {
|
||||
return Err(OpenError::Unreachable);
|
||||
}
|
||||
let marker_path = Self::marker_path(&root);
|
||||
if !marker_path.is_file() {
|
||||
return Err(OpenError::NotAWorkspace);
|
||||
}
|
||||
|
||||
let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?;
|
||||
let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?;
|
||||
if marker.schema_version > SCHEMA_VERSION {
|
||||
return Err(OpenError::TooNew { found: marker.schema_version });
|
||||
}
|
||||
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
/// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen.
|
||||
///
|
||||
/// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv.
|
||||
pub fn create(root: PathBuf) -> Result<Self, OpenError> {
|
||||
if !root.is_dir() {
|
||||
return Err(OpenError::Unreachable);
|
||||
}
|
||||
let marker_path = Self::marker_path(&root);
|
||||
if let Some(dir) = marker_path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let marker = Marker { schema_version: SCHEMA_VERSION };
|
||||
let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default())
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
fs::write(&marker_path, text)?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen.
|
||||
pub fn dir_is_empty(path: &Path) -> io::Result<bool> {
|
||||
Ok(fs::read_dir(path)?.next().is_none())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{dir_is_empty, OpenError, Workspace};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb
|
||||
/// braucht es keine Zufallszahl und keine tempfile-Crate.
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dionotes-test-{name}"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_the_folder_name() {
|
||||
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
|
||||
assert_eq!(ws.name(), "Notizen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_open_roundtrip() {
|
||||
let dir = scratch("create-open");
|
||||
let created = Workspace::create(dir.clone()).expect("anlegbar");
|
||||
assert!(Workspace::has_marker(&dir));
|
||||
assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_leaves_existing_files_alone() {
|
||||
let dir = scratch("create-keeps-files");
|
||||
fs::write(dir.join("notiz.md"), "# Hallo").unwrap();
|
||||
Workspace::create(dir.clone()).expect("anlegbar");
|
||||
assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_folder_is_unreachable() {
|
||||
let dir = scratch("gone").join("weg");
|
||||
assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_without_marker_is_not_a_workspace() {
|
||||
let dir = scratch("no-marker");
|
||||
assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_marker_is_damaged_and_not_recreated() {
|
||||
let dir = scratch("damaged");
|
||||
Workspace::create(dir.clone()).expect("anlegbar");
|
||||
let marker = dir.join(".dionotes").join("workspace.ron");
|
||||
fs::write(&marker, "kaputt").unwrap();
|
||||
|
||||
assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged)));
|
||||
// Der Marker wurde nicht stillschweigend ersetzt.
|
||||
assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_schema_is_refused_with_the_version() {
|
||||
let dir = scratch("too-new");
|
||||
Workspace::create(dir.clone()).expect("anlegbar");
|
||||
fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap();
|
||||
|
||||
match Workspace::open(dir) {
|
||||
Err(OpenError::TooNew { found }) => assert_eq!(found, 99),
|
||||
other => panic!("erwartet TooNew, war {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_folder_is_recognised() {
|
||||
let dir = scratch("emptiness");
|
||||
assert!(dir_is_empty(&dir).unwrap());
|
||||
fs::write(dir.join("etwas.txt"), "x").unwrap();
|
||||
assert!(!dir_is_empty(&dir).unwrap());
|
||||
}
|
||||
}
|
||||
130
src/workspace_list.rs
Normal file
130
src/workspace_list.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Verzeichnisname der App. Überall kleingeschrieben — unter Linux ist das
|
||||
/// Konvention, unter Windows in `%LOCALAPPDATA%` unauffällig. Eine Crate nur
|
||||
/// für diese Normalisierung (`directories`) wäre der teurere Weg gewesen.
|
||||
const APP_DIR: &str = "dionotes";
|
||||
const FILE_NAME: &str = "workspaces.ron";
|
||||
|
||||
/// Die anwendungsweite Liste bekannter Workspaces.
|
||||
///
|
||||
/// Sie enthält nur Pfade: Der Anzeigename ist der Ordnername und wäre als
|
||||
/// eigenes Feld nach dem ersten Umbenennen falsch. Auch keinen Zeitstempel —
|
||||
/// die **Reihenfolge ist die Sortierung**, zuletzt geöffnet steht vorn.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceList {
|
||||
workspaces: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl WorkspaceList {
|
||||
pub fn entries(&self) -> &[PathBuf] {
|
||||
&self.workspaces
|
||||
}
|
||||
|
||||
/// Hebt einen Workspace an die Spitze — beim Öffnen wie beim Hinzufügen.
|
||||
/// Weil vorher gefiltert wird, kann derselbe Pfad nicht doppelt vorkommen.
|
||||
pub fn promote(&mut self, path: PathBuf) {
|
||||
self.workspaces.retain(|known| known != &path);
|
||||
self.workspaces.insert(0, path);
|
||||
}
|
||||
|
||||
/// Wo die Liste liegt.
|
||||
///
|
||||
/// Ein Ausdruck für alle Plattformen: `state_dir()` liefert nur unter Linux
|
||||
/// `Some` — dort führt die XDG-Spec „recently used files" ausdrücklich als
|
||||
/// State, gehört also nach `$XDG_STATE_HOME` und nicht nach `~/.config`.
|
||||
/// Windows und macOS fallen auf das lokale Konfigurationsverzeichnis durch;
|
||||
/// unter Windows bewusst `%LOCALAPPDATA%` statt Roaming, weil absolute
|
||||
/// Workspace-Pfade maschinenspezifisch sind.
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let base = dirs::state_dir().or_else(dirs::config_local_dir)?;
|
||||
Some(base.join(APP_DIR).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Lesen. Eine fehlende oder unlesbare Datei ergibt eine leere Liste statt
|
||||
/// eines Fehlers: Sie enthält nichts Unwiederbringliches — der Nutzer wählt
|
||||
/// seine Ordner einmal neu, seine Notizen sind nicht betroffen. Deshalb
|
||||
/// braucht die Datei auch keine Schema-Version.
|
||||
pub fn load() -> Self {
|
||||
Self::path()
|
||||
.and_then(|path| fs::read_to_string(path).ok())
|
||||
.map(|text| Self::parse(&text))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> io::Result<()> {
|
||||
let path = Self::path().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "kein Konfigurationsverzeichnis")
|
||||
})?;
|
||||
// Das Basisverzeichnis existiert garantiert, das der App nicht.
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
fs::write(path, self.to_ron()?)
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Self {
|
||||
ron::from_str(text).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn to_ron(&self) -> io::Result<String> {
|
||||
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WorkspaceList;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn list_of(paths: &[&str]) -> WorkspaceList {
|
||||
let mut list = WorkspaceList::default();
|
||||
// rückwärts, damit die Reihenfolge am Ende der Eingabe entspricht
|
||||
for p in paths.iter().rev() {
|
||||
list.promote(PathBuf::from(p));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_puts_new_entries_first() {
|
||||
let mut list = list_of(&["a", "b"]);
|
||||
list.promote(PathBuf::from("c"));
|
||||
assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_moves_known_entries_without_duplicating() {
|
||||
let mut list = list_of(&["a", "b", "c"]);
|
||||
list.promote(PathBuf::from("c"));
|
||||
assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survives_a_ron_roundtrip_with_windows_paths() {
|
||||
let list = list_of(&[r"C:\Users\marvin\Notizen", r"D:\Projekte\Test"]);
|
||||
let text = list.to_ron().expect("serialisierbar");
|
||||
assert_eq!(WorkspaceList::parse(&text), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lands_in_the_app_directory() {
|
||||
let path = WorkspaceList::path().expect("Konfigurationsverzeichnis vorhanden");
|
||||
assert!(
|
||||
path.ends_with("dionotes/workspaces.ron"),
|
||||
"unerwarteter Ablageort: {}",
|
||||
path.display()
|
||||
);
|
||||
println!("Ablageort auf dieser Plattform: {}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_content_yields_an_empty_list() {
|
||||
assert_eq!(WorkspaceList::parse("kein RON, nur Text"), WorkspaceList::default());
|
||||
assert_eq!(WorkspaceList::parse(""), WorkspaceList::default());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user