Detailed changes
@@ -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",
@@ -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<Project>,
- workspace: WeakView<Workspace>,
- _workspace_id: workspace::WorkspaceId,
- _item_id: workspace::ItemId,
- cx: &mut ViewContext<Pane>,
- ) -> Task<Result<View<Self>>> {
- Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx))))
- }
}
const DIAGNOSTIC_HEADER: &'static str = "diagnostic header";
@@ -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<Project>,
- workspace: WeakView<Workspace>,
- _workspace_id: workspace::WorkspaceId,
- _item_id: workspace::ItemId,
- cx: &mut ViewContext<Pane>,
- ) -> Task<Result<View<Self>>> {
- Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx))))
- }
}
fn compare_data_locations(
@@ -272,7 +272,8 @@ pub fn init(cx: &mut AppContext) {
workspace::register_project_item::<Editor>(cx);
workspace::FollowableViewRegistry::register::<Editor>(cx);
- workspace::register_deserializable_item::<Editor>(cx);
+ workspace::register_serializable_item::<Editor>(cx);
+
cx.observe_new_views(
|workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
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<Task<()>>,
git_blame_inline_enabled: bool,
+ serialize_dirty_buffers: bool,
show_selection_menu: Option<bool>,
blame: Option<Model<GitBlame>>,
blame_subscription: Option<Subscription>,
@@ -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);
}
@@ -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<Self>) {
+ fn added_to_workspace(&mut self, workspace: &mut Workspace, _: &mut ViewContext<Self>) {
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<Buffer>,
- 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<ItemId>,
+ cx: &mut WindowContext,
+ ) -> Task<Result<()>> {
+ cx.spawn(|_| DB.delete_unloaded_items(workspace_id, alive_items))
+ }
fn deserialize(
project: Model<Project>,
@@ -928,41 +899,171 @@ impl Item for Editor {
item_id: ItemId,
cx: &mut ViewContext<Pane>,
) -> Task<Result<View<Self>>> {
- 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 (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(),
- };
+ 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));
+ }
+ };
- Ok(project.open_path(project_path, cx))
- });
+ 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())?;
+
+ Some(language_registry.language_for_name(&language_name).await?)
+ } else {
+ None
+ };
+
+ // First create the empty buffer
+ let buffer = project.update(&mut cx, |project, cx| {
+ project.create_local_buffer("", language, cx)
+ })?;
- project_item
- .map(|project_item| {
- cx.spawn(|pane, mut cx| async move {
- let (_, project_item) = project_item.await?;
- let buffer = project_item
- .downcast::<Buffer>()
- .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);
-
- 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::<Buffer>().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<Self>,
+ ) -> Option<Task<Result<()>>> {
+ 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
+ )
}
}
@@ -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<PathBuf>,
// scroll_top_row: usize,
// scroll_vertical_offset: f32,
// scroll_horizontal_offset: f32,
+ // content: Option<String>,
+ // language: Option<String>,
// )
pub static ref DB: EditorDb<WorkspaceDb> =
&[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<Option<PathBuf>> {
- SELECT path FROM editors
+ pub fn get_path_and_contents(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(Option<PathBuf>, Option<String>, Option<String>)>> {
+ 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<String>, language: Option<String>) -> 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<Option<(u32, f32, f32)>> {
@@ -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<ItemId>,
+ ) -> Result<()> {
+ let placeholders = alive_items
+ .iter()
+ .map(|_| "?")
+ .collect::<Vec<&str>>()
+ .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());
+ }
}
@@ -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
@@ -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,28 +89,24 @@ impl Item for ImageView {
.into_any_element()
}
- fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
- 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 clone_on_split(
+ &self,
+ _workspace_id: Option<WorkspaceId>,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<View<Self>>
+ where
+ Self: Sized,
+ {
+ Some(cx.new_view(|cx| Self {
+ path: self.path.clone(),
+ focus_handle: cx.focus_handle(),
+ }))
}
+}
- fn serialized_item_kind() -> Option<&'static str> {
- Some(IMAGE_VIEWER_KIND)
+impl SerializableItem for ImageView {
+ fn serialized_item_kind() -> &'static str {
+ IMAGE_VIEWER_KIND
}
fn deserialize(
@@ -120,7 +115,7 @@ impl Item for ImageView {
workspace_id: WorkspaceId,
item_id: ItemId,
cx: &mut ViewContext<Pane>,
- ) -> Task<anyhow::Result<View<Self>>> {
+ ) -> Task<gpui::Result<View<Self>>> {
cx.spawn(|_pane, mut cx| async move {
let image_path = IMAGE_VIEWER
.get_image_path(item_id, workspace_id)?
@@ -133,19 +128,36 @@ impl Item for ImageView {
})
}
- fn clone_on_split(
- &self,
- _workspace_id: Option<WorkspaceId>,
+ fn cleanup(
+ workspace_id: WorkspaceId,
+ alive_items: Vec<ItemId>,
+ cx: &mut WindowContext,
+ ) -> Task<gpui::Result<()>> {
+ 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<Self>,
- ) -> Option<View<Self>>
- where
- Self: Sized,
- {
- Some(cx.new_view(|cx| Self {
- path: self.path.clone(),
- focus_handle: cx.focus_handle(),
+ ) -> Option<Task<gpui::Result<()>>> {
+ 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 {}
@@ -242,13 +254,14 @@ impl ProjectItem for ImageView {
pub fn init(cx: &mut AppContext) {
workspace::register_project_item::<ImageView>(cx);
- workspace::register_deserializable_item::<ImageView>(cx)
+ workspace::register_serializable_item::<ImageView>(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<ItemId>,
+ ) -> Result<()> {
+ let placeholders = alive_items
+ .iter()
+ .map(|_| "?")
+ .collect::<Vec<&str>>()
+ .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
+ }
}
}
@@ -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<serde_json::Value>,
}
+#[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;
@@ -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"] }
@@ -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::<ProjectSettings>(cx, |settings| {
+ settings.session.restore_unsaved_buffers = false
+ });
+ });
+ });
+
app_state
.fs
.as_fake()
@@ -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<Vec<BreadcrumbText>> {
self.results_editor.breadcrumbs(theme, cx)
}
-
- fn serialized_item_kind() -> Option<&'static str> {
- None
- }
-
- fn deserialize(
- _project: Model<Project>,
- _workspace: WeakView<Workspace>,
- _workspace_id: workspace::WorkspaceId,
- _item_id: workspace::ItemId,
- _cx: &mut ViewContext<Pane>,
- ) -> Task<anyhow::Result<View<Self>>> {
- unimplemented!()
- }
}
impl ProjectSearchView {
@@ -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<ItemId>,
+ ) -> Result<()> {
+ let placeholders = alive_items
+ .iter()
+ .map(|_| "?")
+ .collect::<Vec<&str>>()
+ .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
+ }
}
@@ -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)
}
@@ -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::<TerminalView>(cx);
+ register_serializable_item::<TerminalView>(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<Self>) {
+ 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<workspace::ItemId>,
+ cx: &mut WindowContext,
+ ) -> Task<gpui::Result<()>> {
+ 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<Self>,
+ ) -> Option<Task<gpui::Result<()>>> {
+ 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<Self>) {
- 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 {
@@ -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<Self::Event> {
fn added_to_workspace(&mut self, _workspace: &mut Workspace, _cx: &mut ViewContext<Self>) {}
- fn serialized_item_kind() -> Option<&'static str> {
+ fn show_toolbar(&self) -> bool {
+ true
+ }
+
+ fn pixel_position_of_cursor(&self, _: &AppContext) -> Option<Point<Pixels>> {
None
}
+}
+
+pub trait SerializableItem: Item {
+ fn serialized_item_kind() -> &'static str;
+
+ fn cleanup(
+ workspace_id: WorkspaceId,
+ alive_items: Vec<ItemId>,
+ cx: &mut WindowContext,
+ ) -> Task<Result<()>>;
fn deserialize(
_project: Model<Project>,
@@ -255,16 +270,53 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
_workspace_id: WorkspaceId,
_item_id: ItemId,
_cx: &mut ViewContext<Pane>,
- ) -> Task<Result<View<Self>>> {
- unimplemented!(
- "deserialize() must be implemented if serialized_item_kind() returns Some(_)"
- )
+ ) -> Task<Result<View<Self>>>;
+
+ fn serialize(
+ &mut self,
+ workspace: &mut Workspace,
+ item_id: ItemId,
+ closing: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<Task<Result<()>>>;
+
+ 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<Task<Result<()>>>;
+ fn should_serialize(&self, event: &dyn Any, cx: &AppContext) -> bool;
+}
+
+impl<T> SerializableItemHandle for View<T>
+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<Task<Result<()>>> {
+ self.update(cx, |this, cx| {
+ this.serialize(workspace, cx.entity_id().as_u64(), closing, cx)
+ })
}
- fn pixel_position_of_cursor(&self, _: &AppContext) -> Option<Point<Pixels>> {
- None
+
+ fn should_serialize(&self, event: &dyn Any, cx: &AppContext) -> bool {
+ event
+ .downcast_ref::<T::Event>()
+ .map_or(false, |event| self.read(cx).should_serialize(event))
}
}
@@ -324,6 +376,10 @@ pub trait ItemHandle: 'static + Send {
fn reload(&self, project: Model<Project>, cx: &mut WindowContext) -> Task<Result<()>>;
fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyView>;
fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
+ fn to_serializable_item_handle(
+ &self,
+ cx: &AppContext,
+ ) -> Option<Box<dyn SerializableItemHandle>>;
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<Box<dyn SearchableItemHandle>>;
fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation;
fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>>;
- fn serialized_item_kind(&self) -> Option<&'static str>;
fn show_toolbar(&self, cx: &AppContext) -> bool;
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>>;
fn downgrade_item(&self) -> Box<dyn WeakItemHandle>;
@@ -477,6 +532,12 @@ impl<T: Item> ItemHandle for View<T> {
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<T: Item> ItemHandle for View<T> {
}
}
+ 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<T: Item> ItemHandle for View<T> {
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<T: Item> ItemHandle for View<T> {
fn downgrade_item(&self) -> Box<dyn WeakItemHandle> {
Box::new(self.downgrade())
}
+
+ fn to_serializable_item_handle(
+ &self,
+ cx: &AppContext,
+ ) -> Option<Box<dyn SerializableItemHandle>> {
+ SerializableItemRegistry::view_to_serializable_item_handle(self.to_any(), cx)
+ }
}
impl From<Box<dyn ItemHandle>> for AnyView {
@@ -880,7 +950,7 @@ impl<T: FollowableItem> WeakFollowableItemHandle for WeakView<T> {
#[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<ItemNavHistory>,
pub tab_descriptions: Option<Vec<&'static str>>,
pub tab_detail: Cell<Option<usize>>,
+ serialize: Option<Box<dyn Fn() -> Option<Task<anyhow::Result<()>>>>>,
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<Task<anyhow::Result<()>>> + 'static,
+ ) -> Self {
+ self.serialize = Some(Box::new(serialize));
+ self
+ }
+
pub fn set_state(&mut self, state: String, cx: &mut ViewContext<Self>) {
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<Pane>,
) -> Task<anyhow::Result<View<Self>>> {
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<ItemId>,
+ _cx: &mut ui::WindowContext,
+ ) -> Task<anyhow::Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn serialize(
+ &mut self,
+ _workspace: &mut Workspace,
+ _item_id: ItemId,
+ _closing: bool,
+ _cx: &mut ViewContext<Self>,
+ ) -> Option<Task<anyhow::Result<()>>> {
+ 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
}
}
}
@@ -1478,6 +1478,7 @@ impl Pane {
}
}
}
+
Ok(true)
}
@@ -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::<ItemDeserializers>().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);
@@ -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<i64> 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<str>,
- fn(
- Model<Project>,
- WeakView<Workspace>,
- WorkspaceId,
- ItemId,
- &mut ViewContext<Pane>,
- ) -> Task<Result<Box<dyn ItemHandle>>>,
- >,
-);
+#[derive(Copy, Clone)]
+struct SerializableItemDescriptor {
+ deserialize: fn(
+ Model<Project>,
+ WeakView<Workspace>,
+ WorkspaceId,
+ ItemId,
+ &mut ViewContext<Pane>,
+ ) -> Task<Result<Box<dyn ItemHandle>>>,
+ cleanup: fn(WorkspaceId, Vec<ItemId>, &mut WindowContext) -> Task<Result<()>>,
+ view_to_serializable_item: fn(AnyView) -> Box<dyn SerializableItemHandle>,
+}
-impl Global for ItemDeserializers {}
-
-pub fn register_deserializable_item<I: Item>(cx: &mut AppContext) {
- if let Some(serialized_item_kind) = I::serialized_item_kind() {
- let deserializers = cx.default_global::<ItemDeserializers>();
- 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<_>) })
- },
- );
+#[derive(Default)]
+struct SerializableItemRegistry {
+ descriptors_by_kind: HashMap<Arc<str>, SerializableItemDescriptor>,
+ descriptors_by_type: HashMap<TypeId, SerializableItemDescriptor>,
+}
+
+impl Global for SerializableItemRegistry {}
+
+impl SerializableItemRegistry {
+ fn deserialize(
+ item_kind: &str,
+ project: Model<Project>,
+ workspace: WeakView<Workspace>,
+ workspace_id: WorkspaceId,
+ item_item: ItemId,
+ cx: &mut ViewContext<Pane>,
+ ) -> Task<Result<Box<dyn ItemHandle>>> {
+ 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<ItemId>,
+ cx: &mut WindowContext,
+ ) -> Task<Result<()>> {
+ 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<Box<dyn SerializableItemHandle>> {
+ let this = cx.try_global::<Self>()?;
+ 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<SerializableItemDescriptor> {
+ let this = cx.try_global::<Self>()?;
+ this.descriptors_by_kind.get(item_kind).copied()
+ }
+}
+
+pub fn register_serializable_item<I: SerializableItem>(cx: &mut AppContext) {
+ let serialized_item_kind = I::serialized_item_kind();
+
+ let registry = cx.default_global::<SerializableItemRegistry>();
+ 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::<I>().unwrap()),
+ };
+ registry
+ .descriptors_by_kind
+ .insert(Arc::from(serialized_item_kind), descriptor);
+ registry
+ .descriptors_by_type
+ .insert(TypeId::of::<I>(), descriptor);
}
pub struct AppState {
@@ -657,6 +728,8 @@ pub struct Workspace {
on_prompt_for_open_path: Option<PromptForOpenPath>,
render_disconnected_overlay:
Option<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
+ serializable_items_tx: UnboundedSender<Box<dyn SerializableItemHandle>>,
+ _items_serializer: Task<Result<()>>,
}
impl EventEmitter<Event> for Workspace {}
@@ -842,6 +915,12 @@ impl Workspace {
active_call = Some((call, subscriptions));
}
+ let (serializable_items_tx, serializable_items_rx) =
+ mpsc::unbounded::<Box<dyn SerializableItemHandle>>();
+ 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::<Vec<_>>(),
@@ -3885,6 +3993,52 @@ impl Workspace {
Task::ready(())
}
+ async fn serialize_items(
+ this: &WeakView<Self>,
+ items_rx: UnboundedReceiver<Box<dyn SerializableItemHandle>>,
+ 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<dyn SerializableItemHandle>,
+ ) -> 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<Option<ProjectPath>>,
@@ -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::<HashMap<_, _>>()
+ 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::<Vec<_>>()
+ })?;
+
+ 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::<TestItem>(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::<TestPngItemView>()
+ );
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::<TestIpynbItemView>()
+ );
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::<TestAlternatePngItemView>()
);
let handle = workspace
@@ -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::<ProjectSettings>(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::<Editor>()
+ .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::<Workspace>().unwrap());
+
+ let window_is_edited = |window: WindowHandle<Workspace>, 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::<Editor>()
+ .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::<Workspace>().unwrap());
+ assert!(window_is_edited(window, cx));
+
+ window
+ .update(cx, |workspace, cx| {
+ let editor = workspace
+ .active_item(cx)
+ .unwrap()
+ .downcast::<editor::Editor>()
+ .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();