refactor(session): introduce cursor model and full command set

This commit is contained in:
mow 2026-03-09 21:08:59 +01:00
parent 767315a65e
commit 9ec407d9aa
5 changed files with 340 additions and 204 deletions

View file

@ -98,10 +98,4 @@ pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) {
session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect(); session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect();
} }
session.current_index = if session.items.is_empty() {
None
} else {
Some(0)
};
} }

View file

@ -1,18 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// src/document/manager.rs // src/document/manager.rs
// //
// Document Manager single entry point via SessionCommand. // Document manager: session state, cursors, clipboard, and command dispatch.
use crate::document::loader::{load_document, scan_directory_into}; use crate::document::loader::{load_document, scan_directory_into};
use crate::document::session::command::SessionCommand; use crate::document::session::command::SessionCommand;
use crate::document::session::data::{CollectionKind, SessionData}; use crate::document::session::data::{CollectionKind, SessionData, SessionItem};
use crate::document::session::store; use crate::document::session::store;
use crate::document::DocumentContent; use crate::document::DocumentContent;
use crate::error::Error; use crate::error::Error;
use log::warn; use log::warn;
use std::path::Path; use std::path::{Path, PathBuf};
#[allow(dead_code)] /// The load state of the currently viewed document.
#[derive(Debug)] #[derive(Debug)]
pub enum DocumentState { pub enum DocumentState {
Empty, Empty,
@ -21,9 +21,20 @@ pub enum DocumentState {
Error(Error), Error(Error),
} }
/// Central state for all sessions, cursors, and the document clipboard.
///
/// Two cursors are maintained:
/// - `active_session_index` the currently active session
/// - `active_document_index` the currently active document within that session
///
/// All `SessionCommand` variants that do not carry an explicit target operate
/// on the active cursor. `SelectSession` and `SelectDocument` set them.
pub struct DocumentManager { pub struct DocumentManager {
sessions: Vec<SessionData>, sessions: Vec<SessionData>,
pub active_session_name: Option<String>, active_session_index: Option<usize>,
active_document_index: Option<usize>,
clipboard: Option<SessionItem>,
session_file_path: Option<PathBuf>,
pub state: DocumentState, pub state: DocumentState,
} }
@ -31,7 +42,10 @@ impl Default for DocumentManager {
fn default() -> Self { fn default() -> Self {
Self { Self {
sessions: Vec::new(), sessions: Vec::new(),
active_session_name: None, active_session_index: None,
active_document_index: None,
clipboard: None,
session_file_path: None,
state: DocumentState::Empty, state: DocumentState::Empty,
} }
} }
@ -43,79 +57,92 @@ impl DocumentManager {
Self::default() Self::default()
} }
/// The single public entry point for all session/collection state changes. /// The single public entry point for all session and document state changes.
pub fn handle(&mut self, command: SessionCommand) { pub fn handle(&mut self, command: SessionCommand) {
match command { match command {
// -- Lifecycle -- // Session lifecycle
SessionCommand::New { name, kind } => { SessionCommand::NewSession { name } => self.new_session(&name),
self.create_session(&name, kind); SessionCommand::OpenSession { path } => self.open_session_file(&path),
} SessionCommand::CloseSession | SessionCommand::RemoveSession => self.close_session(),
SessionCommand::Open { path } => { SessionCommand::SaveSession => self.save_session(),
self.open_session_file(&path); SessionCommand::SelectSession { name } => self.select_session(&name),
} SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name),
SessionCommand::Select { name } => { SessionCommand::RenameSession { name } => self.rename_session(&name),
if self.session(&name).is_some() {
self.active_session_name = Some(name);
}
}
// -- Collection management -- // Collection management
SessionCommand::AddDirectory { dir } => { SessionCommand::AddDirectoryToSession { dir } => {
if let Some(session) = self.active_session_mut() { if let Some(session) = self.active_session_mut() {
scan_directory_into(session, &dir); scan_directory_into(session, &dir);
} }
self.reset_document_cursor();
} }
SessionCommand::AddDocument { path } => { SessionCommand::AddDocumentToSession { path } => self.add_document(&path),
self.add_document(&path);
}
SessionCommand::CopyDocument {
source,
target,
source_index,
target_index,
} => {
self.copy_document(&source, &target, source_index, target_index);
}
SessionCommand::RemoveDocument { index } => {
self.remove_document(index);
}
// -- Navigation -- // Document management
SessionCommand::SelectDocument { index } => self.select_document(index),
SessionCommand::CopyDocument => self.copy_document(),
SessionCommand::PasteDocument => self.paste_document(),
SessionCommand::RemoveDocument => self.remove_document(),
SessionCommand::NextDocument => self.navigate_next(), SessionCommand::NextDocument => self.navigate_next(),
SessionCommand::PreviousDocument => self.navigate_previous(), SessionCommand::PreviousDocument => self.navigate_previous(),
SessionCommand::SelectDocument { index } => self.select_document(index), SessionCommand::MoveDocumentUp => self.move_document_up(),
SessionCommand::MoveDocumentDown => self.move_document_down(),
SessionCommand::MoveDocumentTo { index } => self.move_document_to(index),
} }
} }
/// Read-only access to a session by name.
#[must_use]
pub fn session(&self, name: &str) -> Option<&SessionData> {
self.sessions.iter().find(|s| s.name == name)
}
/// Read-only access to the active session. /// Read-only access to the active session.
#[must_use] #[must_use]
pub fn active_session(&self) -> Option<&SessionData> { pub fn active_session(&self) -> Option<&SessionData> {
let name = self.active_session_name.as_deref()?; self.active_session_index.map(|i| &self.sessions[i])
}
/// The index of the currently active document within the active session.
#[must_use]
pub fn active_document_index(&self) -> Option<usize> {
self.active_document_index
}
/// Read-only access to all sessions.
#[must_use]
pub fn sessions(&self) -> &[SessionData] {
&self.sessions
}
/// Read-only access to a session by name.
#[must_use]
pub fn session_by_name(&self, name: &str) -> Option<&SessionData> {
self.sessions.iter().find(|s| s.name == name) self.sessions.iter().find(|s| s.name == name)
} }
// -- Private helpers -- // -- Private helpers --
fn session_mut(&mut self, name: &str) -> Option<&mut SessionData> {
self.sessions.iter_mut().find(|s| s.name == name)
}
fn active_session_mut(&mut self) -> Option<&mut SessionData> { fn active_session_mut(&mut self) -> Option<&mut SessionData> {
let name = self.active_session_name.clone()?; self.active_session_index.map(|i| &mut self.sessions[i])
self.sessions.iter_mut().find(|s| s.name == name)
} }
fn create_session(&mut self, name: &str, kind: CollectionKind) { fn index_by_name(&self, name: &str) -> Option<usize> {
if self.session(name).is_none() { self.sessions.iter().position(|s| s.name == name)
self.sessions
.push(SessionData::new(name, kind, None, Vec::new()));
} }
/// Sets `active_document_index` to `Some(0)` if the active session has items, else `None`.
fn reset_document_cursor(&mut self) {
self.active_document_index = match self.active_session() {
Some(s) if !s.items.is_empty() => Some(0),
_ => None,
};
}
fn new_session(&mut self, name: &str) {
if self.index_by_name(name).is_some() {
return;
}
self.sessions.push(SessionData::new(
name,
CollectionKind::DocumentCollection,
None,
Vec::new(),
));
} }
fn open_session_file(&mut self, path: &Path) { fn open_session_file(&mut self, path: &Path) {
@ -141,23 +168,19 @@ impl DocumentManager {
false false
} }
}); });
session.current_index = match session.current_index {
Some(i) if i < session.items.len() => Some(i),
_ if !session.items.is_empty() => Some(0),
_ => None,
};
} }
} }
} }
let browser_name = loaded let browser_index = loaded
.iter() .iter()
.find(|s| s.kind == CollectionKind::DirectoryBrowser) .position(|s| s.kind == CollectionKind::DirectoryBrowser);
.map(|s| s.name.clone());
self.sessions = loaded; self.sessions = loaded;
self.active_session_name = browser_name; self.active_session_index = browser_index;
self.session_file_path = Some(path.to_path_buf());
self.state = DocumentState::Empty; self.state = DocumentState::Empty;
self.reset_document_cursor();
} }
Err(e) => { Err(e) => {
self.state = DocumentState::Error(e); self.state = DocumentState::Error(e);
@ -165,15 +188,89 @@ impl DocumentManager {
} }
} }
fn close_session(&mut self) {
let Some(idx) = self.active_session_index else {
return;
};
// The DirectoryBrowser session is managed by the UI and cannot be closed.
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
return;
}
self.sessions.remove(idx);
self.active_document_index = None;
self.active_session_index = if self.sessions.is_empty() {
None
} else {
Some(idx.saturating_sub(1))
};
}
fn save_session(&mut self) {
let Some(path) = self.session_file_path.clone() else {
return;
};
if let Err(e) = store::save_sessions(&path, &self.sessions) {
self.state = DocumentState::Error(e);
}
}
fn select_session(&mut self, name: &str) {
if let Some(idx) = self.index_by_name(name) {
self.active_session_index = Some(idx);
self.reset_document_cursor();
}
}
fn duplicate_session(&mut self, new_name: &str) {
if self.index_by_name(new_name).is_some() {
return;
}
let Some(idx) = self.active_session_index else {
return;
};
let items = self.sessions[idx].items.clone();
self.sessions.push(SessionData::new(
new_name,
CollectionKind::DocumentCollection,
None,
items,
));
}
fn rename_session(&mut self, name: &str) {
let Some(idx) = self.active_session_index else {
return;
};
// The DirectoryBrowser session cannot be renamed.
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
return;
}
if self.index_by_name(name).is_some() {
return;
}
self.sessions[idx].name = name.to_string();
}
fn add_document(&mut self, path: &Path) { fn add_document(&mut self, path: &Path) {
self.state = DocumentState::Loading; self.state = DocumentState::Loading;
match load_document(path) { match load_document(path) {
Ok(content) => { Ok(content) => {
if let Some(session) = self.active_session_mut() { if let Some(session) = self.active_session_mut() {
if let Some(idx) = session.items.iter().position(|item| item.path == path) { if session.kind == CollectionKind::DocumentCollection
session.current_index = Some(idx); && !session.items.iter().any(|i| i.path == path)
{
session.items.push(SessionItem::new(path.to_path_buf(), 0));
} }
} }
self.active_document_index = self
.active_session()
.and_then(|s| s.items.iter().position(|i| i.path == path));
self.state = DocumentState::Loaded(content); self.state = DocumentState::Loaded(content);
} }
Err(e) => { Err(e) => {
@ -183,111 +280,145 @@ impl DocumentManager {
} }
fn select_document(&mut self, index: usize) { fn select_document(&mut self, index: usize) {
if let Some(session) = self.active_session_mut() { if let Some(session) = self.active_session() {
if index < session.items.len() { if index < session.items.len() {
session.current_index = Some(index); self.active_document_index = Some(index);
} }
} }
} }
fn navigate_next(&mut self) { fn copy_document(&mut self) {
if let Some(session) = self.active_session_mut() { let Some(idx) = self.active_document_index else {
if let Some(idx) = session.current_index { return;
if idx + 1 < session.items.len() { };
session.current_index = Some(idx + 1); self.clipboard = self
.active_session()
.and_then(|s| s.items.get(idx))
.cloned();
} }
fn paste_document(&mut self) {
let Some(item) = self.clipboard.clone() else {
return;
};
if !matches!(
self.active_session().map(|s| &s.kind),
Some(CollectionKind::DocumentCollection)
) {
return;
}
let new_index = {
let Some(session) = self.active_session_mut() else {
return;
};
if session
.items
.iter()
.any(|i| i.path == item.path && i.page_index == item.page_index)
{
return;
}
session.items.push(item);
session.items.len() - 1
};
self.active_document_index = Some(new_index);
}
fn remove_document(&mut self) {
if !matches!(
self.active_session().map(|s| &s.kind),
Some(CollectionKind::DocumentCollection)
) {
return;
}
let Some(idx) = self.active_document_index else {
return;
};
let new_index = {
let Some(session) = self.active_session_mut() else {
return;
};
if idx >= session.items.len() {
return;
}
session.items.remove(idx);
let new_len = session.items.len();
if new_len == 0 {
None
} else {
Some(idx.min(new_len - 1))
}
};
self.active_document_index = new_index;
}
fn navigate_next(&mut self) {
let len = self.active_session().map_or(0, |s| s.items.len());
if let Some(idx) = self.active_document_index {
if idx + 1 < len {
self.active_document_index = Some(idx + 1);
} }
} }
} }
fn navigate_previous(&mut self) { fn navigate_previous(&mut self) {
if let Some(session) = self.active_session_mut() { if let Some(idx) = self.active_document_index {
if let Some(idx) = session.current_index {
if idx > 0 { if idx > 0 {
session.current_index = Some(idx - 1); self.active_document_index = Some(idx - 1);
}
} }
} }
} }
fn copy_document( fn move_document_up(&mut self) {
&mut self, let Some(idx) = self.active_document_index else {
source_name: &str,
target_name: &str,
source_index: usize,
target_index: usize,
) {
let item = if let Some(s) = self.session(source_name) {
match s.items.get(source_index) {
Some(item) => item.clone(),
None => return,
}
} else {
self.state = DocumentState::Error(Error::Session(format!(
"Source session '{source_name}' not found"
)));
return; return;
}; };
if idx == 0 {
let Some(target) = self.session(target_name) else {
self.state = DocumentState::Error(Error::Session(format!(
"Target session '{target_name}' not found"
)));
return;
};
if target.kind != CollectionKind::DocumentCollection {
self.state = DocumentState::Error(Error::Session(
"Target must be a DocumentCollection".to_string(),
));
return; return;
} }
if target
.items
.iter()
.any(|i| i.path == item.path && i.page_index == item.page_index)
{
self.state = DocumentState::Error(Error::Session("Item already exists".to_string()));
return;
}
if let Some(target) = self.session_mut(target_name) {
let idx = target_index.min(target.items.len());
target.items.insert(idx, item);
}
}
fn remove_document(&mut self, index: usize) {
// Check kind before taking a mutable borrow on the session, so that
// self.state can be set without conflicting borrows.
match self.active_session().map(|s| s.kind.clone()) {
None => return,
Some(CollectionKind::DirectoryBrowser) => {
self.state = DocumentState::Error(Error::Session(
"Can only remove from a DocumentCollection".to_string(),
));
return;
}
Some(CollectionKind::DocumentCollection) => {}
}
let Some(session) = self.active_session_mut() else { let Some(session) = self.active_session_mut() else {
return; return;
}; };
session.items.swap(idx, idx - 1);
self.active_document_index = Some(idx - 1);
}
if index >= session.items.len() { fn move_document_down(&mut self) {
let Some(idx) = self.active_document_index else {
return;
};
let Some(session) = self.active_session_mut() else {
return;
};
if idx + 1 >= session.items.len() {
return; return;
} }
session.items.swap(idx, idx + 1);
self.active_document_index = Some(idx + 1);
}
session.items.remove(index); fn move_document_to(&mut self, target: usize) {
let new_len = session.items.len(); let Some(idx) = self.active_document_index else {
if new_len == 0 { return;
session.current_index = None; };
} else if let Some(current) = session.current_index { let Some(session) = self.active_session_mut() else {
if current >= new_len { return;
session.current_index = Some(new_len - 1); };
} if idx == target || target >= session.items.len() {
} return;
}
let item = session.items.remove(idx);
session.items.insert(target, item);
self.active_document_index = Some(target);
} }
} }

View file

@ -3,43 +3,66 @@
// //
// Commands that operate on sessions and their document collections. // Commands that operate on sessions and their document collections.
use crate::document::session::data::CollectionKind;
use std::path::PathBuf; use std::path::PathBuf;
/// Commands operating on sessions and their document collections. /// Commands operating on sessions and their document collections.
/// ///
/// All collection-mutating commands operate on the active session /// Two implicit cursors exist in the manager:
/// unless they explicitly name a session. /// - `active_session_index` the currently active session
/// - `active_document_index` the currently active document within that session
///
/// `SelectSession` and `SelectDocument` set these cursors. All commands that
/// do not carry an explicit target operate on the active cursor.
///
/// Only `DocumentCollection` sessions are user-managed. The single
/// `DirectoryBrowser` session is created by the UI and cannot be closed,
/// renamed, or removed.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SessionCommand { pub enum SessionCommand {
// Lifecycle // Session lifecycle
/// Create a new empty session of the given kind. /// Create a new empty `DocumentCollection` session.
New { name: String, kind: CollectionKind }, NewSession { name: String },
/// Open sessions from an existing .ron file. /// Load sessions from an existing .ron file.
Open { path: PathBuf }, OpenSession { path: PathBuf },
/// Switch the active session by name. /// Close and remove the active session from the store.
Select { name: String }, CloseSession,
/// Persist all sessions to the .ron file.
SaveSession,
/// Set the active session cursor by name.
SelectSession { name: String },
/// Duplicate the active session as a new `DocumentCollection`.
///
/// When the source is a `DirectoryBrowser` this produces a snapshot:
/// the current items are copied into a new `DocumentCollection`.
DuplicateSession { new_name: String },
/// Remove the active `DocumentCollection` session without saving.
RemoveSession,
/// Rename the active `DocumentCollection` session.
RenameSession { name: String },
// Collection management // Collection management within the active session
/// Scan a directory and replace the active session's items. /// Scan a directory and populate the active `DirectoryBrowser` session.
AddDirectory { dir: PathBuf }, AddDirectoryToSession { dir: PathBuf },
/// Add a single document to the active session. /// Add a single document to the active `DocumentCollection` session.
AddDocument { path: PathBuf }, AddDocumentToSession { path: PathBuf },
/// Copy a document from one session to another.
CopyDocument {
source: String,
target: String,
source_index: usize,
target_index: usize,
},
/// Remove a document by index from the active session.
RemoveDocument { index: usize },
// Navigation // Document management within the active session
/// Navigate to the next document in the active session. /// Set the active document cursor by index.
NextDocument,
/// Navigate to the previous document in the active session.
PreviousDocument,
/// Select a document by index in the active session.
SelectDocument { index: usize }, SelectDocument { index: usize },
/// Copy the active document into the clipboard.
CopyDocument,
/// Paste the clipboard item at the end of the active session.
PasteDocument,
/// Remove the active document from the active session.
RemoveDocument,
/// Move the active document cursor to the next item.
NextDocument,
/// Move the active document cursor to the previous item.
PreviousDocument,
/// Move the active document one position towards the front.
MoveDocumentUp,
/// Move the active document one position towards the back.
MoveDocumentDown,
/// Move the active document to an explicit position.
MoveDocumentTo { index: usize },
} }

View file

@ -1,28 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// src/document/session/data.rs // src/document/session/data.rs
// //
// A session is the unit of work in Noctua. It groups a set of document items // Pure data structures for sessions and their document items.
// under a name and tracks which item is currently active.
// //
// There are two kinds of collection: // Operational state (active session cursor, active document cursor, clipboard)
// // lives in the manager, not here. This module contains only what is persisted.
// DirectoryBrowser
// Represents a directory on disk. Items are populated by scanning the
// directory at open time and re-scanned each time the session file is
// loaded the persisted item list is intentionally ignored on load.
// The source directory is stored in `path`.
//
// DocumentCollection
// A curated, ordered list of documents (or individual pages within them).
// Items are persisted exactly as stored. Missing files are detected and
// removed with a warning when the session file is loaded.
//
// The on-disk representation of a session list is managed by `store.rs`.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
/// Determines how a session's items are managed. /// Determines how a session's items are managed.
///
/// A `DirectoryBrowser` session is created once by the UI and cannot be closed
/// or removed by the user. It can be duplicated via `DuplicateSession`, which
/// produces a new `DocumentCollection` containing a snapshot of the current items.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CollectionKind { pub enum CollectionKind {
/// Items are derived from a directory scan; `path` holds the source directory. /// Items are derived from a directory scan; `path` holds the source directory.
@ -48,19 +39,19 @@ impl SessionItem {
} }
} }
/// The full state of a session. /// The persistable state of a single session.
///
/// Operational state (active document index, clipboard) lives in the manager.
/// This struct is serialized as-is to the RON session file.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData { pub struct SessionData {
pub name: String, pub name: String,
pub kind: CollectionKind, pub kind: CollectionKind,
/// For `DirectoryBrowser`: the source directory to scan. /// For `DirectoryBrowser`: the source directory to scan.
/// For `DocumentCollection`: None (items are managed individually). /// For `DocumentCollection`: `None` (items are managed individually).
/// The .ron session file path is tracked separately.
#[serde(default)] #[serde(default)]
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
pub items: Vec<SessionItem>, pub items: Vec<SessionItem>,
/// Index of the currently active item, or `None` if the session is empty.
pub current_index: Option<usize>,
} }
impl SessionData { impl SessionData {
@ -71,13 +62,11 @@ impl SessionData {
path: Option<PathBuf>, path: Option<PathBuf>,
items: Vec<SessionItem>, items: Vec<SessionItem>,
) -> Self { ) -> Self {
let current_index = if items.is_empty() { None } else { Some(0) };
Self { Self {
name: name.to_string(), name: name.to_string(),
kind, kind,
path, path,
items, items,
current_index,
} }
} }
} }

View file

@ -54,7 +54,6 @@ pub fn load_sessions(path: &Path) -> Result<Vec<SessionData>> {
/// ///
/// Returns [`Error::Session`] if the sessions cannot be serialized. /// Returns [`Error::Session`] if the sessions cannot be serialized.
/// Returns [`Error::Io`] if the file cannot be written. /// Returns [`Error::Io`] if the file cannot be written.
#[allow(dead_code)]
pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> { pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> {
let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default()) let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default())
.map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?; .map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?;