refactor(session): introduce cursor model and full command set
This commit is contained in:
parent
767315a65e
commit
9ec407d9aa
5 changed files with 340 additions and 204 deletions
|
|
@ -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)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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) {
|
fn navigate_next(&mut self) {
|
||||||
if let Some(session) = self.active_session_mut() {
|
let len = self.active_session().map_or(0, |s| s.items.len());
|
||||||
if let Some(idx) = session.current_index {
|
if let Some(idx) = self.active_document_index {
|
||||||
if idx + 1 < session.items.len() {
|
if idx + 1 < len {
|
||||||
session.current_index = Some(idx + 1);
|
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 {
|
self.active_document_index = Some(idx - 1);
|
||||||
session.current_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}")))?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue