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.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
// 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::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::DocumentContent;
use crate::error::Error;
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)]
pub enum DocumentState {
Empty,
@ -21,9 +21,20 @@ pub enum DocumentState {
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 {
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,
}
@ -31,7 +42,10 @@ impl Default for DocumentManager {
fn default() -> Self {
Self {
sessions: Vec::new(),
active_session_name: None,
active_session_index: None,
active_document_index: None,
clipboard: None,
session_file_path: None,
state: DocumentState::Empty,
}
}
@ -43,79 +57,92 @@ impl DocumentManager {
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) {
match command {
// -- Lifecycle --
SessionCommand::New { name, kind } => {
self.create_session(&name, kind);
}
SessionCommand::Open { path } => {
self.open_session_file(&path);
}
SessionCommand::Select { name } => {
if self.session(&name).is_some() {
self.active_session_name = Some(name);
}
}
// Session lifecycle
SessionCommand::NewSession { name } => self.new_session(&name),
SessionCommand::OpenSession { path } => self.open_session_file(&path),
SessionCommand::CloseSession | SessionCommand::RemoveSession => self.close_session(),
SessionCommand::SaveSession => self.save_session(),
SessionCommand::SelectSession { name } => self.select_session(&name),
SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name),
SessionCommand::RenameSession { name } => self.rename_session(&name),
// -- Collection management --
SessionCommand::AddDirectory { dir } => {
// Collection management
SessionCommand::AddDirectoryToSession { dir } => {
if let Some(session) = self.active_session_mut() {
scan_directory_into(session, &dir);
}
self.reset_document_cursor();
}
SessionCommand::AddDocument { 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);
}
SessionCommand::AddDocumentToSession { path } => self.add_document(&path),
// -- 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::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.
#[must_use]
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)
}
// -- 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> {
let name = self.active_session_name.clone()?;
self.sessions.iter_mut().find(|s| s.name == name)
self.active_session_index.map(|i| &mut self.sessions[i])
}
fn create_session(&mut self, name: &str, kind: CollectionKind) {
if self.session(name).is_none() {
self.sessions
.push(SessionData::new(name, kind, None, Vec::new()));
fn index_by_name(&self, name: &str) -> Option<usize> {
self.sessions.iter().position(|s| s.name == name)
}
/// 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) {
@ -141,23 +168,19 @@ impl DocumentManager {
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()
.find(|s| s.kind == CollectionKind::DirectoryBrowser)
.map(|s| s.name.clone());
.position(|s| s.kind == CollectionKind::DirectoryBrowser);
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.reset_document_cursor();
}
Err(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) {
self.state = DocumentState::Loading;
match load_document(path) {
Ok(content) => {
if let Some(session) = self.active_session_mut() {
if let Some(idx) = session.items.iter().position(|item| item.path == path) {
session.current_index = Some(idx);
if session.kind == CollectionKind::DocumentCollection
&& !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);
}
Err(e) => {
@ -183,111 +280,145 @@ impl DocumentManager {
}
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() {
session.current_index = Some(index);
self.active_document_index = Some(index);
}
}
}
fn copy_document(&mut self) {
let Some(idx) = self.active_document_index else {
return;
};
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) {
if let Some(session) = self.active_session_mut() {
if let Some(idx) = session.current_index {
if idx + 1 < session.items.len() {
session.current_index = Some(idx + 1);
}
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) {
if let Some(session) = self.active_session_mut() {
if let Some(idx) = session.current_index {
if idx > 0 {
session.current_index = Some(idx - 1);
}
if let Some(idx) = self.active_document_index {
if idx > 0 {
self.active_document_index = Some(idx - 1);
}
}
}
fn copy_document(
&mut self,
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"
)));
fn move_document_up(&mut self) {
let Some(idx) = self.active_document_index else {
return;
};
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(),
));
if idx == 0 {
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 {
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;
}
session.items.swap(idx, idx + 1);
self.active_document_index = Some(idx + 1);
}
session.items.remove(index);
let new_len = session.items.len();
if new_len == 0 {
session.current_index = None;
} else if let Some(current) = session.current_index {
if current >= new_len {
session.current_index = Some(new_len - 1);
}
fn move_document_to(&mut self, target: usize) {
let Some(idx) = self.active_document_index else {
return;
};
let Some(session) = self.active_session_mut() else {
return;
};
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.
use crate::document::session::data::CollectionKind;
use std::path::PathBuf;
/// Commands operating on sessions and their document collections.
///
/// All collection-mutating commands operate on the active session
/// unless they explicitly name a session.
/// Two implicit cursors exist in the manager:
/// - `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)]
pub enum SessionCommand {
// Lifecycle
/// Create a new empty session of the given kind.
New { name: String, kind: CollectionKind },
/// Open sessions from an existing .ron file.
Open { path: PathBuf },
/// Switch the active session by name.
Select { name: String },
// Session lifecycle
/// Create a new empty `DocumentCollection` session.
NewSession { name: String },
/// Load sessions from an existing .ron file.
OpenSession { path: PathBuf },
/// Close and remove the active session from the store.
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
/// Scan a directory and replace the active session's items.
AddDirectory { dir: PathBuf },
/// Add a single document to the active session.
AddDocument { 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 },
// Collection management within the active session
/// Scan a directory and populate the active `DirectoryBrowser` session.
AddDirectoryToSession { dir: PathBuf },
/// Add a single document to the active `DocumentCollection` session.
AddDocumentToSession { path: PathBuf },
// Navigation
/// Navigate to the next document in the active session.
NextDocument,
/// Navigate to the previous document in the active session.
PreviousDocument,
/// Select a document by index in the active session.
// Document management within the active session
/// Set the active document cursor by index.
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
// src/document/session/data.rs
//
// A session is the unit of work in Noctua. It groups a set of document items
// under a name and tracks which item is currently active.
// Pure data structures for sessions and their document items.
//
// There are two kinds of collection:
//
// 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`.
// Operational state (active session cursor, active document cursor, clipboard)
// lives in the manager, not here. This module contains only what is persisted.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 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)]
pub enum CollectionKind {
/// 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)]
pub struct SessionData {
pub name: String,
pub kind: CollectionKind,
/// For `DirectoryBrowser`: the source directory to scan.
/// For `DocumentCollection`: None (items are managed individually).
/// The .ron session file path is tracked separately.
/// For `DocumentCollection`: `None` (items are managed individually).
#[serde(default)]
pub path: Option<PathBuf>,
pub items: Vec<SessionItem>,
/// Index of the currently active item, or `None` if the session is empty.
pub current_index: Option<usize>,
}
impl SessionData {
@ -71,13 +62,11 @@ impl SessionData {
path: Option<PathBuf>,
items: Vec<SessionItem>,
) -> Self {
let current_index = if items.is_empty() { None } else { Some(0) };
Self {
name: name.to_string(),
kind,
path,
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::Io`] if the file cannot be written.
#[allow(dead_code)]
pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> {
let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default())
.map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?;