diff --git a/Cargo.lock b/Cargo.lock index e09e41d155..411e89664f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5493,7 +5493,6 @@ dependencies = [ "gpui", "project", "ui", - "util", "workspace", ] @@ -8536,6 +8535,7 @@ dependencies = [ "rpc", "serde", "serde_json", + "settings", "smol", "task", "terminal_view", @@ -10919,18 +10919,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c" dependencies = [ "proc-macro2", "quote", diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 06b74de825..ecace7c076 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -45,7 +45,7 @@ use ui::{h_flex, prelude::*, Icon, IconName, Label}; use util::ResultExt; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, - ItemNavHistory, Pane, ToolbarItemLocation, Workspace, + ItemNavHistory, ToolbarItemLocation, Workspace, }; actions!(diagnostics, [Deploy, ToggleWarnings]); @@ -786,20 +786,6 @@ impl Item for ProjectDiagnosticsEditor { self.editor .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); } - - fn serialized_item_kind() -> Option<&'static str> { - Some("diagnostics") - } - - fn deserialize( - project: Model, - workspace: WeakView, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, - cx: &mut ViewContext, - ) -> Task>> { - Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx)))) - } } const DIAGNOSTIC_HEADER: &'static str = "diagnostic header"; diff --git a/crates/diagnostics/src/grouped_diagnostics.rs b/crates/diagnostics/src/grouped_diagnostics.rs index db790e75f2..043e0f5825 100644 --- a/crates/diagnostics/src/grouped_diagnostics.rs +++ b/crates/diagnostics/src/grouped_diagnostics.rs @@ -40,7 +40,7 @@ use ui::{h_flex, prelude::*, Icon, IconName, Label}; use util::{debug_panic, ResultExt}; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, - ItemNavHistory, Pane, ToolbarItemLocation, Workspace, + ItemNavHistory, ToolbarItemLocation, Workspace, }; use crate::project_diagnostics_settings::ProjectDiagnosticsSettings; @@ -603,20 +603,6 @@ impl Item for GroupedDiagnosticsEditor { self.editor .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); } - - fn serialized_item_kind() -> Option<&'static str> { - Some("diagnostics") - } - - fn deserialize( - project: Model, - workspace: WeakView, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, - cx: &mut ViewContext, - ) -> Task>> { - Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx)))) - } } fn compare_data_locations( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 99a7043156..ba414e01af 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -272,7 +272,8 @@ pub fn init(cx: &mut AppContext) { workspace::register_project_item::(cx); workspace::FollowableViewRegistry::register::(cx); - workspace::register_deserializable_item::(cx); + workspace::register_serializable_item::(cx); + cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { workspace.register_action(Editor::new_file); @@ -550,6 +551,7 @@ pub struct Editor { show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, git_blame_inline_enabled: bool, + serialize_dirty_buffers: bool, show_selection_menu: Option, blame: Option>, blame_subscription: Option, @@ -1876,6 +1878,9 @@ impl Editor { show_selection_menu: None, show_git_blame_inline_delay_task: None, git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(), + serialize_dirty_buffers: ProjectSettings::get_global(cx) + .session + .restore_unsaved_buffers, blame: None, blame_subscription: None, file_header_size, @@ -11250,8 +11255,11 @@ impl Editor { self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; + let project_settings = ProjectSettings::get_global(cx); + self.serialize_dirty_buffers = project_settings.session.restore_unsaved_buffers; + if self.mode == EditorMode::Full { - let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled(); + let inline_blame_enabled = project_settings.git.inline_blame_enabled(); if self.git_blame_inline_enabled != inline_blame_enabled { self.toggle_git_blame_inline_internal(false, cx); } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index b923ec36e8..5f545f7a36 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -16,10 +16,13 @@ use language::{ proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, Point, SelectionGoal, }; use multi_buffer::AnchorRangeExt; -use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath}; +use project::{ + project_settings::ProjectSettings, search::SearchQuery, FormatTrigger, Item as _, Project, + ProjectPath, +}; use rpc::proto::{self, update_view, PeerId}; use settings::Settings; -use workspace::item::{Dedup, ItemSettings, TabContentParams}; +use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use std::{ any::TypeId, @@ -36,7 +39,7 @@ use ui::{h_flex, prelude::*, Label}; use util::{paths::PathExt, ResultExt, TryFutureExt}; use workspace::item::{BreadcrumbText, FollowEvent}; use workspace::{ - item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem}, + item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, ItemId, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, }; @@ -837,54 +840,8 @@ impl Item for Editor { Some(breadcrumbs) } - fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + fn added_to_workspace(&mut self, workspace: &mut Workspace, _: &mut ViewContext) { self.workspace = Some((workspace.weak_handle(), workspace.database_id())); - let Some(workspace_id) = workspace.database_id() else { - return; - }; - - let item_id = cx.view().item_id().as_u64() as ItemId; - - fn serialize( - buffer: Model, - workspace_id: WorkspaceId, - item_id: ItemId, - cx: &mut AppContext, - ) { - if let Some(file) = buffer.read(cx).file().and_then(|file| file.as_local()) { - let path = file.abs_path(cx); - - cx.background_executor() - .spawn(async move { - DB.save_path(item_id, workspace_id, path.clone()) - .await - .log_err() - }) - .detach(); - } - } - - if let Some(buffer) = self.buffer().read(cx).as_singleton() { - serialize(buffer.clone(), workspace_id, item_id, cx); - - cx.subscribe(&buffer, |this, buffer, event, cx| { - if let Some((_, Some(workspace_id))) = this.workspace.as_ref() { - if let language::Event::FileHandleChanged = event { - serialize( - buffer, - *workspace_id, - cx.view().item_id().as_u64() as ItemId, - cx, - ); - } - } - }) - .detach(); - } - } - - fn serialized_item_kind() -> Option<&'static str> { - Some("Editor") } fn to_item_events(event: &EditorEvent, mut f: impl FnMut(ItemEvent)) { @@ -920,6 +877,20 @@ impl Item for Editor { _ => {} } } +} + +impl SerializableItem for Editor { + fn serialized_item_kind() -> &'static str { + "Editor" + } + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + cx: &mut WindowContext, + ) -> Task> { + cx.spawn(|_| DB.delete_unloaded_items(workspace_id, alive_items)) + } fn deserialize( project: Model, @@ -928,41 +899,171 @@ impl Item for Editor { item_id: ItemId, cx: &mut ViewContext, ) -> Task>> { - let project_item: Result<_> = project.update(cx, |project, cx| { - // Look up the path with this key associated, create a self with that path - let path = DB - .get_path(item_id, workspace_id)? - .context("No path stored for this editor")?; + let path_content_language = match DB + .get_path_and_contents(item_id, workspace_id) + .context("Failed to query editor state") + { + Ok(Some((path, content, language))) => { + if ProjectSettings::get_global(cx) + .session + .restore_unsaved_buffers + { + (path, content, language) + } else { + (path, None, None) + } + } + Ok(None) => { + return Task::ready(Err(anyhow!("No path or contents found for buffer"))); + } + Err(error) => { + return Task::ready(Err(error)); + } + }; - let (worktree, path) = project - .find_worktree(&path, cx) - .with_context(|| format!("No worktree for path: {path:?}"))?; - let project_path = ProjectPath { - worktree_id: worktree.read(cx).id(), - path: path.into(), - }; + match path_content_language { + (None, Some(content), language_name) => cx.spawn(|_, mut cx| async move { + let language = if let Some(language_name) = language_name { + let language_registry = + project.update(&mut cx, |project, _| project.languages().clone())?; - Ok(project.open_path(project_path, cx)) - }); + Some(language_registry.language_for_name(&language_name).await?) + } else { + None + }; - project_item - .map(|project_item| { - cx.spawn(|pane, mut cx| async move { - let (_, project_item) = project_item.await?; - let buffer = project_item - .downcast::() - .map_err(|_| anyhow!("Project item at stored path was not a buffer"))?; - pane.update(&mut cx, |_, cx| { - cx.new_view(|cx| { - let mut editor = Editor::for_buffer(buffer, Some(project), cx); + // First create the empty buffer + let buffer = project.update(&mut cx, |project, cx| { + project.create_local_buffer("", language, cx) + })?; - editor.read_scroll_position_from_db(item_id, workspace_id, cx); - editor + // Then set the text so that the dirty bit is set correctly + buffer.update(&mut cx, |buffer, cx| { + buffer.set_text(content, cx); + })?; + + cx.new_view(|cx| { + let mut editor = Editor::for_buffer(buffer, Some(project), cx); + editor.read_scroll_position_from_db(item_id, workspace_id, cx); + editor + }) + }), + (Some(path), contents, _) => { + let project_item = project.update(cx, |project, cx| { + let (worktree, path) = project + .find_worktree(&path, cx) + .with_context(|| format!("No worktree for path: {path:?}"))?; + let project_path = ProjectPath { + worktree_id: worktree.read(cx).id(), + path: path.into(), + }; + + Ok(project.open_path(project_path, cx)) + }); + + project_item + .map(|project_item| { + cx.spawn(|pane, mut cx| async move { + let (_, project_item) = project_item.await?; + let buffer = project_item.downcast::().map_err(|_| { + anyhow!("Project item at stored path was not a buffer") + })?; + + // This is a bit wasteful: we're loading the whole buffer from + // disk and then overwrite the content. + // But for now, it keeps the implementation of the content serialization + // simple, because we don't have to persist all of the metadata that we get + // by loading the file (git diff base, mtime, ...). + if let Some(buffer_text) = contents { + buffer.update(&mut cx, |buffer, cx| { + buffer.set_text(buffer_text, cx); + })?; + } + + pane.update(&mut cx, |_, cx| { + cx.new_view(|cx| { + let mut editor = Editor::for_buffer(buffer, Some(project), cx); + + editor.read_scroll_position_from_db(item_id, workspace_id, cx); + editor + }) + }) }) }) + .unwrap_or_else(|error| Task::ready(Err(error))) + } + _ => Task::ready(Err(anyhow!("No path or contents found for buffer"))), + } + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: ItemId, + closing: bool, + cx: &mut ViewContext, + ) -> Option>> { + let mut serialize_dirty_buffers = self.serialize_dirty_buffers; + + let project = self.project.clone()?; + if project.read(cx).visible_worktrees(cx).next().is_none() { + // If we don't have a worktree, we don't serialize, because + // projects without worktrees aren't deserialized. + serialize_dirty_buffers = false; + } + + if closing && !serialize_dirty_buffers { + return None; + } + + let workspace_id = workspace.database_id()?; + + let buffer = self.buffer().read(cx).as_singleton()?; + + let is_dirty = buffer.read(cx).is_dirty(); + let path = buffer + .read(cx) + .file() + .and_then(|file| file.as_local()) + .map(|file| file.abs_path(cx)); + let snapshot = buffer.read(cx).snapshot(); + + Some(cx.spawn(|_this, cx| async move { + cx.background_executor() + .spawn(async move { + if let Some(path) = path { + DB.save_path(item_id, workspace_id, path.clone()) + .await + .context("failed to save path of buffer")? + } + + if serialize_dirty_buffers { + let (contents, language) = if is_dirty { + let contents = snapshot.text(); + let language = snapshot.language().map(|lang| lang.name().to_string()); + (Some(contents), language) + } else { + (None, None) + }; + + DB.save_contents(item_id, workspace_id, contents, language) + .await?; + } + + anyhow::Ok(()) }) - }) - .unwrap_or_else(|error| Task::ready(Err(error))) + .await + .context("failed to save contents of buffer")?; + + Ok(()) + })) + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + matches!( + event, + EditorEvent::Saved | EditorEvent::DirtyChanged | EditorEvent::BufferEdited + ) } } diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index 6e37735c13..ade605e99e 100644 --- a/crates/editor/src/persistence.rs +++ b/crates/editor/src/persistence.rs @@ -1,3 +1,5 @@ +use anyhow::Result; +use db::sqlez::statement::Statement; use std::path::PathBuf; use db::sqlez_macros::sql; @@ -10,10 +12,12 @@ define_connection!( // editors( // item_id: usize, // workspace_id: usize, - // path: PathBuf, + // path: Option, // scroll_top_row: usize, // scroll_vertical_offset: f32, // scroll_horizontal_offset: f32, + // content: Option, + // language: Option, // ) pub static ref DB: EditorDb = &[sql! ( @@ -31,13 +35,39 @@ define_connection!( ALTER TABLE editors ADD COLUMN scroll_top_row INTEGER NOT NULL DEFAULT 0; ALTER TABLE editors ADD COLUMN scroll_horizontal_offset REAL NOT NULL DEFAULT 0; ALTER TABLE editors ADD COLUMN scroll_vertical_offset REAL NOT NULL DEFAULT 0; + ), + sql! ( + // Since sqlite3 doesn't support ALTER COLUMN, we create a new + // table, move the data over, drop the old table, rename new table. + CREATE TABLE new_editors_tmp ( + item_id INTEGER NOT NULL, + workspace_id INTEGER NOT NULL, + path BLOB, // <-- No longer "NOT NULL" + scroll_top_row INTEGER NOT NULL DEFAULT 0, + scroll_horizontal_offset REAL NOT NULL DEFAULT 0, + scroll_vertical_offset REAL NOT NULL DEFAULT 0, + contents TEXT, // New + language TEXT, // New + PRIMARY KEY(item_id, workspace_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ) STRICT; + + INSERT INTO new_editors_tmp(item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset) + SELECT item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset + FROM editors; + + DROP TABLE editors; + + ALTER TABLE new_editors_tmp RENAME TO editors; )]; ); impl EditorDb { query! { - pub fn get_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { - SELECT path FROM editors + pub fn get_path_and_contents(item_id: ItemId, workspace_id: WorkspaceId) -> Result, Option, Option)>> { + SELECT path, contents, language FROM editors WHERE item_id = ? AND workspace_id = ? } } @@ -55,6 +85,20 @@ impl EditorDb { } } + query! { + pub async fn save_contents(item_id: ItemId, workspace: WorkspaceId, contents: Option, language: Option) -> Result<()> { + INSERT INTO editors + (item_id, workspace_id, contents, language) + VALUES + (?1, ?2, ?3, ?4) + ON CONFLICT DO UPDATE SET + item_id = ?1, + workspace_id = ?2, + contents = ?3, + language = ?4 + } + } + // Returns the scroll top row, and offset query! { pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { @@ -80,4 +124,75 @@ impl EditorDb { WHERE item_id = ?1 AND workspace_id = ?2 } } + + pub async fn delete_unloaded_items( + &self, + workspace: WorkspaceId, + alive_items: Vec, + ) -> Result<()> { + let placeholders = alive_items + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + + let query = format!( + "DELETE FROM editors WHERE workspace_id = ? AND item_id NOT IN ({placeholders})" + ); + + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&workspace, 1)?; + for id in alive_items { + next_index = statement.bind(&id, next_index)?; + } + statement.exec() + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui; + + #[gpui::test] + async fn test_saving_content() { + env_logger::try_init().ok(); + + let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); + + // Sanity check: make sure there is no row in the `editors` table + assert_eq!(DB.get_path_and_contents(1234, workspace_id).unwrap(), None); + + // Save content/language + DB.save_contents( + 1234, + workspace_id, + Some("testing".into()), + Some("Go".into()), + ) + .await + .unwrap(); + + // Check that it can be read from DB + let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap(); + let (path, contents, language) = path_and_contents.unwrap(); + assert!(path.is_none()); + assert_eq!(contents, Some("testing".to_owned())); + assert_eq!(language, Some("Go".to_owned())); + + // Update it with NULL + DB.save_contents(1234, workspace_id, None, None) + .await + .unwrap(); + + // Check that it worked + let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap(); + let (path, contents, language) = path_and_contents.unwrap(); + assert!(path.is_none()); + assert!(contents.is_none()); + assert!(language.is_none()); + } } diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 7b9e0d9db3..70fe1426e2 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -17,6 +17,5 @@ anyhow.workspace = true db.workspace = true gpui.workspace = true ui.workspace = true -util.workspace = true workspace.workspace = true project.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8c03a5d5f2..d654141a11 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -9,9 +9,8 @@ use ui::prelude::*; use project::{Project, ProjectEntryId, ProjectPath}; use std::{ffi::OsStr, path::PathBuf}; -use util::ResultExt; use workspace::{ - item::{Item, ProjectItem, TabContentParams}, + item::{Item, ProjectItem, SerializableItem, TabContentParams}, ItemId, Pane, Workspace, WorkspaceId, }; @@ -90,49 +89,6 @@ impl Item for ImageView { .into_any_element() } - fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { - let item_id = cx.entity_id().as_u64(); - let workspace_id = workspace.database_id(); - let image_path = self.path.clone(); - - if let Some(workspace_id) = workspace_id { - cx.background_executor() - .spawn({ - let image_path = image_path.clone(); - async move { - IMAGE_VIEWER - .save_image_path(item_id, workspace_id, image_path) - .await - .log_err(); - } - }) - .detach(); - } - } - - fn serialized_item_kind() -> Option<&'static str> { - Some(IMAGE_VIEWER_KIND) - } - - fn deserialize( - _project: Model, - _workspace: WeakView, - workspace_id: WorkspaceId, - item_id: ItemId, - cx: &mut ViewContext, - ) -> Task>> { - cx.spawn(|_pane, mut cx| async move { - let image_path = IMAGE_VIEWER - .get_image_path(item_id, workspace_id)? - .ok_or_else(|| anyhow::anyhow!("No image path found"))?; - - cx.new_view(|cx| ImageView { - path: image_path, - focus_handle: cx.focus_handle(), - }) - }) - } - fn clone_on_split( &self, _workspace_id: Option, @@ -148,6 +104,62 @@ impl Item for ImageView { } } +impl SerializableItem for ImageView { + fn serialized_item_kind() -> &'static str { + IMAGE_VIEWER_KIND + } + + fn deserialize( + _project: Model, + _workspace: WeakView, + workspace_id: WorkspaceId, + item_id: ItemId, + cx: &mut ViewContext, + ) -> Task>> { + cx.spawn(|_pane, mut cx| async move { + let image_path = IMAGE_VIEWER + .get_image_path(item_id, workspace_id)? + .ok_or_else(|| anyhow::anyhow!("No image path found"))?; + + cx.new_view(|cx| ImageView { + path: image_path, + focus_handle: cx.focus_handle(), + }) + }) + } + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + cx: &mut WindowContext, + ) -> Task> { + cx.spawn(|_| IMAGE_VIEWER.delete_unloaded_items(workspace_id, alive_items)) + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: ItemId, + _closing: bool, + cx: &mut ViewContext, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + + Some(cx.background_executor().spawn({ + let image_path = self.path.clone(); + async move { + IMAGE_VIEWER + .save_image_path(item_id, workspace_id, image_path) + .await + } + })) + } + + fn should_serialize(&self, _event: &Self::Event) -> bool { + false + } +} + impl EventEmitter<()> for ImageView {} impl FocusableView for ImageView { fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { @@ -242,13 +254,14 @@ impl ProjectItem for ImageView { pub fn init(cx: &mut AppContext) { workspace::register_project_item::(cx); - workspace::register_deserializable_item::(cx) + workspace::register_serializable_item::(cx) } mod persistence { + use anyhow::Result; use std::path::PathBuf; - use db::{define_connection, query, sqlez_macros::sql}; + use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; define_connection! { @@ -298,5 +311,29 @@ mod persistence { WHERE item_id = ? AND workspace_id = ? } } + + pub async fn delete_unloaded_items( + &self, + workspace: WorkspaceId, + alive_items: Vec, + ) -> Result<()> { + let placeholders = alive_items + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + + let query = format!("DELETE FROM image_viewers WHERE workspace_id = ? AND item_id NOT IN ({placeholders})"); + + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&workspace, 1)?; + for id in alive_items { + next_index = statement.bind(&id, next_index)?; + } + statement.exec() + }) + .await + } } } diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index ecb2b31cb0..63bdc6da74 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -24,6 +24,10 @@ pub struct ProjectSettings { /// Configuration for how direnv configuration should be loaded #[serde(default)] pub load_direnv: DirenvSettings, + + /// Configuration for session-related features + #[serde(default)] + pub session: SessionSettings, } #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] @@ -122,6 +126,25 @@ pub struct LspSettings { pub settings: Option, } +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct SessionSettings { + /// Whether or not to restore unsaved buffers on restart. + /// + /// If this is true, user won't be prompted whether to save/discard + /// dirty files when closing the application. + /// + /// Default: true + pub restore_unsaved_buffers: bool, +} + +impl Default for SessionSettings { + fn default() -> Self { + Self { + restore_unsaved_buffers: true, + } + } +} + impl Settings for ProjectSettings { const KEY: Option<&'static str> = None; diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 08fd70dc30..2289f9cf63 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -38,5 +38,6 @@ workspace.workspace = true editor = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } serde_json.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 17d43452d3..799a832715 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -703,9 +703,10 @@ mod tests { use std::path::PathBuf; use editor::Editor; - use gpui::{TestAppContext, WindowHandle}; - use project::Project; + use gpui::{TestAppContext, UpdateGlobal, WindowHandle}; + use project::{project_settings::ProjectSettings, Project}; use serde_json::json; + use settings::SettingsStore; use workspace::{open_paths, AppState, LocalPaths}; use super::*; @@ -713,6 +714,15 @@ mod tests { #[gpui::test] async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.session.restore_unsaved_buffers = false + }); + }); + }); + app_state .fs .as_fake() diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 82fc0a926c..df79986ee5 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -16,7 +16,7 @@ use gpui::{ EventEmitter, FocusHandle, FocusableView, FontStyle, Global, Hsla, InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Point, Render, SharedString, Styled, Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel, - WeakView, WhiteSpace, WindowContext, + WhiteSpace, WindowContext, }; use menu::Confirm; use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath}; @@ -37,7 +37,7 @@ use util::paths::PathMatcher; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, searchable::{Direction, SearchableItem, SearchableItemHandle}, - DeploySearch, ItemNavHistory, NewSearch, Pane, ToolbarItemEvent, ToolbarItemLocation, + DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, }; @@ -506,20 +506,6 @@ impl Item for ProjectSearchView { fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { self.results_editor.breadcrumbs(theme, cx) } - - fn serialized_item_kind() -> Option<&'static str> { - None - } - - fn deserialize( - _project: Model, - _workspace: WeakView, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, - _cx: &mut ViewContext, - ) -> Task>> { - unimplemented!() - } } impl ProjectSearchView { diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index 0da9ed4729..b8c31e05b0 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -1,6 +1,7 @@ +use anyhow::Result; use std::path::PathBuf; -use db::{define_connection, query, sqlez_macros::sql}; +use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; define_connection! { @@ -68,4 +69,30 @@ impl TerminalDb { WHERE item_id = ? AND workspace_id = ? } } + + pub async fn delete_unloaded_items( + &self, + workspace: WorkspaceId, + alive_items: Vec, + ) -> Result<()> { + let placeholders = alive_items + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + + let query = format!( + "DELETE FROM terminals WHERE workspace_id = ? AND item_id NOT IN ({placeholders})" + ); + + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&workspace, 1)?; + for id in alive_items { + next_index = statement.bind(&id, next_index)?; + } + statement.exec() + }) + .await + } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 064adfaaf4..7d50bdb12e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -26,10 +26,10 @@ use ui::{ use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - item::Item, + item::SerializableItem, pane, ui::IconName, - DraggedTab, NewTerminal, Pane, ToggleZoom, Workspace, + DraggedTab, ItemId, NewTerminal, Pane, ToggleZoom, Workspace, }; use anyhow::Result; @@ -278,6 +278,7 @@ impl TerminalPanel { let pane = pane.downgrade(); let items = futures::future::join_all(items).await; + let mut alive_item_ids = Vec::new(); pane.update(&mut cx, |pane, cx| { let active_item_id = serialized_panel .as_ref() @@ -287,6 +288,7 @@ impl TerminalPanel { if let Some(item) = item.log_err() { let item_id = item.entity_id().as_u64(); pane.add_item(Box::new(item), false, false, None, cx); + alive_item_ids.push(item_id as ItemId); if Some(item_id) == active_item_id { active_ix = Some(pane.items_len() - 1); } @@ -298,6 +300,18 @@ impl TerminalPanel { } })?; + // Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace. + if let Some(workspace) = workspace.upgrade() { + let cleanup_task = workspace.update(&mut cx, |workspace, cx| { + workspace + .database_id() + .map(|workspace_id| TerminalView::cleanup(workspace_id, alive_item_ids, cx)) + })?; + if let Some(task) = cleanup_task { + task.await.log_err(); + } + } + Ok(panel) } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index a82082e57f..326bbc243b 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -29,9 +29,9 @@ use terminal_element::{is_blank, TerminalElement}; use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip}; use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ - item::{BreadcrumbText, Item, ItemEvent, TabContentParams}, + item::{BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams}, notifications::NotifyResultExt, - register_deserializable_item, + register_serializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, CloseActiveItem, NewCenterTerminal, OpenVisible, Pane, ToolbarItemLocation, Workspace, WorkspaceId, @@ -73,7 +73,7 @@ pub fn init(cx: &mut AppContext) { terminal_panel::init(cx); terminal::init(cx); - register_deserializable_item::(cx); + register_serializable_item::(cx); cx.observe_new_views(|workspace: &mut Workspace, _| { workspace.register_action(TerminalView::deploy); @@ -612,22 +612,6 @@ fn subscribe_for_terminal_events( Event::TitleChanged => { cx.emit(ItemEvent::UpdateTab); - let terminal = this.terminal().read(cx); - if terminal.task().is_none() { - if let Some(cwd) = terminal.get_cwd() { - let item_id = cx.entity_id(); - if let Some(workspace_id) = this.workspace_id { - cx.background_executor() - .spawn(async move { - TERMINAL_DB - .save_working_directory(item_id.as_u64(), workspace_id, cwd) - .await - .log_err(); - }) - .detach(); - } - } - } } Event::NewNavigationTarget(maybe_navigation_target) => { @@ -1072,8 +1056,60 @@ impl Item for TerminalView { }]) } - fn serialized_item_kind() -> Option<&'static str> { - Some("Terminal") + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + if self.terminal().read(cx).task().is_none() { + if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) { + cx.background_executor() + .spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64())) + .detach(); + } + self.workspace_id = workspace.database_id(); + } + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(*event) + } +} + +impl SerializableItem for TerminalView { + fn serialized_item_kind() -> &'static str { + "Terminal" + } + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + cx: &mut WindowContext, + ) -> Task> { + cx.spawn(|_| TERMINAL_DB.delete_unloaded_items(workspace_id, alive_items)) + } + + fn serialize( + &mut self, + _workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + cx: &mut ViewContext, + ) -> Option>> { + let terminal = self.terminal().read(cx); + if terminal.task().is_some() { + return None; + } + + if let Some((cwd, workspace_id)) = terminal.get_cwd().zip(self.workspace_id) { + Some(cx.background_executor().spawn(async move { + TERMINAL_DB + .save_working_directory(item_id, workspace_id, cwd) + .await + })) + } else { + None + } + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + matches!(event, ItemEvent::UpdateTab) } fn deserialize( @@ -1116,21 +1152,6 @@ impl Item for TerminalView { }) }) } - - fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { - if self.terminal().read(cx).task().is_none() { - if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) { - cx.background_executor() - .spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64())) - .detach(); - } - self.workspace_id = workspace.database_id(); - } - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { - f(*event) - } } impl SearchableItem for TerminalView { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index fc63249934..74d8892e86 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -3,8 +3,8 @@ use crate::{ persistence::model::ItemId, searchable::SearchableItemHandle, workspace_settings::{AutosaveSetting, WorkspaceSettings}, - DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory, ToolbarItemLocation, - ViewId, Workspace, WorkspaceId, + DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory, SerializableItemRegistry, + ToolbarItemLocation, ViewId, Workspace, WorkspaceId, }; use anyhow::Result; use client::{ @@ -32,6 +32,7 @@ use std::{ }; use theme::Theme; use ui::Element as _; +use util::ResultExt; pub const LEADER_UPDATE_THROTTLE: Duration = Duration::from_millis(200); @@ -245,9 +246,23 @@ pub trait Item: FocusableView + EventEmitter { fn added_to_workspace(&mut self, _workspace: &mut Workspace, _cx: &mut ViewContext) {} - fn serialized_item_kind() -> Option<&'static str> { + fn show_toolbar(&self) -> bool { + true + } + + fn pixel_position_of_cursor(&self, _: &AppContext) -> Option> { None } +} + +pub trait SerializableItem: Item { + fn serialized_item_kind() -> &'static str; + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + cx: &mut WindowContext, + ) -> Task>; fn deserialize( _project: Model, @@ -255,16 +270,53 @@ pub trait Item: FocusableView + EventEmitter { _workspace_id: WorkspaceId, _item_id: ItemId, _cx: &mut ViewContext, - ) -> Task>> { - unimplemented!( - "deserialize() must be implemented if serialized_item_kind() returns Some(_)" - ) + ) -> Task>>; + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: ItemId, + closing: bool, + cx: &mut ViewContext, + ) -> Option>>; + + fn should_serialize(&self, event: &Self::Event) -> bool; +} + +pub trait SerializableItemHandle: ItemHandle { + fn serialized_item_kind(&self) -> &'static str; + fn serialize( + &self, + workspace: &mut Workspace, + closing: bool, + cx: &mut WindowContext, + ) -> Option>>; + fn should_serialize(&self, event: &dyn Any, cx: &AppContext) -> bool; +} + +impl SerializableItemHandle for View +where + T: SerializableItem, +{ + fn serialized_item_kind(&self) -> &'static str { + T::serialized_item_kind() } - fn show_toolbar(&self) -> bool { - true + + fn serialize( + &self, + workspace: &mut Workspace, + closing: bool, + cx: &mut WindowContext, + ) -> Option>> { + self.update(cx, |this, cx| { + this.serialize(workspace, cx.entity_id().as_u64(), closing, cx) + }) } - fn pixel_position_of_cursor(&self, _: &AppContext) -> Option> { - None + + fn should_serialize(&self, event: &dyn Any, cx: &AppContext) -> bool { + event + .downcast_ref::() + .map_or(false, |event| self.read(cx).should_serialize(event)) } } @@ -324,6 +376,10 @@ pub trait ItemHandle: 'static + Send { fn reload(&self, project: Model, cx: &mut WindowContext) -> Task>; fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option; fn to_followable_item_handle(&self, cx: &AppContext) -> Option>; + fn to_serializable_item_handle( + &self, + cx: &AppContext, + ) -> Option>; fn on_release( &self, cx: &mut AppContext, @@ -332,7 +388,6 @@ pub trait ItemHandle: 'static + Send { fn to_searchable_item_handle(&self, cx: &AppContext) -> Option>; fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation; fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option>; - fn serialized_item_kind(&self) -> Option<&'static str>; fn show_toolbar(&self, cx: &AppContext) -> bool; fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option>; fn downgrade_item(&self) -> Box; @@ -477,6 +532,12 @@ impl ItemHandle for View { this.added_to_workspace(workspace, cx); }); + if let Some(serializable_item) = self.to_serializable_item_handle(cx) { + workspace + .enqueue_item_serialization(serializable_item) + .log_err(); + } + if workspace .panes_by_item .insert(self.item_id(), pane.downgrade()) @@ -554,6 +615,12 @@ impl ItemHandle for View { } } + if let Some(item) = item.to_serializable_item_handle(cx) { + if item.should_serialize(event, cx) { + workspace.enqueue_item_serialization(item).ok(); + } + } + T::to_item_events(event, |event| match event { ItemEvent::CloseItem => { pane.update(cx, |pane, cx| { @@ -694,10 +761,6 @@ impl ItemHandle for View { self.read(cx).breadcrumbs(theme, cx) } - fn serialized_item_kind(&self) -> Option<&'static str> { - T::serialized_item_kind() - } - fn show_toolbar(&self, cx: &AppContext) -> bool { self.read(cx).show_toolbar() } @@ -709,6 +772,13 @@ impl ItemHandle for View { fn downgrade_item(&self) -> Box { Box::new(self.downgrade()) } + + fn to_serializable_item_handle( + &self, + cx: &AppContext, + ) -> Option> { + SerializableItemRegistry::view_to_serializable_item_handle(self.to_any(), cx) + } } impl From> for AnyView { @@ -880,7 +950,7 @@ impl WeakFollowableItemHandle for WeakView { #[cfg(any(test, feature = "test-support"))] pub mod test { - use super::{Item, ItemEvent, TabContentParams}; + use super::{Item, ItemEvent, SerializableItem, TabContentParams}; use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId}; use gpui::{ AnyElement, AppContext, Context as _, EntityId, EventEmitter, FocusableView, @@ -909,6 +979,7 @@ pub mod test { pub nav_history: Option, pub tab_descriptions: Option>, pub tab_detail: Cell>, + serialize: Option Option>>>>, focus_handle: gpui::FocusHandle, } @@ -972,6 +1043,7 @@ pub mod test { tab_detail: Default::default(), workspace_id: Default::default(), focus_handle: cx.focus_handle(), + serialize: None, } } @@ -1007,6 +1079,14 @@ pub mod test { self } + pub fn with_serialize( + mut self, + serialize: impl Fn() -> Option>> + 'static, + ) -> Self { + self.serialize = Some(Box::new(serialize)); + self + } + pub fn set_state(&mut self, state: String, cx: &mut ViewContext) { self.push_to_nav_history(cx); self.state = state; @@ -1115,6 +1195,7 @@ pub mod test { tab_detail: Default::default(), workspace_id: self.workspace_id, focus_handle: cx.focus_handle(), + serialize: None, })) } @@ -1165,9 +1246,11 @@ pub mod test { self.is_dirty = false; Task::ready(Ok(())) } + } - fn serialized_item_kind() -> Option<&'static str> { - Some("TestItem") + impl SerializableItem for TestItem { + fn serialized_item_kind() -> &'static str { + "TestItem" } fn deserialize( @@ -1178,7 +1261,35 @@ pub mod test { cx: &mut ViewContext, ) -> Task>> { let view = cx.new_view(|cx| Self::new_deserialized(workspace_id, cx)); - Task::Ready(Some(anyhow::Ok(view))) + Task::ready(Ok(view)) + } + + fn cleanup( + _workspace_id: WorkspaceId, + _alive_items: Vec, + _cx: &mut ui::WindowContext, + ) -> Task> { + Task::ready(Ok(())) + } + + fn serialize( + &mut self, + _workspace: &mut Workspace, + _item_id: ItemId, + _closing: bool, + _cx: &mut ViewContext, + ) -> Option>> { + if let Some(serialize) = self.serialize.take() { + let result = serialize(); + self.serialize = Some(serialize); + result + } else { + None + } + } + + fn should_serialize(&self, _event: &Self::Event) -> bool { + false } } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index c4802764b7..4c9faaa1ec 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1478,6 +1478,7 @@ impl Pane { } } } + Ok(true) } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 711585b596..2ee5bc2240 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -1,5 +1,7 @@ use super::{SerializedAxis, SerializedWindowBounds}; -use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId}; +use crate::{ + item::ItemHandle, Member, Pane, PaneAxis, SerializableItemRegistry, Workspace, WorkspaceId, +}; use anyhow::{Context, Result}; use async_recursion::async_recursion; use client::DevServerProjectId; @@ -7,7 +9,7 @@ use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; -use gpui::{AsyncWindowContext, Model, Task, View, WeakView}; +use gpui::{AsyncWindowContext, Model, View, WeakView}; use project::Project; use serde::{Deserialize, Serialize}; use std::{ @@ -339,14 +341,14 @@ impl SerializedPane { for (index, item) in self.children.iter().enumerate() { let project = project.clone(); item_tasks.push(pane.update(cx, |_, cx| { - if let Some(deserializer) = cx.global::().get(&item.kind) { - deserializer(project, workspace.clone(), workspace_id, item.item_id, cx) - } else { - Task::ready(Err(anyhow::anyhow!( - "Deserializer does not exist for item kind: {}", - item.kind - ))) - } + SerializableItemRegistry::deserialize( + &item.kind, + project, + workspace.clone(), + workspace_id, + item.item_id, + cx, + ) })?); if item.active { active_item_index = Some(index); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1581bb593a..0aec91a622 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -22,7 +22,10 @@ use collections::{hash_map, HashMap, HashSet}; use derive_more::{Deref, DerefMut}; use dock::{Dock, DockPosition, Panel, PanelButtons, PanelHandle}; use futures::{ - channel::{mpsc, oneshot}, + channel::{ + mpsc::{self, UnboundedReceiver, UnboundedSender}, + oneshot, + }, future::try_join_all, Future, FutureExt, StreamExt, }; @@ -37,7 +40,7 @@ use gpui::{ }; use item::{ FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings, - ProjectItem, + ProjectItem, SerializableItem, SerializableItemHandle, }; use itertools::Itertools; use language::{LanguageRegistry, Rope}; @@ -85,7 +88,7 @@ use ui::{ IntoElement, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _, WindowContext, }; -use util::{maybe, ResultExt}; +use util::{maybe, ResultExt, TryFutureExt}; use uuid::Uuid; pub use workspace_settings::{ AutosaveSetting, RestoreOnStartupBehaviour, TabBarSettings, WorkspaceSettings, @@ -280,6 +283,12 @@ impl Column for WorkspaceId { .with_context(|| format!("Failed to read WorkspaceId at index {start_index}")) } } +impl Into for WorkspaceId { + fn into(self) -> i64 { + self.0 + } +} + pub fn init_settings(cx: &mut AppContext) { WorkspaceSettings::register(cx); ItemSettings::register(cx); @@ -427,34 +436,96 @@ impl FollowableViewRegistry { } } -#[derive(Default, Deref, DerefMut)] -struct ItemDeserializers( - HashMap< - Arc, - fn( - Model, - WeakView, - WorkspaceId, - ItemId, - &mut ViewContext, - ) -> Task>>, - >, -); +#[derive(Copy, Clone)] +struct SerializableItemDescriptor { + deserialize: fn( + Model, + WeakView, + WorkspaceId, + ItemId, + &mut ViewContext, + ) -> Task>>, + cleanup: fn(WorkspaceId, Vec, &mut WindowContext) -> Task>, + view_to_serializable_item: fn(AnyView) -> Box, +} -impl Global for ItemDeserializers {} +#[derive(Default)] +struct SerializableItemRegistry { + descriptors_by_kind: HashMap, SerializableItemDescriptor>, + descriptors_by_type: HashMap, +} -pub fn register_deserializable_item(cx: &mut AppContext) { - if let Some(serialized_item_kind) = I::serialized_item_kind() { - let deserializers = cx.default_global::(); - deserializers.insert( - Arc::from(serialized_item_kind), - |project, workspace, workspace_id, item_id, cx| { - let task = I::deserialize(project, workspace, workspace_id, item_id, cx); - cx.foreground_executor() - .spawn(async { Ok(Box::new(task.await?) as Box<_>) }) - }, - ); +impl Global for SerializableItemRegistry {} + +impl SerializableItemRegistry { + fn deserialize( + item_kind: &str, + project: Model, + workspace: WeakView, + workspace_id: WorkspaceId, + item_item: ItemId, + cx: &mut ViewContext, + ) -> Task>> { + let Some(descriptor) = Self::descriptor(item_kind, cx) else { + return Task::ready(Err(anyhow!( + "cannot deserialize {}, descriptor not found", + item_kind + ))); + }; + + (descriptor.deserialize)(project, workspace, workspace_id, item_item, cx) } + + fn cleanup( + item_kind: &str, + workspace_id: WorkspaceId, + loaded_items: Vec, + cx: &mut WindowContext, + ) -> Task> { + let Some(descriptor) = Self::descriptor(item_kind, cx) else { + return Task::ready(Err(anyhow!( + "cannot cleanup {}, descriptor not found", + item_kind + ))); + }; + + (descriptor.cleanup)(workspace_id, loaded_items, cx) + } + + fn view_to_serializable_item_handle( + view: AnyView, + cx: &AppContext, + ) -> Option> { + let this = cx.try_global::()?; + let descriptor = this.descriptors_by_type.get(&view.entity_type())?; + Some((descriptor.view_to_serializable_item)(view)) + } + + fn descriptor(item_kind: &str, cx: &AppContext) -> Option { + let this = cx.try_global::()?; + this.descriptors_by_kind.get(item_kind).copied() + } +} + +pub fn register_serializable_item(cx: &mut AppContext) { + let serialized_item_kind = I::serialized_item_kind(); + + let registry = cx.default_global::(); + let descriptor = SerializableItemDescriptor { + deserialize: |project, workspace, workspace_id, item_id, cx| { + let task = I::deserialize(project, workspace, workspace_id, item_id, cx); + cx.foreground_executor() + .spawn(async { Ok(Box::new(task.await?) as Box<_>) }) + }, + cleanup: |workspace_id, loaded_items, cx| I::cleanup(workspace_id, loaded_items, cx), + view_to_serializable_item: |view| Box::new(view.downcast::().unwrap()), + }; + registry + .descriptors_by_kind + .insert(Arc::from(serialized_item_kind), descriptor); + registry + .descriptors_by_type + .insert(TypeId::of::(), descriptor); } pub struct AppState { @@ -657,6 +728,8 @@ pub struct Workspace { on_prompt_for_open_path: Option, render_disconnected_overlay: Option) -> AnyElement>>, + serializable_items_tx: UnboundedSender>, + _items_serializer: Task>, } impl EventEmitter for Workspace {} @@ -842,6 +915,12 @@ impl Workspace { active_call = Some((call, subscriptions)); } + let (serializable_items_tx, serializable_items_rx) = + mpsc::unbounded::>(); + let _items_serializer = cx.spawn(|this, mut cx| async move { + Self::serialize_items(&this, serializable_items_rx, &mut cx).await + }); + let subscriptions = vec![ cx.observe_window_activation(Self::on_window_activation_changed), cx.observe_window_bounds(move |this, cx| { @@ -942,6 +1021,8 @@ impl Workspace { on_prompt_for_new_path: None, on_prompt_for_open_path: None, render_disconnected_overlay: None, + serializable_items_tx, + _items_serializer, } } @@ -1649,27 +1730,52 @@ impl Workspace { let project = self.project.clone(); cx.spawn(|workspace, mut cx| async move { - // Override save mode and display "Save all files" prompt - if save_intent == SaveIntent::Close && dirty_items.len() > 1 { - let answer = workspace.update(&mut cx, |_, cx| { - let (prompt, detail) = Pane::file_names_for_prompt( - &mut dirty_items.iter().map(|(_, handle)| handle), - dirty_items.len(), - cx, - ); - cx.prompt( - PromptLevel::Warning, - &prompt, - Some(&detail), - &["Save all", "Discard all", "Cancel"], - ) - })?; - match answer.await.log_err() { - Some(0) => save_intent = SaveIntent::SaveAll, - Some(1) => save_intent = SaveIntent::Skip, - _ => {} + let dirty_items = if save_intent == SaveIntent::Close && dirty_items.len() > 0 { + let (serialize_tasks, remaining_dirty_items) = + workspace.update(&mut cx, |workspace, cx| { + let mut remaining_dirty_items = Vec::new(); + let mut serialize_tasks = Vec::new(); + for (pane, item) in dirty_items { + if let Some(task) = item + .to_serializable_item_handle(cx) + .and_then(|handle| handle.serialize(workspace, true, cx)) + { + serialize_tasks.push(task); + } else { + remaining_dirty_items.push((pane, item)); + } + } + (serialize_tasks, remaining_dirty_items) + })?; + + futures::future::try_join_all(serialize_tasks).await?; + + if remaining_dirty_items.len() > 1 { + let answer = workspace.update(&mut cx, |_, cx| { + let (prompt, detail) = Pane::file_names_for_prompt( + &mut remaining_dirty_items.iter().map(|(_, handle)| handle), + remaining_dirty_items.len(), + cx, + ); + cx.prompt( + PromptLevel::Warning, + &prompt, + Some(&detail), + &["Save all", "Discard all", "Cancel"], + ) + })?; + match answer.await.log_err() { + Some(0) => save_intent = SaveIntent::SaveAll, + Some(1) => save_intent = SaveIntent::Skip, + _ => {} + } } - } + + remaining_dirty_items + } else { + dirty_items + }; + for (pane, item) in dirty_items { let (singleton, project_entry_ids) = cx.update(|cx| (item.is_singleton(cx), item.project_entry_ids(cx)))?; @@ -3743,12 +3849,14 @@ impl Workspace { let active_item_id = pane.active_item().map(|item| item.item_id()); ( pane.items() - .filter_map(|item_handle| { + .filter_map(|handle| { + let handle = handle.to_serializable_item_handle(cx)?; + Some(SerializedItem { - kind: Arc::from(item_handle.serialized_item_kind()?), - item_id: item_handle.item_id().as_u64(), - active: Some(item_handle.item_id()) == active_item_id, - preview: pane.is_active_preview_item(item_handle.item_id()), + kind: Arc::from(handle.serialized_item_kind()), + item_id: handle.item_id().as_u64(), + active: Some(handle.item_id()) == active_item_id, + preview: pane.is_active_preview_item(handle.item_id()), }) }) .collect::>(), @@ -3885,6 +3993,52 @@ impl Workspace { Task::ready(()) } + async fn serialize_items( + this: &WeakView, + items_rx: UnboundedReceiver>, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + const CHUNK_SIZE: usize = 200; + const THROTTLE_TIME: Duration = Duration::from_millis(200); + + let mut serializable_items = items_rx.ready_chunks(CHUNK_SIZE); + + while let Some(items_received) = serializable_items.next().await { + let unique_items = + items_received + .into_iter() + .fold(HashMap::default(), |mut acc, item| { + acc.entry(item.item_id()).or_insert(item); + acc + }); + + // We use into_iter() here so that the references to the items are moved into + // the tasks and not kept alive while we're sleeping. + for (_, item) in unique_items.into_iter() { + if let Ok(Some(task)) = + this.update(cx, |workspace, cx| item.serialize(workspace, false, cx)) + { + cx.background_executor() + .spawn(async move { task.await.log_err() }) + .detach(); + } + } + + cx.background_executor().timer(THROTTLE_TIME).await; + } + + Ok(()) + } + + pub(crate) fn enqueue_item_serialization( + &mut self, + item: Box, + ) -> Result<()> { + self.serializable_items_tx + .unbounded_send(item) + .map_err(|err| anyhow!("failed to send serializable item over channel: {}", err)) + } + pub(crate) fn load_workspace( serialized_workspace: SerializedWorkspace, paths_to_open: Vec>, @@ -3911,16 +4065,23 @@ impl Workspace { center_group = Some((group, active_pane)) } - let mut items_by_project_path = cx.update(|cx| { - center_items - .unwrap_or_default() - .into_iter() - .filter_map(|item| { - let item = item?; - let project_path = item.project_path(cx)?; - Some((project_path, item)) - }) - .collect::>() + let mut items_by_project_path = HashMap::default(); + let mut item_ids_by_kind = HashMap::default(); + let mut all_deserialized_items = Vec::default(); + cx.update(|cx| { + for item in center_items.unwrap_or_default().into_iter().flatten() { + if let Some(serializable_item_handle) = item.to_serializable_item_handle(cx) { + item_ids_by_kind + .entry(serializable_item_handle.serialized_item_kind()) + .or_insert(Vec::new()) + .push(item.item_id().as_u64() as ItemId); + } + + if let Some(project_path) = item.project_path(cx) { + items_by_project_path.insert(project_path, item.clone()); + } + all_deserialized_items.push(item); + } })?; let opened_items = paths_to_open @@ -3965,10 +4126,35 @@ impl Workspace { cx.notify(); })?; - // Serialize ourself to make sure our timestamps and any pane / item changes are replicated + // Clean up all the items that have _not_ been loaded. Our ItemIds aren't stable. That means + // after loading the items, we might have different items and in order to avoid + // the database filling up, we delete items that haven't been loaded now. + // + // The items that have been loaded, have been saved after they've been added to the workspace. + let clean_up_tasks = workspace.update(&mut cx, |_, cx| { + item_ids_by_kind + .into_iter() + .map(|(item_kind, loaded_items)| { + SerializableItemRegistry::cleanup( + item_kind, + serialized_workspace.id, + loaded_items, + cx, + ) + .log_err() + }) + .collect::>() + })?; + + futures::future::join_all(clean_up_tasks).await; + workspace .update(&mut cx, |workspace, cx| { + // Serialize ourself to make sure our timestamps and any pane / item changes are replicated workspace.serialize_workspace_internal(cx).detach(); + + // Ensure that we mark the window as edited if we did load dirty items + workspace.update_window_edited(cx); }) .ok(); @@ -5557,6 +5743,41 @@ mod tests { assert!(!task.await.unwrap()); } + #[gpui::test] + async fn test_close_window_with_serializable_items(cx: &mut TestAppContext) { + init_test(cx); + + // Register TestItem as a serializable item + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "one": "" })).await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + // When there are dirty untitled items, but they can serialize, then there is no prompt. + let item1 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + let item2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + workspace.update(cx, |w, cx| { + w.add_item_to_active_pane(Box::new(item1.clone()), None, cx); + w.add_item_to_active_pane(Box::new(item2.clone()), None, cx); + }); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); + assert!(task.await.unwrap()); + } + #[gpui::test] async fn test_close_pane_items(cx: &mut TestAppContext) { init_test(cx); @@ -6352,7 +6573,6 @@ mod tests { use super::*; - const TEST_PNG_KIND: &str = "TestPngItemView"; // View struct TestPngItemView { focus_handle: FocusHandle, @@ -6384,10 +6604,6 @@ mod tests { impl Item for TestPngItemView { type Event = (); - - fn serialized_item_kind() -> Option<&'static str> { - Some(TEST_PNG_KIND) - } } impl EventEmitter<()> for TestPngItemView {} impl FocusableView for TestPngItemView { @@ -6419,7 +6635,6 @@ mod tests { } } - const TEST_IPYNB_KIND: &str = "TestIpynbItemView"; // View struct TestIpynbItemView { focus_handle: FocusHandle, @@ -6451,10 +6666,6 @@ mod tests { impl Item for TestIpynbItemView { type Event = (); - - fn serialized_item_kind() -> Option<&'static str> { - Some(TEST_IPYNB_KIND) - } } impl EventEmitter<()> for TestIpynbItemView {} impl FocusableView for TestIpynbItemView { @@ -6490,14 +6701,10 @@ mod tests { focus_handle: FocusHandle, } - const TEST_ALTERNATE_PNG_KIND: &str = "TestAlternatePngItemView"; impl Item for TestAlternatePngItemView { type Event = (); - - fn serialized_item_kind() -> Option<&'static str> { - Some(TEST_ALTERNATE_PNG_KIND) - } } + impl EventEmitter<()> for TestAlternatePngItemView {} impl FocusableView for TestAlternatePngItemView { fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { @@ -6564,7 +6771,10 @@ mod tests { .unwrap(); // Now we can check if the handle we got back errored or not - assert_eq!(handle.serialized_item_kind().unwrap(), TEST_PNG_KIND); + assert_eq!( + handle.to_any().entity_type(), + TypeId::of::() + ); let handle = workspace .update(cx, |workspace, cx| { @@ -6574,7 +6784,10 @@ mod tests { .await .unwrap(); - assert_eq!(handle.serialized_item_kind().unwrap(), TEST_IPYNB_KIND); + assert_eq!( + handle.to_any().entity_type(), + TypeId::of::() + ); let handle = workspace .update(cx, |workspace, cx| { @@ -6622,8 +6835,8 @@ mod tests { // This _must_ be the second item registered assert_eq!( - handle.serialized_item_kind().unwrap(), - TEST_ALTERNATE_PNG_KIND + handle.to_any().entity_type(), + TypeId::of::() ); let handle = workspace diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f3e7a99f2e..2380dcf626 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -964,13 +964,16 @@ mod tests { use editor::{display_map::DisplayRow, scroll::Autoscroll, DisplayPoint, Editor}; use gpui::{ actions, Action, AnyWindowHandle, AppContext, AssetSource, BorrowAppContext, Entity, - SemanticVersion, TestAppContext, VisualTestContext, WindowHandle, + SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, }; use language::{LanguageMatcher, LanguageRegistry}; - use project::{Project, ProjectPath, WorktreeSettings}; + use project::{project_settings::ProjectSettings, Project, ProjectPath, WorktreeSettings}; use serde_json::json; use settings::{handle_settings_file_changes, watch_config_file, SettingsStore}; - use std::path::{Path, PathBuf}; + use std::{ + path::{Path, PathBuf}, + time::Duration, + }; use task::{RevealStrategy, SpawnInTerminal}; use theme::{ThemeRegistry, ThemeSettings}; use workspace::{ @@ -1253,9 +1256,18 @@ mod tests { } #[gpui::test] - async fn test_window_edit_state(cx: &mut TestAppContext) { + async fn test_window_edit_state_restoring_disabled(cx: &mut TestAppContext) { let executor = cx.executor(); let app_state = init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.session.restore_unsaved_buffers = false + }); + }); + }); + app_state .fs .as_fake() @@ -1335,6 +1347,9 @@ mod tests { close.await.unwrap(); assert!(!window_is_edited(window, cx)); + // Advance the clock to ensure that the item has been serialized and dropped from the queue + cx.executor().advance_clock(Duration::from_secs(1)); + // Opening the buffer again doesn't impact the window's edited state. cx.update(|cx| { open_paths( @@ -1346,6 +1361,22 @@ mod tests { }) .await .unwrap(); + executor.run_until_parked(); + + window + .update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "hey"); + }); + }) + .unwrap(); + let editor = window .read_with(cx, |workspace, cx| { workspace @@ -1363,6 +1394,7 @@ mod tests { editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); }) .unwrap(); + executor.run_until_parked(); assert!(window_is_edited(window, cx)); // Ensure closing the window via the mouse gets preempted due to the @@ -1377,6 +1409,102 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 0); } + #[gpui::test] + async fn test_window_edit_state_restoring_enabled(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({"a": "hey"})) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + // When opening the workspace, the window is not in a edited state. + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().is_edited()) + }; + + let editor = window + .read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + + assert!(!window_is_edited(window, cx)); + + // Editing a buffer marks the window as edited. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); + }) + .unwrap(); + + assert!(window_is_edited(window, cx)); + cx.run_until_parked(); + + // Advance the clock to make sure the workspace is serialized + cx.executor().advance_clock(Duration::from_secs(1)); + + // When closing the window, no prompt shows up and the window is closed. + // buffer having unsaved changes. + assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close()); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| cx.windows().len()), 0); + + // When we now reopen the window, the edited state and the edited buffer are back + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + assert!(cx.update(|cx| cx.active_window().is_some())); + + // When opening the workspace, the window is not in a edited state. + let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + assert!(window_is_edited(window, cx)); + + window + .update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "EDIThey"); + assert!(editor.is_dirty(cx)); + }); + + editor + }) + .unwrap(); + } + #[gpui::test] async fn test_new_empty_workspace(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -2256,6 +2384,8 @@ mod tests { assert!(workspace.active_item(cx).is_none()); }) .unwrap(); + + cx.run_until_parked(); editor_1.assert_released(); editor_2.assert_released(); buffer.assert_released();