refactor: workspace/session/item ... ja ja, i know but now it is nailed.

This commit is contained in:
mow 2026-03-15 12:25:39 +01:00
parent ca6f8f788f
commit 79cc199d11
6 changed files with 878 additions and 0 deletions

165
src/active_state.rs Normal file
View file

@ -0,0 +1,165 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/active_state.rs
//
// Pure navigation state: tracks which session and item are currently active.
use anyhow::{Context, Result};
use uuid::Uuid;
use crate::workspace::model::{Item, Session, Workspace};
/// Tracks the active session and item selection.
///
/// `ActiveState` does not own or store a `Workspace`. All methods that need
/// workspace data receive it as a parameter. This keeps navigation state
/// cleanly separated from domain data.
#[derive(Debug, Clone)]
pub struct ActiveState {
active_session: Option<Uuid>,
active_item: Option<usize>,
}
impl Default for ActiveState {
fn default() -> Self {
Self::new()
}
}
impl ActiveState {
#[must_use]
pub fn new() -> Self {
Self {
active_session: None,
active_item: None,
}
}
/// Clear all navigation state.
pub fn reset(&mut self) {
self.active_session = None;
self.active_item = None;
}
pub fn select_session(&mut self, workspace: &Workspace, name: &str) -> Result<()> {
let session = workspace.session_by_name(name)?;
if self.active_session == Some(session.id) {
return Ok(());
}
self.active_session = Some(session.id);
self.active_item = if session.items.is_empty() {
None
} else {
Some(0)
};
Ok(())
}
pub fn select_session_by_id(&mut self, workspace: &Workspace, id: Uuid) -> Result<()> {
if self.active_session == Some(id) {
return Ok(());
}
let session = workspace.session(id)?;
self.active_session = Some(session.id);
self.active_item = if session.items.is_empty() {
None
} else {
Some(0)
};
Ok(())
}
pub fn clear_session(&mut self) {
self.active_session = None;
self.active_item = None;
}
pub fn select_item(&mut self, workspace: &Workspace, index: usize) -> Result<()> {
let session = self.require_active_session(workspace)?;
if index >= session.items.len() {
anyhow::bail!(
"Item index {index} out of range (session {:?} has {} items)",
session.name,
session.items.len()
);
}
self.active_item = Some(index);
Ok(())
}
pub fn next_item(&mut self, workspace: &Workspace) {
let count = self.item_count(workspace);
if let Some(idx) = self.active_item {
if idx + 1 < count {
self.active_item = Some(idx + 1);
}
}
}
pub fn previous_item(&mut self) {
if let Some(idx) = self.active_item {
if idx > 0 {
self.active_item = Some(idx - 1);
}
}
}
/// Adjust the item cursor after an item was removed from the active session.
pub fn adjust_after_remove(&mut self, workspace: &Workspace) {
let count = self.item_count(workspace);
self.active_item = if count == 0 {
None
} else {
Some(self.active_item.unwrap_or(0).min(count - 1))
};
}
/// Point the item cursor to the last item in the active session.
pub fn select_last_item(&mut self, workspace: &Workspace) {
let count = self.item_count(workspace);
self.active_item = if count == 0 { None } else { Some(count - 1) };
}
// -- Read-only accessors --
#[must_use]
pub fn active_session_id(&self) -> Option<Uuid> {
self.active_session
}
#[must_use]
pub fn active_item_index(&self) -> Option<usize> {
self.active_item
}
/// Returns the active session from the workspace.
#[must_use]
pub fn active_session<'a>(&self, workspace: &'a Workspace) -> Option<&'a Session> {
let id = self.active_session?;
workspace.session(id).ok()
}
/// Returns the active item from the active session.
#[must_use]
pub fn active_item<'a>(&self, workspace: &'a Workspace) -> Option<&'a Item> {
let session = self.active_session(workspace)?;
let idx = self.active_item?;
session.items.get(idx)
}
/// Returns the UUID of the active item.
#[must_use]
pub fn active_item_id(&self, workspace: &Workspace) -> Option<Uuid> {
self.active_item(workspace).map(|i| i.id)
}
// Private helpers
fn require_active_session<'a>(&self, workspace: &'a Workspace) -> Result<&'a Session> {
let id = self.active_session.context("No active session")?;
workspace.session(id)
}
fn item_count(&self, workspace: &Workspace) -> usize {
self.active_session(workspace).map_or(0, |s| s.items.len())
}
}

338
src/patch.rs Normal file
View file

@ -0,0 +1,338 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/patch.rs
//
// Patch file executor for scripted workspace operations and testing.
use crate::active_state::ActiveState;
use crate::workspace::model::{Item, ItemKind, Session, SessionType, Workspace};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
/// A single step in a patch file. Each variant maps directly to a JSON key.
#[derive(Debug, Deserialize)]
pub enum PatchStep {
NewWorkspace(String),
LoadWorkspace(String),
SaveWorkspace(String),
CreateSession {
name: String,
session_type: String,
},
DeleteSession(String),
CopySession(String),
RenameSession {
session: String,
name: String,
},
AddItem {
session: String,
name: String,
kind: String,
path: String,
},
RemoveItem {
session: String,
item: String,
},
RenameItem {
session: String,
item: String,
name: String,
},
CopyItem {
from: String,
item: String,
to: String,
},
MoveItem {
from: String,
item: String,
to: String,
},
SelectSession(String),
SelectItem(usize),
NextItem,
PreviousItem,
MoveItemUp,
MoveItemDown,
MoveItemToIndex(usize),
ViewWorkspace,
ViewSession(String),
}
pub fn load(path: &Path) -> Result<Vec<PatchStep>> {
let content = std::fs::read_to_string(path).context("Failed to read patch file")?;
serde_json::from_str(&content).context("Failed to parse patch file")
}
pub fn run(path: &Path) {
let steps = match load(path) {
Ok(s) => s,
Err(e) => {
eprintln!("patch: {e}");
return;
}
};
let total = steps.len();
let width = total.to_string().len();
println!("patch: '{}' ({total} steps)\n", path.display());
let mut ws = Workspace::new("untitled");
let mut active = ActiveState::new();
for (i, step) in steps.into_iter().enumerate() {
let n = i + 1;
print!("[{n:>width$}/{total}] ");
if let Err(e) = execute(step, &mut ws, &mut active) {
println!("[ERR] {e}");
}
}
println!("\npatch: done.");
}
fn execute(step: PatchStep, ws: &mut Workspace, active: &mut ActiveState) -> Result<()> {
match step {
PatchStep::NewWorkspace(name) => {
println!("New Workspace {name:?}");
*ws = Workspace::new(&name);
active.reset();
}
PatchStep::LoadWorkspace(path) => {
*ws = Workspace::load(&path)?;
active.reset();
println!("Load Workspace {path:?} ({} sessions)", ws.sessions.len());
}
PatchStep::SaveWorkspace(path) => {
ws.save(&path)?;
println!("Save Workspace {path:?}");
}
PatchStep::CreateSession { name, session_type } => {
let st = parse_session_type(&session_type)?;
let session = Session::new(&name, st);
let id = session.id;
ws.add_session(session);
active.select_session_by_id(ws, id)?;
println!("Create Session {name:?}");
}
PatchStep::DeleteSession(name) => {
let id = ws.session_by_name(&name)?.id;
ws.remove_session(id)?;
if active.active_session_id() == Some(id) {
active.clear_session();
}
println!("Delete Session {name:?}");
}
PatchStep::CopySession(name) => {
let id = ws.session_by_name(&name)?.id;
let new_id = ws.copy_session(id)?;
let new_name = ws.session(new_id)?.name.clone();
active.select_session_by_id(ws, new_id)?;
println!("Copy Session {name:?} -> {new_name:?}");
}
PatchStep::RenameSession { session, name } => {
let id = ws.session_by_name(&session)?.id;
ws.rename_session(id, &name)?;
println!("Rename Session {session:?} -> {name:?}");
}
PatchStep::AddItem {
session,
name,
kind,
path,
} => {
let sid = ws.session_by_name(&session)?.id;
let item = Item::new(&name, parse_item_kind(&kind, &path)?);
ws.add_item(sid, item)?;
if active.active_session_id() == Some(sid) {
active.select_last_item(ws);
}
println!("Add Item {name:?} to {session:?}");
}
PatchStep::RemoveItem { session, item } => {
let sid = ws.session_by_name(&session)?.id;
let iid = ws.item_by_name(sid, &item)?.id;
ws.remove_item(sid, iid)?;
if active.active_session_id() == Some(sid) {
active.adjust_after_remove(ws);
}
println!("Remove Item {item:?} from {session:?}");
}
PatchStep::RenameItem {
session,
item,
name,
} => {
let sid = ws.session_by_name(&session)?.id;
let iid = ws.item_by_name(sid, &item)?.id;
ws.rename_item(sid, iid, &name)?;
println!("Rename Item {item:?} -> {name:?}");
}
PatchStep::CopyItem { from, item, to } => {
let from_id = ws.session_by_name(&from)?.id;
let iid = ws.item_by_name(from_id, &item)?.id;
let to_id = ws.session_by_name(&to)?.id;
ws.copy_item(from_id, iid, to_id, usize::MAX)?;
println!("Copy Item {item:?}: {from:?} -> {to:?}");
}
PatchStep::MoveItem { from, item, to } => {
let from_id = ws.session_by_name(&from)?.id;
let iid = ws.item_by_name(from_id, &item)?.id;
let to_id = ws.session_by_name(&to)?.id;
ws.move_item(from_id, iid, to_id, usize::MAX)?;
if active.active_session_id() == Some(from_id) {
active.adjust_after_remove(ws);
}
println!("Move Item {item:?}: {from:?} -> {to:?}");
}
PatchStep::SelectSession(name) => {
active.select_session(ws, &name)?;
println!("Select Session {name:?}");
print_context(ws, active);
}
PatchStep::SelectItem(index) => {
active.select_item(ws, index)?;
println!("Select Item [{index}]");
print_context(ws, active);
}
PatchStep::NextItem => {
active.next_item(ws);
println!("Next Item");
print_context(ws, active);
}
PatchStep::PreviousItem => {
active.previous_item();
println!("Previous Item");
print_context(ws, active);
}
PatchStep::MoveItemUp => {
let sid = active.active_session_id().context("No active session")?;
let idx = active.active_item_index().context("No active item")?;
if idx == 0 {
bail!("Already at top")
}
let iid = active.active_item_id(ws).context("No active item")?;
ws.move_item(sid, iid, sid, idx - 1)?;
active.select_item(ws, idx - 1)?;
println!("Move Item Up");
print_context(ws, active);
}
PatchStep::MoveItemDown => {
let sid = active.active_session_id().context("No active session")?;
let idx = active.active_item_index().context("No active item")?;
let count = active.active_session(ws).map_or(0, |s| s.items.len());
if idx + 1 >= count {
bail!("Already at bottom")
}
let iid = active.active_item_id(ws).context("No active item")?;
ws.move_item(sid, iid, sid, idx + 1)?;
active.select_item(ws, idx + 1)?;
println!("Move Item Down");
print_context(ws, active);
}
PatchStep::MoveItemToIndex(target) => {
let sid = active.active_session_id().context("No active session")?;
let iid = active.active_item_id(ws).context("No active item")?;
ws.move_item(sid, iid, sid, target)?;
let count = active.active_session(ws).map_or(0, |s| s.items.len());
active.select_item(ws, target.min(count.saturating_sub(1)))?;
println!("Move Item To [{target}]");
print_context(ws, active);
}
PatchStep::ViewWorkspace => {
println!(
"View Workspace {:?} {} sessions",
ws.name,
ws.sessions.len()
);
for s in &ws.sessions {
let m = if active.active_session_id() == Some(s.id) {
"=>"
} else {
" "
};
println!(
" {m} {:?} ({:?}) {} items",
s.name,
s.session_type,
s.items.len()
);
}
}
PatchStep::ViewSession(name) => {
let session = ws.session_by_name(&name)?;
println!(
"View Session {:?} ({:?}) {} items",
session.name,
session.session_type,
session.items.len()
);
for (i, item) in session.items.iter().enumerate() {
let m = if active.active_session_id() == Some(session.id)
&& active.active_item_index() == Some(i)
{
"=>"
} else {
" "
};
let kind = match &item.kind {
ItemKind::Portable { .. } => "portable",
ItemKind::Raster { .. } => "raster",
ItemKind::Vector { .. } => "vector",
ItemKind::Document { .. } => "document",
};
println!(" {m} [{i}] {:?} ({kind})", item.name);
}
}
}
Ok(())
}
fn print_context(ws: &Workspace, active: &ActiveState) {
let Some(session) = active.active_session(ws) else {
return;
};
let item_info = match active.active_item(ws) {
Some(item) => format!(
"[{}] {:?}",
active.active_item_index().unwrap_or(0),
item.name
),
None => "-".into(),
};
println!(
" {:?} {} items active: {item_info}",
session.name,
session.items.len()
);
}
fn parse_session_type(s: &str) -> Result<SessionType> {
match s.to_lowercase().as_str() {
"browser" => Ok(SessionType::Browser),
"collection" => Ok(SessionType::Collection),
_ => bail!("Unknown session type: {s:?}"),
}
}
fn parse_item_kind(kind: &str, path: &str) -> Result<ItemKind> {
let path = PathBuf::from(path);
match kind.to_lowercase().as_str() {
"portable" | "pdf" => Ok(ItemKind::Portable { path }),
"raster" | "image" => Ok(ItemKind::Raster { path }),
"vector" | "svg" => Ok(ItemKind::Vector { path }),
"document" => Ok(ItemKind::Document { path }),
_ => bail!("Unknown item kind: {kind:?}"),
}
}

174
src/workspace/manager.rs Normal file
View file

@ -0,0 +1,174 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/workspace/manager.rs
//
// Workspace operations: session and item CRUD, reordering, and RON persistence.
use crate::workspace::model::{Item, Session, Workspace};
use anyhow::{Context, Result};
use std::path::Path;
use uuid::Uuid;
impl Workspace {
/// Serialize the workspace to a RON file.
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let config = ron::ser::PrettyConfig::default();
let data =
ron::ser::to_string_pretty(self, config).context("Failed to serialize workspace")?;
std::fs::write(&path, data).context("Failed to write workspace file")?;
Ok(())
}
/// Deserialize a workspace from a RON file.
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let data = std::fs::read_to_string(&path).context("Failed to read workspace file")?;
let workspace: Workspace =
ron::from_str(&data).context("Failed to deserialize workspace")?;
Ok(workspace)
}
pub fn add_session(&mut self, session: Session) {
self.sessions.push(session);
}
pub fn remove_session(&mut self, session_id: Uuid) -> Result<Session> {
let pos = self.session_pos(session_id)?;
Ok(self.sessions.remove(pos))
}
pub fn copy_session(&mut self, session_id: Uuid) -> Result<Uuid> {
let pos = self.session_pos(session_id)?;
let mut clone = self.sessions[pos].clone();
clone.id = Uuid::new_v4();
clone.name = format!("{} (copy)", clone.name);
for item in &mut clone.items {
item.id = Uuid::new_v4();
}
let new_id = clone.id;
self.sessions.push(clone);
Ok(new_id)
}
pub fn move_session(&mut self, session_id: Uuid, new_index: usize) -> Result<()> {
let pos = self.session_pos(session_id)?;
let session = self.sessions.remove(pos);
let insert_at = new_index.min(self.sessions.len());
self.sessions.insert(insert_at, session);
Ok(())
}
pub fn rename_session(&mut self, session_id: Uuid, new_name: impl Into<String>) -> Result<()> {
let session = self.session_mut(session_id)?;
session.name = new_name.into();
Ok(())
}
pub fn add_item(&mut self, session_id: Uuid, item: Item) -> Result<()> {
let session = self.session_mut(session_id)?;
session.items.push(item);
Ok(())
}
pub fn remove_item(&mut self, session_id: Uuid, item_id: Uuid) -> Result<Item> {
let session = self.session_mut(session_id)?;
let pos = item_pos(&session.items, item_id)?;
Ok(session.items.remove(pos))
}
/// Move an item within the same session or between two sessions.
pub fn move_item(
&mut self,
from_session: Uuid,
item_id: Uuid,
to_session: Uuid,
to_index: usize,
) -> Result<()> {
let item = self.remove_item(from_session, item_id)?;
let target = self.session_mut(to_session)?;
let insert_at = to_index.min(target.items.len());
target.items.insert(insert_at, item);
Ok(())
}
/// Copy an item within the same session or between two sessions.
pub fn copy_item(
&mut self,
from_session: Uuid,
item_id: Uuid,
to_session: Uuid,
to_index: usize,
) -> Result<Uuid> {
let source = self.session(from_session)?;
let pos = item_pos(&source.items, item_id)?;
let mut clone = source.items[pos].clone();
clone.id = Uuid::new_v4();
clone.name = format!("{} (copy)", clone.name);
let new_id = clone.id;
let target = self.session_mut(to_session)?;
let insert_at = to_index.min(target.items.len());
target.items.insert(insert_at, clone);
Ok(new_id)
}
pub fn rename_item(
&mut self,
session_id: Uuid,
item_id: Uuid,
new_name: impl Into<String>,
) -> Result<()> {
let session = self.session_mut(session_id)?;
let pos = item_pos(&session.items, item_id)?;
session.items[pos].name = new_name.into();
Ok(())
}
/// Read-only access to a session by id.
pub fn session(&self, id: Uuid) -> Result<&Session> {
self.sessions
.iter()
.find(|s| s.id == id)
.context(format!("Session {id} not found"))
}
/// Read-only access to a session by name.
pub fn session_by_name(&self, name: &str) -> Result<&Session> {
self.sessions
.iter()
.find(|s| s.name == name)
.context(format!("Session {name:?} not found"))
}
/// Find an item by name within a session.
pub fn item_by_name(&self, session_id: Uuid, name: &str) -> Result<&Item> {
let session = self.session(session_id)?;
session
.items
.iter()
.find(|i| i.name == name)
.context(format!(
"Item {name:?} not found in session {:?}",
session.name
))
}
fn session_mut(&mut self, id: Uuid) -> Result<&mut Session> {
self.sessions
.iter_mut()
.find(|s| s.id == id)
.context(format!("Session {id} not found"))
}
fn session_pos(&self, id: Uuid) -> Result<usize> {
self.sessions
.iter()
.position(|s| s.id == id)
.context(format!("Session {id} not found"))
}
}
fn item_pos(items: &[Item], id: Uuid) -> Result<usize> {
items
.iter()
.position(|i| i.id == id)
.context(format!("Item {id} not found"))
}

7
src/workspace/mod.rs Normal file
View file

@ -0,0 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/workspace/mod.rs
//
// Workspace subsystem: data model, operations, and persistence.
pub mod manager;
pub mod model;

100
src/workspace/model.rs Normal file
View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/workspace/model.rs
//
// Pure data structures for the workspace, sessions, and their items.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use uuid::Uuid;
/// The document kind an item references.
///
/// Each variant carries the path to the source file. Format-specific fields
/// (e.g. page index for PDFs) live on the variant so they can differ per kind.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ItemKind {
Portable { path: PathBuf },
Raster { path: PathBuf },
Vector { path: PathBuf },
Document { path: PathBuf },
}
impl ItemKind {
/// Returns the file path regardless of the variant.
pub fn path(&self) -> &PathBuf {
match self {
ItemKind::Portable { path }
| ItemKind::Raster { path }
| ItemKind::Vector { path }
| ItemKind::Document { path } => path,
}
}
}
/// A single entry in a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: Uuid,
pub name: String,
pub kind: ItemKind,
pub tags: Vec<String>,
}
impl Item {
pub fn new(name: impl Into<String>, kind: ItemKind) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
kind,
tags: Vec::new(),
}
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
}
/// Determines how a session behaves in the UI.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SessionType {
Browser,
Collection,
}
/// A named group of items inside a workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: Uuid,
pub name: String,
pub session_type: SessionType,
pub items: Vec<Item>,
}
impl Session {
pub fn new(name: impl Into<String>, session_type: SessionType) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
session_type,
items: Vec::new(),
}
}
}
/// The top-level container that is persisted as a `.ws.ron` file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub name: String,
pub sessions: Vec<Session>,
}
impl Workspace {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
sessions: Vec::new(),
}
}
}

94
test.json Normal file
View file

@ -0,0 +1,94 @@
[
{ "NewWorkspace": "Demo Projekt" },
{ "CreateSession": { "name": "Referenzen", "session_type": "browser" } },
{
"AddItem": {
"session": "Referenzen",
"name": "Logo",
"kind": "vector",
"path": "tmp/pictures/logo.svg"
}
},
{
"AddItem": {
"session": "Referenzen",
"name": "Foto Hintergrund",
"kind": "raster",
"path": "tmp/pictures/hero.jpg"
}
},
{
"AddItem": {
"session": "Referenzen",
"name": "Handbuch",
"kind": "portable",
"path": "tmp/pictures/handbook.pdf"
}
},
{ "ViewSession": "Referenzen" },
{ "SelectSession": "Referenzen" },
{ "SelectItem": 0 },
"NextItem",
"NextItem",
"PreviousItem",
{ "SelectItem": 0 },
"MoveItemDown",
{ "ViewSession": "Referenzen" },
"MoveItemUp",
{ "ViewSession": "Referenzen" },
{ "MoveItemToIndex": 2 },
{ "ViewSession": "Referenzen" },
{ "MoveItemToIndex": 0 },
{ "CreateSession": { "name": "Favoriten", "session_type": "collection" } },
{ "CreateSession": { "name": "Archiv", "session_type": "collection" } },
"ViewWorkspace",
{ "CopyItem": { "from": "Referenzen", "item": "Logo", "to": "Favoriten" } },
{
"CopyItem": {
"from": "Referenzen",
"item": "Foto Hintergrund",
"to": "Favoriten"
}
},
{ "ViewSession": "Favoriten" },
{
"RenameItem": {
"session": "Favoriten",
"item": "Logo (copy)",
"name": "Hauptlogo"
}
},
{ "ViewSession": "Favoriten" },
{
"RemoveItem": { "session": "Favoriten", "item": "Foto Hintergrund (copy)" }
},
{ "ViewSession": "Favoriten" },
{ "MoveItem": { "from": "Referenzen", "item": "Handbuch", "to": "Archiv" } },
{ "ViewSession": "Referenzen" },
{ "ViewSession": "Archiv" },
{ "CopySession": "Referenzen" },
"ViewWorkspace",
{
"RenameSession": {
"session": "Referenzen (copy)",
"name": "Backup Referenzen"
}
},
"ViewWorkspace",
{ "DeleteSession": "Archiv" },
"ViewWorkspace",
{ "SaveWorkspace": "tmp/demo.ws.ron" },
{ "LoadWorkspace": "tmp/demo.ws.ron" },
"ViewWorkspace"
]