385 lines
14 KiB
Rust
385 lines
14 KiB
Rust
use std::collections::{hash_map::Entry, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use futures_util::stream::AbortHandle;
|
|
use markdown_parser::markdown_parser::parse_markdown_to_raw_text;
|
|
use galaxyui::{
|
|
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle,
|
|
WindowId,
|
|
};
|
|
|
|
use crate::{
|
|
cloud_object::{
|
|
model::persistence::{CloudModel, CloudModelEvent},
|
|
Owner,
|
|
},
|
|
drive::OpenWarpDriveObjectSettings,
|
|
pane_group::{NotebookPane, PaneContent},
|
|
safe_debug, safe_warn,
|
|
server::{
|
|
cloud_objects::update_manager::{
|
|
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
|
},
|
|
ids::SyncId,
|
|
},
|
|
workspace::PaneViewLocator,
|
|
};
|
|
|
|
use super::{notebook::NotebookView, CloudNotebook};
|
|
|
|
#[cfg(test)]
|
|
#[path = "manager_tests.rs"]
|
|
mod tests;
|
|
|
|
/// A singleton model tracking open notebooks.
|
|
///
|
|
/// This is tightly tied to the [workspace](crate::workspace::Workspace) and
|
|
/// [pane group](crate::pane_group::PaneGroup) views, as they contain all open notebook panes.
|
|
///
|
|
/// The overall flow is:
|
|
/// 1. A `Workspace` is asked to open a notebook (from the Warp Drive index, universal search, etc.).
|
|
/// 2. It checks the `NotebookManager` to see if the notebook is already open.
|
|
/// 3. If it is, the existing notebook pane is focused (this may be in another window).
|
|
/// 4. If not, the `Workspace` uses the `NotebookManager` to create a new notebook pane and
|
|
/// attaches it to the active tab.
|
|
/// 5. When the new pane is attached to a pane group, it registers itself with the `NotebookManager`.
|
|
/// This is because we need the pane group's ID in order to re-focus the pane.
|
|
/// 6. When the pane is closed, it de-registers itself from the `NotebookManager`.
|
|
///
|
|
/// During session restoration, notebook panes are created and attached by the `PaneGroup`.
|
|
///
|
|
/// NotebookManager also manages a cache of the raw, unformatted text of notebooks
|
|
/// which is needed for notebook search.
|
|
pub struct NotebookManager {
|
|
panes_by_hashed_id: HashMap<String, NotebookPaneData>,
|
|
// Cache
|
|
raw_text_by_hashed_id: HashMap<String, NotebookRawTextStatus>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum NotebookRawTextStatus {
|
|
NotParsed,
|
|
ParseInFlight(AbortHandle),
|
|
// We store this as an arc so it can be used in fuzzy searches
|
|
// without cloning the notebook's entire parsed contents.
|
|
Parsed(Arc<str>),
|
|
ParseError,
|
|
}
|
|
|
|
/// Source for a new notebook pane.
|
|
#[derive(Debug, Clone)]
|
|
pub enum NotebookSource {
|
|
Existing(SyncId),
|
|
New {
|
|
title: Option<String>,
|
|
owner: Owner,
|
|
initial_folder_id: Option<SyncId>,
|
|
},
|
|
}
|
|
|
|
impl NotebookManager {
|
|
/// Create a new [`NotebookManager`] singleton.
|
|
pub fn new(cached_notebooks: Vec<CloudNotebook>, ctx: &mut ModelContext<Self>) -> Self {
|
|
ctx.subscribe_to_model(
|
|
&UpdateManager::handle(ctx),
|
|
Self::handle_update_manager_event,
|
|
);
|
|
|
|
ctx.subscribe_to_model(&CloudModel::handle(ctx), Self::handle_cloud_model_event);
|
|
|
|
let mut raw_text_by_hashed_id: HashMap<String, NotebookRawTextStatus> = HashMap::new();
|
|
// Parse all the cached notebook raw text
|
|
|
|
cached_notebooks.into_iter().for_each(|notebook| {
|
|
let hashed_id = notebook.id.uid();
|
|
let handle = Self::spawn_raw_text_parse_for_notebook(notebook, ctx);
|
|
raw_text_by_hashed_id.insert(
|
|
hashed_id,
|
|
NotebookRawTextStatus::ParseInFlight(handle.abort_handle()),
|
|
);
|
|
});
|
|
|
|
Self {
|
|
panes_by_hashed_id: HashMap::new(),
|
|
raw_text_by_hashed_id,
|
|
}
|
|
}
|
|
|
|
fn spawn_raw_text_parse_for_notebook(
|
|
notebook: CloudNotebook,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> SpawnedFutureHandle {
|
|
let hashed_id = notebook.id.uid();
|
|
ctx.spawn(
|
|
async move { parse_markdown_to_raw_text(¬ebook.model().data) },
|
|
move |manager, response, _ctx| match response {
|
|
Ok(parsed_text) => {
|
|
manager.raw_text_by_hashed_id.insert(
|
|
hashed_id,
|
|
NotebookRawTextStatus::Parsed(Arc::from(parsed_text)),
|
|
);
|
|
}
|
|
Err(err) => {
|
|
manager
|
|
.raw_text_by_hashed_id
|
|
.insert(hashed_id, NotebookRawTextStatus::ParseError);
|
|
log::error!("Cached Notebook raw text failed to parse: {err}.");
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Create a mock [`NotebookManager`] for use in tests.
|
|
#[cfg(test)]
|
|
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
|
|
Self::new(Vec::new(), ctx)
|
|
}
|
|
|
|
/// If the notebook is already open in a pane, finds the location of that pane.
|
|
pub fn find_pane(&self, source: &NotebookSource) -> Option<(WindowId, PaneViewLocator)> {
|
|
match source {
|
|
NotebookSource::Existing(notebook_id) => {
|
|
let pane_data = self.panes_by_hashed_id.get(¬ebook_id.uid())?;
|
|
Some((pane_data.window_id, pane_data.locator))
|
|
}
|
|
NotebookSource::New { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
|
if let CloudModelEvent::ObjectUpdated { type_and_id, .. } = event {
|
|
if let Some(notebook_id) = type_and_id.as_notebook_id() {
|
|
self.update_raw_text_for_notebook(notebook_id, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the raw text of a given notebook id - if it exists in the cache.
|
|
pub fn notebook_raw_text(&self, notebook_id: SyncId) -> Option<&str> {
|
|
match self
|
|
.raw_text_by_hashed_id
|
|
.get(¬ebook_id.uid())
|
|
.unwrap_or(&NotebookRawTextStatus::NotParsed)
|
|
{
|
|
NotebookRawTextStatus::Parsed(text) => Some(text),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Returns a shared handle to the parsed raw text.
|
|
pub fn notebook_raw_text_shared(&self, notebook_id: SyncId) -> Option<Arc<str>> {
|
|
match self
|
|
.raw_text_by_hashed_id
|
|
.get(¬ebook_id.uid())
|
|
.unwrap_or(&NotebookRawTextStatus::NotParsed)
|
|
{
|
|
NotebookRawTextStatus::Parsed(text) => Some(text.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Unconditionally create a new notebook pane.
|
|
pub fn create_pane(
|
|
&mut self,
|
|
source: &NotebookSource,
|
|
settings: &OpenWarpDriveObjectSettings,
|
|
window_id: WindowId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> NotebookPane {
|
|
let view = ctx.add_typed_action_view(window_id, NotebookView::new);
|
|
|
|
match source {
|
|
NotebookSource::Existing(notebook_id) => {
|
|
let notebook = CloudModel::as_ref(ctx).get_notebook(notebook_id).cloned();
|
|
if let Some(notebook) = notebook {
|
|
view.update(ctx, |view, ctx| view.load(notebook, settings, ctx));
|
|
} else {
|
|
// If the notebook doesn't exist yet, try waiting for initial load and check again
|
|
view.update(ctx, |view, ctx| {
|
|
view.wait_for_initial_load_then_load(*notebook_id, settings, window_id, ctx)
|
|
});
|
|
}
|
|
}
|
|
NotebookSource::New {
|
|
title,
|
|
owner,
|
|
initial_folder_id,
|
|
} => view.update(ctx, |view, ctx| {
|
|
view.open_new_notebook(title.clone(), *owner, *initial_folder_id, ctx);
|
|
}),
|
|
}
|
|
|
|
NotebookPane::new(view, ctx)
|
|
}
|
|
|
|
/// Register an open notebook pane once it's bound to a pane group.
|
|
pub fn register_pane(
|
|
&mut self,
|
|
pane: &NotebookPane,
|
|
pane_group_id: EntityId,
|
|
window_id: WindowId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Some(notebook_id) = pane.notebook_view(ctx).as_ref(ctx).notebook_id(ctx) else {
|
|
log::warn!("Notebook pane has no notebook ID");
|
|
return;
|
|
};
|
|
|
|
let entry = self.panes_by_hashed_id.entry(notebook_id.uid());
|
|
if let Entry::Vacant(entry) = entry {
|
|
entry.insert(NotebookPaneData {
|
|
notebook_id,
|
|
window_id,
|
|
locator: PaneViewLocator {
|
|
pane_group_id,
|
|
pane_id: pane.id(),
|
|
},
|
|
handle: pane.notebook_view(ctx).downgrade(),
|
|
});
|
|
} else {
|
|
safe_warn!(
|
|
safe: ("Ignoring duplicate notebook pane registration"),
|
|
full: ("Ignoring duplicate notebook pane registration for {notebook_id}")
|
|
);
|
|
}
|
|
}
|
|
|
|
// De-register an open notebook pane when it's removed from a pane group.
|
|
pub fn deregister_pane(&mut self, pane: &NotebookPane, ctx: &mut ModelContext<Self>) {
|
|
let Some(notebook_id) = pane.notebook_view(ctx).as_ref(ctx).notebook_id(ctx) else {
|
|
log::warn!("Notebook pane has no notebook ID");
|
|
return;
|
|
};
|
|
|
|
// If a notebook pane is restored, the notebook may have been reopened in the meantime. In
|
|
// that case, don't let closing the original pane clear out the new pane.
|
|
if let Entry::Occupied(entry) = self.panes_by_hashed_id.entry(notebook_id.uid()) {
|
|
if entry.get().locator.pane_id == pane.id() {
|
|
entry.remove();
|
|
} else {
|
|
log::warn!(
|
|
"Ignoring duplicate registration of panes for {}",
|
|
notebook_id.uid()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawns an async thread to compute the notebook's raw text, adds this
|
|
/// result to the cache ones the operation has been completed.
|
|
fn update_raw_text_for_notebook(&mut self, notebook_id: SyncId, ctx: &mut ModelContext<Self>) {
|
|
log::debug!("Updating raw text cache for {}", notebook_id.uid());
|
|
let Some(notebook) = CloudModel::handle(ctx).read(ctx, |model, _| {
|
|
Some(model.get_notebook(¬ebook_id)?.clone())
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
if let Some(NotebookRawTextStatus::ParseInFlight(abort_handle)) =
|
|
self.raw_text_by_hashed_id.get(¬ebook_id.uid())
|
|
{
|
|
// If there's already a parse in flight, abort it
|
|
abort_handle.abort();
|
|
}
|
|
|
|
let handle = Self::spawn_raw_text_parse_for_notebook(notebook, ctx);
|
|
|
|
self.raw_text_by_hashed_id.insert(
|
|
notebook_id.uid(),
|
|
NotebookRawTextStatus::ParseInFlight(handle.abort_handle()),
|
|
);
|
|
}
|
|
|
|
fn handle_update_manager_event(
|
|
&mut self,
|
|
event: &UpdateManagerEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
|
return;
|
|
};
|
|
|
|
if !matches!(&result.success_type, OperationSuccessType::Success) {
|
|
return;
|
|
}
|
|
if let ObjectOperation::Create { .. } = result.operation {
|
|
let server_id = result.server_id.expect("Expect server id on success");
|
|
let Some(server_id) = CloudModel::as_ref(ctx)
|
|
.get_notebook_by_uid(&server_id.uid())
|
|
.and_then(|notebook| notebook.id.into_server())
|
|
else {
|
|
return;
|
|
};
|
|
let Some(client_id) = result.client_id else {
|
|
return;
|
|
};
|
|
|
|
if let Some(mut pane) = self.panes_by_hashed_id.remove(&client_id.to_string()) {
|
|
pane.notebook_id = SyncId::ServerId(server_id);
|
|
self.panes_by_hashed_id
|
|
.insert(server_id.uid().clone(), pane);
|
|
}
|
|
if let Some(parse_status) = self.raw_text_by_hashed_id.remove(&client_id.to_string()) {
|
|
self.raw_text_by_hashed_id
|
|
.insert(server_id.uid(), parse_status);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Swap the ID of the notebook open in a pane. This assumes the pane location and view are
|
|
/// unchanged.
|
|
pub(super) fn swap_notebook(&mut self, old_id: SyncId, new_id: SyncId) {
|
|
if let Some(mut pane_data) = self.panes_by_hashed_id.remove(&old_id.uid()) {
|
|
debug_assert_eq!(pane_data.notebook_id, old_id);
|
|
pane_data.notebook_id = new_id;
|
|
debug_assert!(
|
|
self.panes_by_hashed_id
|
|
.insert(new_id.uid(), pane_data)
|
|
.is_none(),
|
|
"New notebook was already open"
|
|
);
|
|
} else {
|
|
log::warn!("Tried to swap notebooks, but the old one was not open");
|
|
}
|
|
}
|
|
|
|
/// Close all open notebooks, saving any changes. This is called before the app terminates to
|
|
/// prevent data loss, since notebooks are not saved immediately after every user edit.
|
|
pub fn close_notebooks(&self, ctx: &mut ModelContext<Self>) {
|
|
for pane in self.panes_by_hashed_id.values() {
|
|
if let Some(notebook_view) = pane.handle.upgrade(ctx) {
|
|
safe_debug!(
|
|
safe : ("Closing notebook on termination"),
|
|
full: ("Closing notebook {} on termination", pane.notebook_id)
|
|
);
|
|
notebook_view.update(ctx, |view, ctx| view.on_detach(ctx));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reset the notebook manager state for logout.
|
|
///
|
|
/// This _does not_ save any pending notebook changes.
|
|
pub fn reset(&mut self) {
|
|
self.panes_by_hashed_id.clear();
|
|
for (_, status) in self.raw_text_by_hashed_id.drain() {
|
|
if let NotebookRawTextStatus::ParseInFlight(handle) = status {
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NotebookPaneData {
|
|
notebook_id: SyncId,
|
|
window_id: WindowId,
|
|
handle: WeakViewHandle<NotebookView>,
|
|
locator: PaneViewLocator,
|
|
}
|
|
|
|
impl Entity for NotebookManager {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for NotebookManager {}
|