persistence.rs

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