1pub mod model;
2
3use std::path::Path;
4
5use anyhow::{anyhow, bail, Context, Result};
6use client::DevServerProjectId;
7use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
8use gpui::{point, size, Axis, Bounds, WindowBounds};
9
10use sqlez::{
11 bindable::{Bind, Column, StaticColumnCount},
12 statement::Statement,
13};
14
15use ui::px;
16use util::ResultExt;
17use uuid::Uuid;
18
19use crate::WorkspaceId;
20
21use model::{
22 GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
23 SerializedWorkspace,
24};
25
26use self::model::{
27 DockStructure, LocalPathsOrder, SerializedDevServerProject, SerializedWorkspaceLocation,
28};
29
30#[derive(Copy, Clone, Debug, PartialEq)]
31pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
32impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
33impl sqlez::bindable::Bind for SerializedAxis {
34 fn bind(
35 &self,
36 statement: &sqlez::statement::Statement,
37 start_index: i32,
38 ) -> anyhow::Result<i32> {
39 match self.0 {
40 gpui::Axis::Horizontal => "Horizontal",
41 gpui::Axis::Vertical => "Vertical",
42 }
43 .bind(statement, start_index)
44 }
45}
46
47impl sqlez::bindable::Column for SerializedAxis {
48 fn column(
49 statement: &mut sqlez::statement::Statement,
50 start_index: i32,
51 ) -> anyhow::Result<(Self, i32)> {
52 String::column(statement, start_index).and_then(|(axis_text, next_index)| {
53 Ok((
54 match axis_text.as_str() {
55 "Horizontal" => Self(Axis::Horizontal),
56 "Vertical" => Self(Axis::Vertical),
57 _ => anyhow::bail!("Stored serialized item kind is incorrect"),
58 },
59 next_index,
60 ))
61 })
62 }
63}
64
65#[derive(Copy, Clone, Debug, PartialEq, Default)]
66pub(crate) struct SerializedWindowBounds(pub(crate) WindowBounds);
67
68impl StaticColumnCount for SerializedWindowBounds {
69 fn column_count() -> usize {
70 5
71 }
72}
73
74impl Bind for SerializedWindowBounds {
75 fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
76 match self.0 {
77 WindowBounds::Windowed(bounds) => {
78 let next_index = statement.bind(&"Windowed", start_index)?;
79 statement.bind(
80 &(
81 SerializedPixels(bounds.origin.x),
82 SerializedPixels(bounds.origin.y),
83 SerializedPixels(bounds.size.width),
84 SerializedPixels(bounds.size.height),
85 ),
86 next_index,
87 )
88 }
89 WindowBounds::Maximized(bounds) => {
90 let next_index = statement.bind(&"Maximized", start_index)?;
91 statement.bind(
92 &(
93 SerializedPixels(bounds.origin.x),
94 SerializedPixels(bounds.origin.y),
95 SerializedPixels(bounds.size.width),
96 SerializedPixels(bounds.size.height),
97 ),
98 next_index,
99 )
100 }
101 WindowBounds::Fullscreen(bounds) => {
102 let next_index = statement.bind(&"FullScreen", start_index)?;
103 statement.bind(
104 &(
105 SerializedPixels(bounds.origin.x),
106 SerializedPixels(bounds.origin.y),
107 SerializedPixels(bounds.size.width),
108 SerializedPixels(bounds.size.height),
109 ),
110 next_index,
111 )
112 }
113 }
114 }
115}
116
117impl Column for SerializedWindowBounds {
118 fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
119 let (window_state, next_index) = String::column(statement, start_index)?;
120 let ((x, y, width, height), _): ((i32, i32, i32, i32), _) =
121 Column::column(statement, next_index)?;
122 let bounds = Bounds {
123 origin: point(px(x as f32), px(y as f32)),
124 size: size(px(width as f32), px(height as f32)),
125 };
126
127 let status = match window_state.as_str() {
128 "Windowed" | "Fixed" => SerializedWindowBounds(WindowBounds::Windowed(bounds)),
129 "Maximized" => SerializedWindowBounds(WindowBounds::Maximized(bounds)),
130 "FullScreen" => SerializedWindowBounds(WindowBounds::Fullscreen(bounds)),
131 _ => bail!("Window State did not have a valid string"),
132 };
133
134 Ok((status, next_index + 4))
135 }
136}
137
138#[derive(Clone, Debug, PartialEq)]
139struct SerializedPixels(gpui::Pixels);
140impl sqlez::bindable::StaticColumnCount for SerializedPixels {}
141
142impl sqlez::bindable::Bind for SerializedPixels {
143 fn bind(
144 &self,
145 statement: &sqlez::statement::Statement,
146 start_index: i32,
147 ) -> anyhow::Result<i32> {
148 let this: i32 = self.0 .0 as i32;
149 this.bind(statement, start_index)
150 }
151}
152
153define_connection! {
154 // Current schema shape using pseudo-rust syntax:
155 //
156 // workspaces(
157 // workspace_id: usize, // Primary key for workspaces
158 // local_paths: Bincode<Vec<PathBuf>>,
159 // local_paths_order: Bincode<Vec<usize>>,
160 // dock_visible: bool, // Deprecated
161 // dock_anchor: DockAnchor, // Deprecated
162 // dock_pane: Option<usize>, // Deprecated
163 // left_sidebar_open: boolean,
164 // timestamp: String, // UTC YYYY-MM-DD HH:MM:SS
165 // window_state: String, // WindowBounds Discriminant
166 // window_x: Option<f32>, // WindowBounds::Fixed RectF x
167 // window_y: Option<f32>, // WindowBounds::Fixed RectF y
168 // window_width: Option<f32>, // WindowBounds::Fixed RectF width
169 // window_height: Option<f32>, // WindowBounds::Fixed RectF height
170 // display: Option<Uuid>, // Display id
171 // fullscreen: Option<bool>, // Is the window fullscreen?
172 // centered_layout: Option<bool>, // Is the Centered Layout mode activated?
173 // )
174 //
175 // pane_groups(
176 // group_id: usize, // Primary key for pane_groups
177 // workspace_id: usize, // References workspaces table
178 // parent_group_id: Option<usize>, // None indicates that this is the root node
179 // position: Optiopn<usize>, // None indicates that this is the root node
180 // axis: Option<Axis>, // 'Vertical', 'Horizontal'
181 // flexes: Option<Vec<f32>>, // A JSON array of floats
182 // )
183 //
184 // panes(
185 // pane_id: usize, // Primary key for panes
186 // workspace_id: usize, // References workspaces table
187 // active: bool,
188 // )
189 //
190 // center_panes(
191 // pane_id: usize, // Primary key for center_panes
192 // parent_group_id: Option<usize>, // References pane_groups. If none, this is the root
193 // position: Option<usize>, // None indicates this is the root
194 // )
195 //
196 // CREATE TABLE items(
197 // item_id: usize, // This is the item's view id, so this is not unique
198 // workspace_id: usize, // References workspaces table
199 // pane_id: usize, // References panes table
200 // kind: String, // Indicates which view this connects to. This is the key in the item_deserializers global
201 // position: usize, // Position of the item in the parent pane. This is equivalent to panes' position column
202 // active: bool, // Indicates if this item is the active one in the pane
203 // preview: bool // Indicates if this item is a preview item
204 // )
205 pub static ref DB: WorkspaceDb<()> =
206 &[sql!(
207 CREATE TABLE workspaces(
208 workspace_id INTEGER PRIMARY KEY,
209 workspace_location BLOB UNIQUE,
210 dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
211 dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
212 dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
213 left_sidebar_open INTEGER, // Boolean
214 timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
215 FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
216 ) STRICT;
217
218 CREATE TABLE pane_groups(
219 group_id INTEGER PRIMARY KEY,
220 workspace_id INTEGER NOT NULL,
221 parent_group_id INTEGER, // NULL indicates that this is a root node
222 position INTEGER, // NULL indicates that this is a root node
223 axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
224 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
225 ON DELETE CASCADE
226 ON UPDATE CASCADE,
227 FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
228 ) STRICT;
229
230 CREATE TABLE panes(
231 pane_id INTEGER PRIMARY KEY,
232 workspace_id INTEGER NOT NULL,
233 active INTEGER NOT NULL, // Boolean
234 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
235 ON DELETE CASCADE
236 ON UPDATE CASCADE
237 ) STRICT;
238
239 CREATE TABLE center_panes(
240 pane_id INTEGER PRIMARY KEY,
241 parent_group_id INTEGER, // NULL means that this is a root pane
242 position INTEGER, // NULL means that this is a root pane
243 FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
244 ON DELETE CASCADE,
245 FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
246 ) STRICT;
247
248 CREATE TABLE items(
249 item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
250 workspace_id INTEGER NOT NULL,
251 pane_id INTEGER NOT NULL,
252 kind TEXT NOT NULL,
253 position INTEGER NOT NULL,
254 active INTEGER NOT NULL,
255 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
256 ON DELETE CASCADE
257 ON UPDATE CASCADE,
258 FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
259 ON DELETE CASCADE,
260 PRIMARY KEY(item_id, workspace_id)
261 ) STRICT;
262 ),
263 sql!(
264 ALTER TABLE workspaces ADD COLUMN window_state TEXT;
265 ALTER TABLE workspaces ADD COLUMN window_x REAL;
266 ALTER TABLE workspaces ADD COLUMN window_y REAL;
267 ALTER TABLE workspaces ADD COLUMN window_width REAL;
268 ALTER TABLE workspaces ADD COLUMN window_height REAL;
269 ALTER TABLE workspaces ADD COLUMN display BLOB;
270 ),
271 // Drop foreign key constraint from workspaces.dock_pane to panes table.
272 sql!(
273 CREATE TABLE workspaces_2(
274 workspace_id INTEGER PRIMARY KEY,
275 workspace_location BLOB UNIQUE,
276 dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
277 dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
278 dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
279 left_sidebar_open INTEGER, // Boolean
280 timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
281 window_state TEXT,
282 window_x REAL,
283 window_y REAL,
284 window_width REAL,
285 window_height REAL,
286 display BLOB
287 ) STRICT;
288 INSERT INTO workspaces_2 SELECT * FROM workspaces;
289 DROP TABLE workspaces;
290 ALTER TABLE workspaces_2 RENAME TO workspaces;
291 ),
292 // Add panels related information
293 sql!(
294 ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
295 ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
296 ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
297 ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
298 ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
299 ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
300 ),
301 // Add panel zoom persistence
302 sql!(
303 ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
304 ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
305 ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
306 ),
307 // Add pane group flex data
308 sql!(
309 ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
310 ),
311 // Add fullscreen field to workspace
312 // Deprecated, `WindowBounds` holds the fullscreen state now.
313 // Preserving so users can downgrade Zed.
314 sql!(
315 ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
316 ),
317 // Add preview field to items
318 sql!(
319 ALTER TABLE items ADD COLUMN preview INTEGER; //bool
320 ),
321 // Add centered_layout field to workspace
322 sql!(
323 ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
324 ),
325 sql!(
326 CREATE TABLE remote_projects (
327 remote_project_id INTEGER NOT NULL UNIQUE,
328 path TEXT,
329 dev_server_name TEXT
330 );
331 ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
332 ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
333 ),
334 sql!(
335 DROP TABLE remote_projects;
336 CREATE TABLE dev_server_projects (
337 id INTEGER NOT NULL UNIQUE,
338 path TEXT,
339 dev_server_name TEXT
340 );
341 ALTER TABLE workspaces DROP COLUMN remote_project_id;
342 ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
343 ),
344 sql!(
345 ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
346 ),
347 ];
348}
349
350impl WorkspaceDb {
351 /// Returns a serialized workspace for the given worktree_roots. If the passed array
352 /// is empty, the most recent workspace is returned instead. If no workspace for the
353 /// passed roots is stored, returns none.
354 pub(crate) fn workspace_for_roots<P: AsRef<Path>>(
355 &self,
356 worktree_roots: &[P],
357 ) -> Option<SerializedWorkspace> {
358 let local_paths = LocalPaths::new(worktree_roots);
359
360 // Note that we re-assign the workspace_id here in case it's empty
361 // and we've grabbed the most recent workspace
362 let (
363 workspace_id,
364 local_paths,
365 local_paths_order,
366 dev_server_project_id,
367 window_bounds,
368 display,
369 centered_layout,
370 docks,
371 ): (
372 WorkspaceId,
373 Option<LocalPaths>,
374 Option<LocalPathsOrder>,
375 Option<u64>,
376 Option<SerializedWindowBounds>,
377 Option<Uuid>,
378 Option<bool>,
379 DockStructure,
380 ) = self
381 .select_row_bound(sql! {
382 SELECT
383 workspace_id,
384 local_paths,
385 local_paths_order,
386 dev_server_project_id,
387 window_state,
388 window_x,
389 window_y,
390 window_width,
391 window_height,
392 display,
393 centered_layout,
394 left_dock_visible,
395 left_dock_active_panel,
396 left_dock_zoom,
397 right_dock_visible,
398 right_dock_active_panel,
399 right_dock_zoom,
400 bottom_dock_visible,
401 bottom_dock_active_panel,
402 bottom_dock_zoom
403 FROM workspaces
404 WHERE local_paths = ?
405 })
406 .and_then(|mut prepared_statement| (prepared_statement)(&local_paths))
407 .context("No workspaces found")
408 .warn_on_err()
409 .flatten()?;
410
411 let location = if let Some(dev_server_project_id) = dev_server_project_id {
412 let dev_server_project: SerializedDevServerProject = self
413 .select_row_bound(sql! {
414 SELECT id, path, dev_server_name
415 FROM dev_server_projects
416 WHERE id = ?
417 })
418 .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
419 .context("No remote project found")
420 .warn_on_err()
421 .flatten()?;
422 SerializedWorkspaceLocation::DevServer(dev_server_project)
423 } else if let Some(local_paths) = local_paths {
424 match local_paths_order {
425 Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
426 None => {
427 let order = LocalPathsOrder::default_for_paths(&local_paths);
428 SerializedWorkspaceLocation::Local(local_paths, order)
429 }
430 }
431 } else {
432 return None;
433 };
434
435 Some(SerializedWorkspace {
436 id: workspace_id,
437 location,
438 center_group: self
439 .get_center_pane_group(workspace_id)
440 .context("Getting center group")
441 .log_err()?,
442 window_bounds,
443 centered_layout: centered_layout.unwrap_or(false),
444 display,
445 docks,
446 })
447 }
448
449 pub(crate) fn workspace_for_dev_server_project(
450 &self,
451 dev_server_project_id: DevServerProjectId,
452 ) -> Option<SerializedWorkspace> {
453 // Note that we re-assign the workspace_id here in case it's empty
454 // and we've grabbed the most recent workspace
455 let (
456 workspace_id,
457 local_paths,
458 local_paths_order,
459 dev_server_project_id,
460 window_bounds,
461 display,
462 centered_layout,
463 docks,
464 ): (
465 WorkspaceId,
466 Option<LocalPaths>,
467 Option<LocalPathsOrder>,
468 Option<u64>,
469 Option<SerializedWindowBounds>,
470 Option<Uuid>,
471 Option<bool>,
472 DockStructure,
473 ) = self
474 .select_row_bound(sql! {
475 SELECT
476 workspace_id,
477 local_paths,
478 local_paths_order,
479 dev_server_project_id,
480 window_state,
481 window_x,
482 window_y,
483 window_width,
484 window_height,
485 display,
486 centered_layout,
487 left_dock_visible,
488 left_dock_active_panel,
489 left_dock_zoom,
490 right_dock_visible,
491 right_dock_active_panel,
492 right_dock_zoom,
493 bottom_dock_visible,
494 bottom_dock_active_panel,
495 bottom_dock_zoom
496 FROM workspaces
497 WHERE dev_server_project_id = ?
498 })
499 .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id.0))
500 .context("No workspaces found")
501 .warn_on_err()
502 .flatten()?;
503
504 let location = if let Some(dev_server_project_id) = dev_server_project_id {
505 let dev_server_project: SerializedDevServerProject = self
506 .select_row_bound(sql! {
507 SELECT id, path, dev_server_name
508 FROM dev_server_projects
509 WHERE id = ?
510 })
511 .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
512 .context("No remote project found")
513 .warn_on_err()
514 .flatten()?;
515 SerializedWorkspaceLocation::DevServer(dev_server_project)
516 } else if let Some(local_paths) = local_paths {
517 match local_paths_order {
518 Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
519 None => {
520 let order = LocalPathsOrder::default_for_paths(&local_paths);
521 SerializedWorkspaceLocation::Local(local_paths, order)
522 }
523 }
524 } else {
525 return None;
526 };
527
528 Some(SerializedWorkspace {
529 id: workspace_id,
530 location,
531 center_group: self
532 .get_center_pane_group(workspace_id)
533 .context("Getting center group")
534 .log_err()?,
535 window_bounds,
536 centered_layout: centered_layout.unwrap_or(false),
537 display,
538 docks,
539 })
540 }
541
542 /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
543 /// that used this workspace previously
544 pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
545 self.write(move |conn| {
546 conn.with_savepoint("update_worktrees", || {
547 // Clear out panes and pane_groups
548 conn.exec_bound(sql!(
549 DELETE FROM pane_groups WHERE workspace_id = ?1;
550 DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
551 .context("Clearing old panes")?;
552
553 match workspace.location {
554 SerializedWorkspaceLocation::Local(local_paths, local_paths_order) => {
555 conn.exec_bound(sql!(
556 DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ?
557 ))?((&local_paths, workspace.id))
558 .context("clearing out old locations")?;
559
560 // Upsert
561 conn.exec_bound(sql!(
562 INSERT INTO workspaces(
563 workspace_id,
564 local_paths,
565 local_paths_order,
566 left_dock_visible,
567 left_dock_active_panel,
568 left_dock_zoom,
569 right_dock_visible,
570 right_dock_active_panel,
571 right_dock_zoom,
572 bottom_dock_visible,
573 bottom_dock_active_panel,
574 bottom_dock_zoom,
575 timestamp
576 )
577 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, CURRENT_TIMESTAMP)
578 ON CONFLICT DO
579 UPDATE SET
580 local_paths = ?2,
581 local_paths_order = ?3,
582 left_dock_visible = ?4,
583 left_dock_active_panel = ?5,
584 left_dock_zoom = ?6,
585 right_dock_visible = ?7,
586 right_dock_active_panel = ?8,
587 right_dock_zoom = ?9,
588 bottom_dock_visible = ?10,
589 bottom_dock_active_panel = ?11,
590 bottom_dock_zoom = ?12,
591 timestamp = CURRENT_TIMESTAMP
592 ))?((workspace.id, &local_paths, &local_paths_order, workspace.docks))
593 .context("Updating workspace")?;
594 }
595 SerializedWorkspaceLocation::DevServer(dev_server_project) => {
596 conn.exec_bound(sql!(
597 DELETE FROM workspaces WHERE dev_server_project_id = ? AND workspace_id != ?
598 ))?((dev_server_project.id.0, workspace.id))
599 .context("clearing out old locations")?;
600
601 conn.exec_bound(sql!(
602 INSERT INTO dev_server_projects(
603 id,
604 path,
605 dev_server_name
606 ) VALUES (?1, ?2, ?3)
607 ON CONFLICT DO
608 UPDATE SET
609 path = ?2,
610 dev_server_name = ?3
611 ))?(&dev_server_project)?;
612
613 // Upsert
614 conn.exec_bound(sql!(
615 INSERT INTO workspaces(
616 workspace_id,
617 dev_server_project_id,
618 left_dock_visible,
619 left_dock_active_panel,
620 left_dock_zoom,
621 right_dock_visible,
622 right_dock_active_panel,
623 right_dock_zoom,
624 bottom_dock_visible,
625 bottom_dock_active_panel,
626 bottom_dock_zoom,
627 timestamp
628 )
629 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP)
630 ON CONFLICT DO
631 UPDATE SET
632 dev_server_project_id = ?2,
633 left_dock_visible = ?3,
634 left_dock_active_panel = ?4,
635 left_dock_zoom = ?5,
636 right_dock_visible = ?6,
637 right_dock_active_panel = ?7,
638 right_dock_zoom = ?8,
639 bottom_dock_visible = ?9,
640 bottom_dock_active_panel = ?10,
641 bottom_dock_zoom = ?11,
642 timestamp = CURRENT_TIMESTAMP
643 ))?((
644 workspace.id,
645 dev_server_project.id.0,
646 workspace.docks,
647 ))
648 .context("Updating workspace")?;
649 }
650 }
651
652 // Save center pane group
653 Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
654 .context("save pane group in save workspace")?;
655
656 Ok(())
657 })
658 .log_err();
659 })
660 .await;
661 }
662
663 query! {
664 pub async fn next_id() -> Result<WorkspaceId> {
665 INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
666 }
667 }
668
669 query! {
670 fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>)>> {
671 SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id
672 FROM workspaces
673 WHERE local_paths IS NOT NULL OR dev_server_project_id IS NOT NULL
674 ORDER BY timestamp DESC
675 }
676 }
677
678 query! {
679 fn dev_server_projects() -> Result<Vec<SerializedDevServerProject>> {
680 SELECT id, path, dev_server_name
681 FROM dev_server_projects
682 }
683 }
684
685 pub(crate) fn last_window(
686 &self,
687 ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
688 let mut prepared_query =
689 self.select::<(Option<Uuid>, Option<SerializedWindowBounds>)>(sql!(
690 SELECT
691 display,
692 window_state, window_x, window_y, window_width, window_height
693 FROM workspaces
694 WHERE local_paths
695 IS NOT NULL
696 ORDER BY timestamp DESC
697 LIMIT 1
698 ))?;
699 let result = prepared_query()?;
700 Ok(result.into_iter().next().unwrap_or_else(|| (None, None)))
701 }
702
703 query! {
704 pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
705 DELETE FROM workspaces
706 WHERE workspace_id IS ?
707 }
708 }
709
710 pub async fn delete_workspace_by_dev_server_project_id(
711 &self,
712 id: DevServerProjectId,
713 ) -> Result<()> {
714 self.write(move |conn| {
715 conn.exec_bound(sql!(
716 DELETE FROM dev_server_projects WHERE id = ?
717 ))?(id.0)?;
718 conn.exec_bound(sql!(
719 DELETE FROM workspaces
720 WHERE dev_server_project_id IS ?
721 ))?(id.0)
722 })
723 .await
724 }
725
726 // Returns the recent locations which are still valid on disk and deletes ones which no longer
727 // exist.
728 pub async fn recent_workspaces_on_disk(
729 &self,
730 ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation)>> {
731 let mut result = Vec::new();
732 let mut delete_tasks = Vec::new();
733 let dev_server_projects = self.dev_server_projects()?;
734
735 for (id, location, order, dev_server_project_id) in self.recent_workspaces()? {
736 if let Some(dev_server_project_id) = dev_server_project_id.map(DevServerProjectId) {
737 if let Some(dev_server_project) = dev_server_projects
738 .iter()
739 .find(|rp| rp.id == dev_server_project_id)
740 {
741 result.push((id, dev_server_project.clone().into()));
742 } else {
743 delete_tasks.push(self.delete_workspace_by_id(id));
744 }
745 continue;
746 }
747
748 if location.paths().iter().all(|path| path.exists())
749 && location.paths().iter().any(|path| path.is_dir())
750 {
751 result.push((id, SerializedWorkspaceLocation::Local(location, order)));
752 } else {
753 delete_tasks.push(self.delete_workspace_by_id(id));
754 }
755 }
756
757 futures::future::join_all(delete_tasks).await;
758 Ok(result)
759 }
760
761 pub async fn last_workspace(&self) -> Result<Option<LocalPaths>> {
762 Ok(self
763 .recent_workspaces_on_disk()
764 .await?
765 .into_iter()
766 .filter_map(|(_, location)| match location {
767 SerializedWorkspaceLocation::Local(local_paths, _) => Some(local_paths),
768 SerializedWorkspaceLocation::DevServer(_) => None,
769 })
770 .next())
771 }
772
773 fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
774 Ok(self
775 .get_pane_group(workspace_id, None)?
776 .into_iter()
777 .next()
778 .unwrap_or_else(|| {
779 SerializedPaneGroup::Pane(SerializedPane {
780 active: true,
781 children: vec![],
782 })
783 }))
784 }
785
786 fn get_pane_group(
787 &self,
788 workspace_id: WorkspaceId,
789 group_id: Option<GroupId>,
790 ) -> Result<Vec<SerializedPaneGroup>> {
791 type GroupKey = (Option<GroupId>, WorkspaceId);
792 type GroupOrPane = (
793 Option<GroupId>,
794 Option<SerializedAxis>,
795 Option<PaneId>,
796 Option<bool>,
797 Option<String>,
798 );
799 self.select_bound::<GroupKey, GroupOrPane>(sql!(
800 SELECT group_id, axis, pane_id, active, flexes
801 FROM (SELECT
802 group_id,
803 axis,
804 NULL as pane_id,
805 NULL as active,
806 position,
807 parent_group_id,
808 workspace_id,
809 flexes
810 FROM pane_groups
811 UNION
812 SELECT
813 NULL,
814 NULL,
815 center_panes.pane_id,
816 panes.active as active,
817 position,
818 parent_group_id,
819 panes.workspace_id as workspace_id,
820 NULL
821 FROM center_panes
822 JOIN panes ON center_panes.pane_id = panes.pane_id)
823 WHERE parent_group_id IS ? AND workspace_id = ?
824 ORDER BY position
825 ))?((group_id, workspace_id))?
826 .into_iter()
827 .map(|(group_id, axis, pane_id, active, flexes)| {
828 if let Some((group_id, axis)) = group_id.zip(axis) {
829 let flexes = flexes
830 .map(|flexes: String| serde_json::from_str::<Vec<f32>>(&flexes))
831 .transpose()?;
832
833 Ok(SerializedPaneGroup::Group {
834 axis,
835 children: self.get_pane_group(workspace_id, Some(group_id))?,
836 flexes,
837 })
838 } else if let Some((pane_id, active)) = pane_id.zip(active) {
839 Ok(SerializedPaneGroup::Pane(SerializedPane::new(
840 self.get_items(pane_id)?,
841 active,
842 )))
843 } else {
844 bail!("Pane Group Child was neither a pane group or a pane");
845 }
846 })
847 // Filter out panes and pane groups which don't have any children or items
848 .filter(|pane_group| match pane_group {
849 Ok(SerializedPaneGroup::Group { children, .. }) => !children.is_empty(),
850 Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
851 _ => true,
852 })
853 .collect::<Result<_>>()
854 }
855
856 fn save_pane_group(
857 conn: &Connection,
858 workspace_id: WorkspaceId,
859 pane_group: &SerializedPaneGroup,
860 parent: Option<(GroupId, usize)>,
861 ) -> Result<()> {
862 match pane_group {
863 SerializedPaneGroup::Group {
864 axis,
865 children,
866 flexes,
867 } => {
868 let (parent_id, position) = parent.unzip();
869
870 let flex_string = flexes
871 .as_ref()
872 .map(|flexes| serde_json::json!(flexes).to_string());
873
874 let group_id = conn.select_row_bound::<_, i64>(sql!(
875 INSERT INTO pane_groups(
876 workspace_id,
877 parent_group_id,
878 position,
879 axis,
880 flexes
881 )
882 VALUES (?, ?, ?, ?, ?)
883 RETURNING group_id
884 ))?((
885 workspace_id,
886 parent_id,
887 position,
888 *axis,
889 flex_string,
890 ))?
891 .ok_or_else(|| anyhow!("Couldn't retrieve group_id from inserted pane_group"))?;
892
893 for (position, group) in children.iter().enumerate() {
894 Self::save_pane_group(conn, workspace_id, group, Some((group_id, position)))?
895 }
896
897 Ok(())
898 }
899 SerializedPaneGroup::Pane(pane) => {
900 Self::save_pane(conn, workspace_id, pane, parent)?;
901 Ok(())
902 }
903 }
904 }
905
906 fn save_pane(
907 conn: &Connection,
908 workspace_id: WorkspaceId,
909 pane: &SerializedPane,
910 parent: Option<(GroupId, usize)>,
911 ) -> Result<PaneId> {
912 let pane_id = conn.select_row_bound::<_, i64>(sql!(
913 INSERT INTO panes(workspace_id, active)
914 VALUES (?, ?)
915 RETURNING pane_id
916 ))?((workspace_id, pane.active))?
917 .ok_or_else(|| anyhow!("Could not retrieve inserted pane_id"))?;
918
919 let (parent_id, order) = parent.unzip();
920 conn.exec_bound(sql!(
921 INSERT INTO center_panes(pane_id, parent_group_id, position)
922 VALUES (?, ?, ?)
923 ))?((pane_id, parent_id, order))?;
924
925 Self::save_items(conn, workspace_id, pane_id, &pane.children).context("Saving items")?;
926
927 Ok(pane_id)
928 }
929
930 fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
931 self.select_bound(sql!(
932 SELECT kind, item_id, active, preview FROM items
933 WHERE pane_id = ?
934 ORDER BY position
935 ))?(pane_id)
936 }
937
938 fn save_items(
939 conn: &Connection,
940 workspace_id: WorkspaceId,
941 pane_id: PaneId,
942 items: &[SerializedItem],
943 ) -> Result<()> {
944 let mut insert = conn.exec_bound(sql!(
945 INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active, preview) VALUES (?, ?, ?, ?, ?, ?, ?)
946 )).context("Preparing insertion")?;
947 for (position, item) in items.iter().enumerate() {
948 insert((workspace_id, pane_id, position, item))?;
949 }
950
951 Ok(())
952 }
953
954 query! {
955 pub async fn update_timestamp(workspace_id: WorkspaceId) -> Result<()> {
956 UPDATE workspaces
957 SET timestamp = CURRENT_TIMESTAMP
958 WHERE workspace_id = ?
959 }
960 }
961
962 query! {
963 pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> {
964 UPDATE workspaces
965 SET window_state = ?2,
966 window_x = ?3,
967 window_y = ?4,
968 window_width = ?5,
969 window_height = ?6,
970 display = ?7
971 WHERE workspace_id = ?1
972 }
973 }
974
975 query! {
976 pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> {
977 UPDATE workspaces
978 SET centered_layout = ?2
979 WHERE workspace_id = ?1
980 }
981 }
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987 use db::open_test_db;
988 use gpui;
989
990 #[gpui::test]
991 async fn test_next_id_stability() {
992 env_logger::try_init().ok();
993
994 let db = WorkspaceDb(open_test_db("test_next_id_stability").await);
995
996 db.write(|conn| {
997 conn.migrate(
998 "test_table",
999 &[sql!(
1000 CREATE TABLE test_table(
1001 text TEXT,
1002 workspace_id INTEGER,
1003 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1004 ON DELETE CASCADE
1005 ) STRICT;
1006 )],
1007 )
1008 .unwrap();
1009 })
1010 .await;
1011
1012 let id = db.next_id().await.unwrap();
1013 // Assert the empty row got inserted
1014 assert_eq!(
1015 Some(id),
1016 db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
1017 SELECT workspace_id FROM workspaces WHERE workspace_id = ?
1018 ))
1019 .unwrap()(id)
1020 .unwrap()
1021 );
1022
1023 db.write(move |conn| {
1024 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
1025 .unwrap()(("test-text-1", id))
1026 .unwrap()
1027 })
1028 .await;
1029
1030 let test_text_1 = db
1031 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
1032 .unwrap()(1)
1033 .unwrap()
1034 .unwrap();
1035 assert_eq!(test_text_1, "test-text-1");
1036 }
1037
1038 #[gpui::test]
1039 async fn test_workspace_id_stability() {
1040 env_logger::try_init().ok();
1041
1042 let db = WorkspaceDb(open_test_db("test_workspace_id_stability").await);
1043
1044 db.write(|conn| {
1045 conn.migrate(
1046 "test_table",
1047 &[sql!(
1048 CREATE TABLE test_table(
1049 text TEXT,
1050 workspace_id INTEGER,
1051 FOREIGN KEY(workspace_id)
1052 REFERENCES workspaces(workspace_id)
1053 ON DELETE CASCADE
1054 ) STRICT;)],
1055 )
1056 })
1057 .await
1058 .unwrap();
1059
1060 let mut workspace_1 = SerializedWorkspace {
1061 id: WorkspaceId(1),
1062 location: LocalPaths::new(["/tmp", "/tmp2"]).into(),
1063 center_group: Default::default(),
1064 window_bounds: Default::default(),
1065 display: Default::default(),
1066 docks: Default::default(),
1067 centered_layout: false,
1068 };
1069
1070 let workspace_2 = SerializedWorkspace {
1071 id: WorkspaceId(2),
1072 location: LocalPaths::new(["/tmp"]).into(),
1073 center_group: Default::default(),
1074 window_bounds: Default::default(),
1075 display: Default::default(),
1076 docks: Default::default(),
1077 centered_layout: false,
1078 };
1079
1080 db.save_workspace(workspace_1.clone()).await;
1081
1082 db.write(|conn| {
1083 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
1084 .unwrap()(("test-text-1", 1))
1085 .unwrap();
1086 })
1087 .await;
1088
1089 db.save_workspace(workspace_2.clone()).await;
1090
1091 db.write(|conn| {
1092 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
1093 .unwrap()(("test-text-2", 2))
1094 .unwrap();
1095 })
1096 .await;
1097
1098 workspace_1.location = LocalPaths::new(["/tmp", "/tmp3"]).into();
1099 db.save_workspace(workspace_1.clone()).await;
1100 db.save_workspace(workspace_1).await;
1101 db.save_workspace(workspace_2).await;
1102
1103 let test_text_2 = db
1104 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
1105 .unwrap()(2)
1106 .unwrap()
1107 .unwrap();
1108 assert_eq!(test_text_2, "test-text-2");
1109
1110 let test_text_1 = db
1111 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
1112 .unwrap()(1)
1113 .unwrap()
1114 .unwrap();
1115 assert_eq!(test_text_1, "test-text-1");
1116 }
1117
1118 fn group(axis: Axis, children: Vec<SerializedPaneGroup>) -> SerializedPaneGroup {
1119 SerializedPaneGroup::Group {
1120 axis: SerializedAxis(axis),
1121 flexes: None,
1122 children,
1123 }
1124 }
1125
1126 #[gpui::test]
1127 async fn test_full_workspace_serialization() {
1128 env_logger::try_init().ok();
1129
1130 let db = WorkspaceDb(open_test_db("test_full_workspace_serialization").await);
1131
1132 // -----------------
1133 // | 1,2 | 5,6 |
1134 // | - - - | |
1135 // | 3,4 | |
1136 // -----------------
1137 let center_group = group(
1138 Axis::Horizontal,
1139 vec![
1140 group(
1141 Axis::Vertical,
1142 vec![
1143 SerializedPaneGroup::Pane(SerializedPane::new(
1144 vec![
1145 SerializedItem::new("Terminal", 5, false, false),
1146 SerializedItem::new("Terminal", 6, true, false),
1147 ],
1148 false,
1149 )),
1150 SerializedPaneGroup::Pane(SerializedPane::new(
1151 vec![
1152 SerializedItem::new("Terminal", 7, true, false),
1153 SerializedItem::new("Terminal", 8, false, false),
1154 ],
1155 false,
1156 )),
1157 ],
1158 ),
1159 SerializedPaneGroup::Pane(SerializedPane::new(
1160 vec![
1161 SerializedItem::new("Terminal", 9, false, false),
1162 SerializedItem::new("Terminal", 10, true, false),
1163 ],
1164 false,
1165 )),
1166 ],
1167 );
1168
1169 let workspace = SerializedWorkspace {
1170 id: WorkspaceId(5),
1171 location: SerializedWorkspaceLocation::Local(
1172 LocalPaths::new(["/tmp", "/tmp2"]),
1173 LocalPathsOrder::new([1, 0]),
1174 ),
1175 center_group,
1176 window_bounds: Default::default(),
1177 display: Default::default(),
1178 docks: Default::default(),
1179 centered_layout: false,
1180 };
1181
1182 db.save_workspace(workspace.clone()).await;
1183
1184 let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
1185 assert_eq!(workspace, round_trip_workspace.unwrap());
1186
1187 // Test guaranteed duplicate IDs
1188 db.save_workspace(workspace.clone()).await;
1189 db.save_workspace(workspace.clone()).await;
1190
1191 let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
1192 assert_eq!(workspace, round_trip_workspace.unwrap());
1193 }
1194
1195 #[gpui::test]
1196 async fn test_workspace_assignment() {
1197 env_logger::try_init().ok();
1198
1199 let db = WorkspaceDb(open_test_db("test_basic_functionality").await);
1200
1201 let workspace_1 = SerializedWorkspace {
1202 id: WorkspaceId(1),
1203 location: SerializedWorkspaceLocation::Local(
1204 LocalPaths::new(["/tmp", "/tmp2"]),
1205 LocalPathsOrder::new([0, 1]),
1206 ),
1207 center_group: Default::default(),
1208 window_bounds: Default::default(),
1209 display: Default::default(),
1210 docks: Default::default(),
1211 centered_layout: false,
1212 };
1213
1214 let mut workspace_2 = SerializedWorkspace {
1215 id: WorkspaceId(2),
1216 location: LocalPaths::new(["/tmp"]).into(),
1217 center_group: Default::default(),
1218 window_bounds: Default::default(),
1219 display: Default::default(),
1220 docks: Default::default(),
1221 centered_layout: false,
1222 };
1223
1224 db.save_workspace(workspace_1.clone()).await;
1225 db.save_workspace(workspace_2.clone()).await;
1226
1227 // Test that paths are treated as a set
1228 assert_eq!(
1229 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
1230 workspace_1
1231 );
1232 assert_eq!(
1233 db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
1234 workspace_1
1235 );
1236
1237 // Make sure that other keys work
1238 assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
1239 assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
1240
1241 // Test 'mutate' case of updating a pre-existing id
1242 workspace_2.location = LocalPaths::new(["/tmp", "/tmp2"]).into();
1243
1244 db.save_workspace(workspace_2.clone()).await;
1245 assert_eq!(
1246 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
1247 workspace_2
1248 );
1249
1250 // Test other mechanism for mutating
1251 let mut workspace_3 = SerializedWorkspace {
1252 id: WorkspaceId(3),
1253 location: SerializedWorkspaceLocation::Local(
1254 LocalPaths::new(&["/tmp", "/tmp2"]),
1255 LocalPathsOrder::new([1, 0]),
1256 ),
1257 center_group: Default::default(),
1258 window_bounds: Default::default(),
1259 display: Default::default(),
1260 docks: Default::default(),
1261 centered_layout: false,
1262 };
1263
1264 db.save_workspace(workspace_3.clone()).await;
1265 assert_eq!(
1266 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
1267 workspace_3
1268 );
1269
1270 // Make sure that updating paths differently also works
1271 workspace_3.location = LocalPaths::new(["/tmp3", "/tmp4", "/tmp2"]).into();
1272 db.save_workspace(workspace_3.clone()).await;
1273 assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
1274 assert_eq!(
1275 db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
1276 .unwrap(),
1277 workspace_3
1278 );
1279 }
1280
1281 use crate::persistence::model::SerializedWorkspace;
1282 use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
1283
1284 fn default_workspace<P: AsRef<Path>>(
1285 workspace_id: &[P],
1286 center_group: &SerializedPaneGroup,
1287 ) -> SerializedWorkspace {
1288 SerializedWorkspace {
1289 id: WorkspaceId(4),
1290 location: LocalPaths::new(workspace_id).into(),
1291 center_group: center_group.clone(),
1292 window_bounds: Default::default(),
1293 display: Default::default(),
1294 docks: Default::default(),
1295 centered_layout: false,
1296 }
1297 }
1298
1299 #[gpui::test]
1300 async fn test_simple_split() {
1301 env_logger::try_init().ok();
1302
1303 let db = WorkspaceDb(open_test_db("simple_split").await);
1304
1305 // -----------------
1306 // | 1,2 | 5,6 |
1307 // | - - - | |
1308 // | 3,4 | |
1309 // -----------------
1310 let center_pane = group(
1311 Axis::Horizontal,
1312 vec![
1313 group(
1314 Axis::Vertical,
1315 vec![
1316 SerializedPaneGroup::Pane(SerializedPane::new(
1317 vec![
1318 SerializedItem::new("Terminal", 1, false, false),
1319 SerializedItem::new("Terminal", 2, true, false),
1320 ],
1321 false,
1322 )),
1323 SerializedPaneGroup::Pane(SerializedPane::new(
1324 vec![
1325 SerializedItem::new("Terminal", 4, false, false),
1326 SerializedItem::new("Terminal", 3, true, false),
1327 ],
1328 true,
1329 )),
1330 ],
1331 ),
1332 SerializedPaneGroup::Pane(SerializedPane::new(
1333 vec![
1334 SerializedItem::new("Terminal", 5, true, false),
1335 SerializedItem::new("Terminal", 6, false, false),
1336 ],
1337 false,
1338 )),
1339 ],
1340 );
1341
1342 let workspace = default_workspace(&["/tmp"], ¢er_pane);
1343
1344 db.save_workspace(workspace.clone()).await;
1345
1346 let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
1347
1348 assert_eq!(workspace.center_group, new_workspace.center_group);
1349 }
1350
1351 #[gpui::test]
1352 async fn test_cleanup_panes() {
1353 env_logger::try_init().ok();
1354
1355 let db = WorkspaceDb(open_test_db("test_cleanup_panes").await);
1356
1357 let center_pane = group(
1358 Axis::Horizontal,
1359 vec![
1360 group(
1361 Axis::Vertical,
1362 vec![
1363 SerializedPaneGroup::Pane(SerializedPane::new(
1364 vec![
1365 SerializedItem::new("Terminal", 1, false, false),
1366 SerializedItem::new("Terminal", 2, true, false),
1367 ],
1368 false,
1369 )),
1370 SerializedPaneGroup::Pane(SerializedPane::new(
1371 vec![
1372 SerializedItem::new("Terminal", 4, false, false),
1373 SerializedItem::new("Terminal", 3, true, false),
1374 ],
1375 true,
1376 )),
1377 ],
1378 ),
1379 SerializedPaneGroup::Pane(SerializedPane::new(
1380 vec![
1381 SerializedItem::new("Terminal", 5, false, false),
1382 SerializedItem::new("Terminal", 6, true, false),
1383 ],
1384 false,
1385 )),
1386 ],
1387 );
1388
1389 let id = &["/tmp"];
1390
1391 let mut workspace = default_workspace(id, ¢er_pane);
1392
1393 db.save_workspace(workspace.clone()).await;
1394
1395 workspace.center_group = group(
1396 Axis::Vertical,
1397 vec![
1398 SerializedPaneGroup::Pane(SerializedPane::new(
1399 vec![
1400 SerializedItem::new("Terminal", 1, false, false),
1401 SerializedItem::new("Terminal", 2, true, false),
1402 ],
1403 false,
1404 )),
1405 SerializedPaneGroup::Pane(SerializedPane::new(
1406 vec![
1407 SerializedItem::new("Terminal", 4, true, false),
1408 SerializedItem::new("Terminal", 3, false, false),
1409 ],
1410 true,
1411 )),
1412 ],
1413 );
1414
1415 db.save_workspace(workspace.clone()).await;
1416
1417 let new_workspace = db.workspace_for_roots(id).unwrap();
1418
1419 assert_eq!(workspace.center_group, new_workspace.center_group);
1420 }
1421}