persistence.rs

   1pub mod model;
   2
   3use std::{
   4    borrow::Cow,
   5    collections::BTreeMap,
   6    path::{Path, PathBuf},
   7    str::FromStr,
   8    sync::Arc,
   9};
  10
  11use chrono::{DateTime, NaiveDateTime, Utc};
  12use fs::Fs;
  13
  14use anyhow::{Context as _, Result, bail};
  15use collections::{HashMap, HashSet, IndexSet};
  16use db::{
  17    kvp::KeyValueStore,
  18    query,
  19    sqlez::{connection::Connection, domain::Domain},
  20    sqlez_macros::sql,
  21};
  22use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
  23use project::{
  24    debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
  25    trusted_worktrees::{DbTrustedPaths, RemoteHostLocation},
  26};
  27
  28use language::{LanguageName, Toolchain, ToolchainScope};
  29use remote::{
  30    DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
  31};
  32use serde::{Deserialize, Serialize};
  33use sqlez::{
  34    bindable::{Bind, Column, StaticColumnCount},
  35    statement::Statement,
  36    thread_safe_connection::ThreadSafeConnection,
  37};
  38
  39use ui::{App, SharedString, px};
  40use util::{ResultExt, maybe, rel_path::RelPath};
  41use uuid::Uuid;
  42
  43use crate::{
  44    WorkspaceId,
  45    path_list::{PathList, SerializedPathList},
  46    persistence::model::RemoteConnectionKind,
  47};
  48
  49use model::{
  50    GroupId, ItemId, PaneId, RemoteConnectionId, SerializedItem, SerializedPane,
  51    SerializedPaneGroup, SerializedWorkspace,
  52};
  53
  54use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace};
  55
  56// https://www.sqlite.org/limits.html
  57// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
  58// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
  59const MAX_QUERY_PLACEHOLDERS: usize = 32000;
  60
  61fn parse_timestamp(text: &str) -> DateTime<Utc> {
  62    NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
  63        .map(|naive| naive.and_utc())
  64        .unwrap_or_else(|_| Utc::now())
  65}
  66
  67#[derive(Copy, Clone, Debug, PartialEq)]
  68pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
  69impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
  70impl sqlez::bindable::Bind for SerializedAxis {
  71    fn bind(
  72        &self,
  73        statement: &sqlez::statement::Statement,
  74        start_index: i32,
  75    ) -> anyhow::Result<i32> {
  76        match self.0 {
  77            gpui::Axis::Horizontal => "Horizontal",
  78            gpui::Axis::Vertical => "Vertical",
  79        }
  80        .bind(statement, start_index)
  81    }
  82}
  83
  84impl sqlez::bindable::Column for SerializedAxis {
  85    fn column(
  86        statement: &mut sqlez::statement::Statement,
  87        start_index: i32,
  88    ) -> anyhow::Result<(Self, i32)> {
  89        String::column(statement, start_index).and_then(|(axis_text, next_index)| {
  90            Ok((
  91                match axis_text.as_str() {
  92                    "Horizontal" => Self(Axis::Horizontal),
  93                    "Vertical" => Self(Axis::Vertical),
  94                    _ => anyhow::bail!("Stored serialized item kind is incorrect"),
  95                },
  96                next_index,
  97            ))
  98        })
  99    }
 100}
 101
 102#[derive(Copy, Clone, Debug, PartialEq, Default)]
 103pub(crate) struct SerializedWindowBounds(pub(crate) WindowBounds);
 104
 105impl StaticColumnCount for SerializedWindowBounds {
 106    fn column_count() -> usize {
 107        5
 108    }
 109}
 110
 111impl Bind for SerializedWindowBounds {
 112    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
 113        match self.0 {
 114            WindowBounds::Windowed(bounds) => {
 115                let next_index = statement.bind(&"Windowed", start_index)?;
 116                statement.bind(
 117                    &(
 118                        SerializedPixels(bounds.origin.x),
 119                        SerializedPixels(bounds.origin.y),
 120                        SerializedPixels(bounds.size.width),
 121                        SerializedPixels(bounds.size.height),
 122                    ),
 123                    next_index,
 124                )
 125            }
 126            WindowBounds::Maximized(bounds) => {
 127                let next_index = statement.bind(&"Maximized", start_index)?;
 128                statement.bind(
 129                    &(
 130                        SerializedPixels(bounds.origin.x),
 131                        SerializedPixels(bounds.origin.y),
 132                        SerializedPixels(bounds.size.width),
 133                        SerializedPixels(bounds.size.height),
 134                    ),
 135                    next_index,
 136                )
 137            }
 138            WindowBounds::Fullscreen(bounds) => {
 139                let next_index = statement.bind(&"FullScreen", start_index)?;
 140                statement.bind(
 141                    &(
 142                        SerializedPixels(bounds.origin.x),
 143                        SerializedPixels(bounds.origin.y),
 144                        SerializedPixels(bounds.size.width),
 145                        SerializedPixels(bounds.size.height),
 146                    ),
 147                    next_index,
 148                )
 149            }
 150        }
 151    }
 152}
 153
 154impl Column for SerializedWindowBounds {
 155    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
 156        let (window_state, next_index) = String::column(statement, start_index)?;
 157        let ((x, y, width, height), _): ((i32, i32, i32, i32), _) =
 158            Column::column(statement, next_index)?;
 159        let bounds = Bounds {
 160            origin: point(px(x as f32), px(y as f32)),
 161            size: size(px(width as f32), px(height as f32)),
 162        };
 163
 164        let status = match window_state.as_str() {
 165            "Windowed" | "Fixed" => SerializedWindowBounds(WindowBounds::Windowed(bounds)),
 166            "Maximized" => SerializedWindowBounds(WindowBounds::Maximized(bounds)),
 167            "FullScreen" => SerializedWindowBounds(WindowBounds::Fullscreen(bounds)),
 168            _ => bail!("Window State did not have a valid string"),
 169        };
 170
 171        Ok((status, next_index + 4))
 172    }
 173}
 174
 175const DEFAULT_WINDOW_BOUNDS_KEY: &str = "default_window_bounds";
 176
 177pub fn read_default_window_bounds(kvp: &KeyValueStore) -> Option<(Uuid, WindowBounds)> {
 178    let json_str = kvp
 179        .read_kvp(DEFAULT_WINDOW_BOUNDS_KEY)
 180        .log_err()
 181        .flatten()?;
 182
 183    let (display_uuid, persisted) =
 184        serde_json::from_str::<(Uuid, WindowBoundsJson)>(&json_str).ok()?;
 185    Some((display_uuid, persisted.into()))
 186}
 187
 188pub async fn write_default_window_bounds(
 189    kvp: &KeyValueStore,
 190    bounds: WindowBounds,
 191    display_uuid: Uuid,
 192) -> anyhow::Result<()> {
 193    let persisted = WindowBoundsJson::from(bounds);
 194    let json_str = serde_json::to_string(&(display_uuid, persisted))?;
 195    kvp.write_kvp(DEFAULT_WINDOW_BOUNDS_KEY.to_string(), json_str)
 196        .await?;
 197    Ok(())
 198}
 199
 200#[derive(Serialize, Deserialize)]
 201pub enum WindowBoundsJson {
 202    Windowed {
 203        x: i32,
 204        y: i32,
 205        width: i32,
 206        height: i32,
 207    },
 208    Maximized {
 209        x: i32,
 210        y: i32,
 211        width: i32,
 212        height: i32,
 213    },
 214    Fullscreen {
 215        x: i32,
 216        y: i32,
 217        width: i32,
 218        height: i32,
 219    },
 220}
 221
 222impl From<WindowBounds> for WindowBoundsJson {
 223    fn from(b: WindowBounds) -> Self {
 224        match b {
 225            WindowBounds::Windowed(bounds) => {
 226                let origin = bounds.origin;
 227                let size = bounds.size;
 228                WindowBoundsJson::Windowed {
 229                    x: f32::from(origin.x).round() as i32,
 230                    y: f32::from(origin.y).round() as i32,
 231                    width: f32::from(size.width).round() as i32,
 232                    height: f32::from(size.height).round() as i32,
 233                }
 234            }
 235            WindowBounds::Maximized(bounds) => {
 236                let origin = bounds.origin;
 237                let size = bounds.size;
 238                WindowBoundsJson::Maximized {
 239                    x: f32::from(origin.x).round() as i32,
 240                    y: f32::from(origin.y).round() as i32,
 241                    width: f32::from(size.width).round() as i32,
 242                    height: f32::from(size.height).round() as i32,
 243                }
 244            }
 245            WindowBounds::Fullscreen(bounds) => {
 246                let origin = bounds.origin;
 247                let size = bounds.size;
 248                WindowBoundsJson::Fullscreen {
 249                    x: f32::from(origin.x).round() as i32,
 250                    y: f32::from(origin.y).round() as i32,
 251                    width: f32::from(size.width).round() as i32,
 252                    height: f32::from(size.height).round() as i32,
 253                }
 254            }
 255        }
 256    }
 257}
 258
 259impl From<WindowBoundsJson> for WindowBounds {
 260    fn from(n: WindowBoundsJson) -> Self {
 261        match n {
 262            WindowBoundsJson::Windowed {
 263                x,
 264                y,
 265                width,
 266                height,
 267            } => WindowBounds::Windowed(Bounds {
 268                origin: point(px(x as f32), px(y as f32)),
 269                size: size(px(width as f32), px(height as f32)),
 270            }),
 271            WindowBoundsJson::Maximized {
 272                x,
 273                y,
 274                width,
 275                height,
 276            } => WindowBounds::Maximized(Bounds {
 277                origin: point(px(x as f32), px(y as f32)),
 278                size: size(px(width as f32), px(height as f32)),
 279            }),
 280            WindowBoundsJson::Fullscreen {
 281                x,
 282                y,
 283                width,
 284                height,
 285            } => WindowBounds::Fullscreen(Bounds {
 286                origin: point(px(x as f32), px(y as f32)),
 287                size: size(px(width as f32), px(height as f32)),
 288            }),
 289        }
 290    }
 291}
 292
 293fn read_multi_workspace_state(window_id: WindowId, cx: &App) -> model::MultiWorkspaceState {
 294    let kvp = KeyValueStore::global(cx);
 295    kvp.scoped("multi_workspace_state")
 296        .read(&window_id.as_u64().to_string())
 297        .log_err()
 298        .flatten()
 299        .and_then(|json| serde_json::from_str(&json).ok())
 300        .unwrap_or_default()
 301}
 302
 303pub async fn write_multi_workspace_state(
 304    kvp: &KeyValueStore,
 305    window_id: WindowId,
 306    state: model::MultiWorkspaceState,
 307) {
 308    if let Ok(json_str) = serde_json::to_string(&state) {
 309        kvp.scoped("multi_workspace_state")
 310            .write(window_id.as_u64().to_string(), json_str)
 311            .await
 312            .log_err();
 313    }
 314}
 315
 316pub fn read_serialized_multi_workspaces(
 317    session_workspaces: Vec<model::SessionWorkspace>,
 318    cx: &App,
 319) -> Vec<model::SerializedMultiWorkspace> {
 320    let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
 321    let mut window_id_to_group: HashMap<WindowId, usize> = HashMap::default();
 322
 323    for session_workspace in session_workspaces {
 324        match session_workspace.window_id {
 325            Some(window_id) => {
 326                let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| {
 327                    window_groups.push(Vec::new());
 328                    window_groups.len() - 1
 329                });
 330                window_groups[group_index].push(session_workspace);
 331            }
 332            None => {
 333                window_groups.push(vec![session_workspace]);
 334            }
 335        }
 336    }
 337
 338    window_groups
 339        .into_iter()
 340        .filter_map(|group| {
 341            let window_id = group.first().and_then(|sw| sw.window_id);
 342            let state = window_id
 343                .map(|wid| read_multi_workspace_state(wid, cx))
 344                .unwrap_or_default();
 345            let active_workspace = state
 346                .active_workspace_id
 347                .and_then(|id| group.iter().position(|ws| ws.workspace_id == id))
 348                .or(Some(0))
 349                .and_then(|index| group.into_iter().nth(index))?;
 350            Some(model::SerializedMultiWorkspace {
 351                active_workspace,
 352                state,
 353            })
 354        })
 355        .collect()
 356}
 357
 358const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state";
 359
 360pub fn read_default_dock_state(kvp: &KeyValueStore) -> Option<DockStructure> {
 361    let json_str = kvp.read_kvp(DEFAULT_DOCK_STATE_KEY).log_err().flatten()?;
 362
 363    serde_json::from_str::<DockStructure>(&json_str).ok()
 364}
 365
 366pub async fn write_default_dock_state(
 367    kvp: &KeyValueStore,
 368    docks: DockStructure,
 369) -> anyhow::Result<()> {
 370    let json_str = serde_json::to_string(&docks)?;
 371    kvp.write_kvp(DEFAULT_DOCK_STATE_KEY.to_string(), json_str)
 372        .await?;
 373    Ok(())
 374}
 375
 376#[derive(Debug)]
 377pub struct Breakpoint {
 378    pub position: u32,
 379    pub message: Option<Arc<str>>,
 380    pub condition: Option<Arc<str>>,
 381    pub hit_condition: Option<Arc<str>>,
 382    pub state: BreakpointState,
 383}
 384
 385/// Wrapper for DB type of a breakpoint
 386struct BreakpointStateWrapper<'a>(Cow<'a, BreakpointState>);
 387
 388impl From<BreakpointState> for BreakpointStateWrapper<'static> {
 389    fn from(kind: BreakpointState) -> Self {
 390        BreakpointStateWrapper(Cow::Owned(kind))
 391    }
 392}
 393
 394impl StaticColumnCount for BreakpointStateWrapper<'_> {
 395    fn column_count() -> usize {
 396        1
 397    }
 398}
 399
 400impl Bind for BreakpointStateWrapper<'_> {
 401    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
 402        statement.bind(&self.0.to_int(), start_index)
 403    }
 404}
 405
 406impl Column for BreakpointStateWrapper<'_> {
 407    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
 408        let state = statement.column_int(start_index)?;
 409
 410        match state {
 411            0 => Ok((BreakpointState::Enabled.into(), start_index + 1)),
 412            1 => Ok((BreakpointState::Disabled.into(), start_index + 1)),
 413            _ => anyhow::bail!("Invalid BreakpointState discriminant {state}"),
 414        }
 415    }
 416}
 417
 418impl sqlez::bindable::StaticColumnCount for Breakpoint {
 419    fn column_count() -> usize {
 420        // Position, log message, condition message, and hit condition message
 421        4 + BreakpointStateWrapper::column_count()
 422    }
 423}
 424
 425impl sqlez::bindable::Bind for Breakpoint {
 426    fn bind(
 427        &self,
 428        statement: &sqlez::statement::Statement,
 429        start_index: i32,
 430    ) -> anyhow::Result<i32> {
 431        let next_index = statement.bind(&self.position, start_index)?;
 432        let next_index = statement.bind(&self.message, next_index)?;
 433        let next_index = statement.bind(&self.condition, next_index)?;
 434        let next_index = statement.bind(&self.hit_condition, next_index)?;
 435        statement.bind(
 436            &BreakpointStateWrapper(Cow::Borrowed(&self.state)),
 437            next_index,
 438        )
 439    }
 440}
 441
 442impl Column for Breakpoint {
 443    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
 444        let position = statement
 445            .column_int(start_index)
 446            .with_context(|| format!("Failed to read BreakPoint at index {start_index}"))?
 447            as u32;
 448        let (message, next_index) = Option::<String>::column(statement, start_index + 1)?;
 449        let (condition, next_index) = Option::<String>::column(statement, next_index)?;
 450        let (hit_condition, next_index) = Option::<String>::column(statement, next_index)?;
 451        let (state, next_index) = BreakpointStateWrapper::column(statement, next_index)?;
 452
 453        Ok((
 454            Breakpoint {
 455                position,
 456                message: message.map(Arc::from),
 457                condition: condition.map(Arc::from),
 458                hit_condition: hit_condition.map(Arc::from),
 459                state: state.0.into_owned(),
 460            },
 461            next_index,
 462        ))
 463    }
 464}
 465
 466#[derive(Clone, Debug, PartialEq)]
 467struct SerializedPixels(gpui::Pixels);
 468impl sqlez::bindable::StaticColumnCount for SerializedPixels {}
 469
 470impl sqlez::bindable::Bind for SerializedPixels {
 471    fn bind(
 472        &self,
 473        statement: &sqlez::statement::Statement,
 474        start_index: i32,
 475    ) -> anyhow::Result<i32> {
 476        let this: i32 = u32::from(self.0) as _;
 477        this.bind(statement, start_index)
 478    }
 479}
 480
 481pub struct WorkspaceDb(ThreadSafeConnection);
 482
 483impl Domain for WorkspaceDb {
 484    const NAME: &str = stringify!(WorkspaceDb);
 485
 486    const MIGRATIONS: &[&str] = &[
 487        sql!(
 488            CREATE TABLE workspaces(
 489                workspace_id INTEGER PRIMARY KEY,
 490                workspace_location BLOB UNIQUE,
 491                dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
 492                dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
 493                dock_pane INTEGER, // Deprecated.  Preserving so users can downgrade Zed.
 494                left_sidebar_open INTEGER, // Boolean
 495                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
 496                FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
 497            ) STRICT;
 498
 499            CREATE TABLE pane_groups(
 500                group_id INTEGER PRIMARY KEY,
 501                workspace_id INTEGER NOT NULL,
 502                parent_group_id INTEGER, // NULL indicates that this is a root node
 503                position INTEGER, // NULL indicates that this is a root node
 504                axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
 505                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
 506                ON DELETE CASCADE
 507                ON UPDATE CASCADE,
 508                FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
 509            ) STRICT;
 510
 511            CREATE TABLE panes(
 512                pane_id INTEGER PRIMARY KEY,
 513                workspace_id INTEGER NOT NULL,
 514                active INTEGER NOT NULL, // Boolean
 515                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
 516                ON DELETE CASCADE
 517                ON UPDATE CASCADE
 518            ) STRICT;
 519
 520            CREATE TABLE center_panes(
 521                pane_id INTEGER PRIMARY KEY,
 522                parent_group_id INTEGER, // NULL means that this is a root pane
 523                position INTEGER, // NULL means that this is a root pane
 524                FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
 525                ON DELETE CASCADE,
 526                FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
 527            ) STRICT;
 528
 529            CREATE TABLE items(
 530                item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
 531                workspace_id INTEGER NOT NULL,
 532                pane_id INTEGER NOT NULL,
 533                kind TEXT NOT NULL,
 534                position INTEGER NOT NULL,
 535                active INTEGER NOT NULL,
 536                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
 537                ON DELETE CASCADE
 538                ON UPDATE CASCADE,
 539                FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
 540                ON DELETE CASCADE,
 541                PRIMARY KEY(item_id, workspace_id)
 542            ) STRICT;
 543        ),
 544        sql!(
 545            ALTER TABLE workspaces ADD COLUMN window_state TEXT;
 546            ALTER TABLE workspaces ADD COLUMN window_x REAL;
 547            ALTER TABLE workspaces ADD COLUMN window_y REAL;
 548            ALTER TABLE workspaces ADD COLUMN window_width REAL;
 549            ALTER TABLE workspaces ADD COLUMN window_height REAL;
 550            ALTER TABLE workspaces ADD COLUMN display BLOB;
 551        ),
 552        // Drop foreign key constraint from workspaces.dock_pane to panes table.
 553        sql!(
 554            CREATE TABLE workspaces_2(
 555                workspace_id INTEGER PRIMARY KEY,
 556                workspace_location BLOB UNIQUE,
 557                dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
 558                dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
 559                dock_pane INTEGER, // Deprecated.  Preserving so users can downgrade Zed.
 560                left_sidebar_open INTEGER, // Boolean
 561                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
 562                window_state TEXT,
 563                window_x REAL,
 564                window_y REAL,
 565                window_width REAL,
 566                window_height REAL,
 567                display BLOB
 568            ) STRICT;
 569            INSERT INTO workspaces_2 SELECT * FROM workspaces;
 570            DROP TABLE workspaces;
 571            ALTER TABLE workspaces_2 RENAME TO workspaces;
 572        ),
 573        // Add panels related information
 574        sql!(
 575            ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
 576            ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
 577            ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
 578            ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
 579            ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
 580            ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
 581        ),
 582        // Add panel zoom persistence
 583        sql!(
 584            ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
 585            ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
 586            ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
 587        ),
 588        // Add pane group flex data
 589        sql!(
 590            ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
 591        ),
 592        // Add fullscreen field to workspace
 593        // Deprecated, `WindowBounds` holds the fullscreen state now.
 594        // Preserving so users can downgrade Zed.
 595        sql!(
 596            ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
 597        ),
 598        // Add preview field to items
 599        sql!(
 600            ALTER TABLE items ADD COLUMN preview INTEGER; //bool
 601        ),
 602        // Add centered_layout field to workspace
 603        sql!(
 604            ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
 605        ),
 606        sql!(
 607            CREATE TABLE remote_projects (
 608                remote_project_id INTEGER NOT NULL UNIQUE,
 609                path TEXT,
 610                dev_server_name TEXT
 611            );
 612            ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
 613            ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
 614        ),
 615        sql!(
 616            DROP TABLE remote_projects;
 617            CREATE TABLE dev_server_projects (
 618                id INTEGER NOT NULL UNIQUE,
 619                path TEXT,
 620                dev_server_name TEXT
 621            );
 622            ALTER TABLE workspaces DROP COLUMN remote_project_id;
 623            ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
 624        ),
 625        sql!(
 626            ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
 627        ),
 628        sql!(
 629            ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
 630        ),
 631        sql!(
 632            ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL;
 633        ),
 634        sql!(
 635            ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
 636        ),
 637        sql!(
 638            CREATE TABLE ssh_projects (
 639                id INTEGER PRIMARY KEY,
 640                host TEXT NOT NULL,
 641                port INTEGER,
 642                path TEXT NOT NULL,
 643                user TEXT
 644            );
 645            ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
 646        ),
 647        sql!(
 648            ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
 649        ),
 650        sql!(
 651            CREATE TABLE toolchains (
 652                workspace_id INTEGER,
 653                worktree_id INTEGER,
 654                language_name TEXT NOT NULL,
 655                name TEXT NOT NULL,
 656                path TEXT NOT NULL,
 657                PRIMARY KEY (workspace_id, worktree_id, language_name)
 658            );
 659        ),
 660        sql!(
 661            ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
 662        ),
 663        sql!(
 664            CREATE TABLE breakpoints (
 665                workspace_id INTEGER NOT NULL,
 666                path TEXT NOT NULL,
 667                breakpoint_location INTEGER NOT NULL,
 668                kind INTEGER NOT NULL,
 669                log_message TEXT,
 670                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
 671                ON DELETE CASCADE
 672                ON UPDATE CASCADE
 673            );
 674        ),
 675        sql!(
 676            ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT;
 677            CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array);
 678            ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT;
 679        ),
 680        sql!(
 681            ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL
 682        ),
 683        sql!(
 684            ALTER TABLE breakpoints DROP COLUMN kind
 685        ),
 686        sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL),
 687        sql!(
 688            ALTER TABLE breakpoints ADD COLUMN condition TEXT;
 689            ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT;
 690        ),
 691        sql!(CREATE TABLE toolchains2 (
 692            workspace_id INTEGER,
 693            worktree_id INTEGER,
 694            language_name TEXT NOT NULL,
 695            name TEXT NOT NULL,
 696            path TEXT NOT NULL,
 697            raw_json TEXT NOT NULL,
 698            relative_worktree_path TEXT NOT NULL,
 699            PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT;
 700            INSERT INTO toolchains2
 701                SELECT * FROM toolchains;
 702            DROP TABLE toolchains;
 703            ALTER TABLE toolchains2 RENAME TO toolchains;
 704        ),
 705        sql!(
 706            CREATE TABLE ssh_connections (
 707                id INTEGER PRIMARY KEY,
 708                host TEXT NOT NULL,
 709                port INTEGER,
 710                user TEXT
 711            );
 712
 713            INSERT INTO ssh_connections (host, port, user)
 714            SELECT DISTINCT host, port, user
 715            FROM ssh_projects;
 716
 717            CREATE TABLE workspaces_2(
 718                workspace_id INTEGER PRIMARY KEY,
 719                paths TEXT,
 720                paths_order TEXT,
 721                ssh_connection_id INTEGER REFERENCES ssh_connections(id),
 722                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
 723                window_state TEXT,
 724                window_x REAL,
 725                window_y REAL,
 726                window_width REAL,
 727                window_height REAL,
 728                display BLOB,
 729                left_dock_visible INTEGER,
 730                left_dock_active_panel TEXT,
 731                right_dock_visible INTEGER,
 732                right_dock_active_panel TEXT,
 733                bottom_dock_visible INTEGER,
 734                bottom_dock_active_panel TEXT,
 735                left_dock_zoom INTEGER,
 736                right_dock_zoom INTEGER,
 737                bottom_dock_zoom INTEGER,
 738                fullscreen INTEGER,
 739                centered_layout INTEGER,
 740                session_id TEXT,
 741                window_id INTEGER
 742            ) STRICT;
 743
 744            INSERT
 745            INTO workspaces_2
 746            SELECT
 747                workspaces.workspace_id,
 748                CASE
 749                    WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
 750                    ELSE
 751                        CASE
 752                            WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
 753                                NULL
 754                            ELSE
 755                                replace(workspaces.local_paths_array, ',', CHAR(10))
 756                        END
 757                END as paths,
 758
 759                CASE
 760                    WHEN ssh_projects.id IS NOT NULL THEN ""
 761                    ELSE workspaces.local_paths_order_array
 762                END as paths_order,
 763
 764                CASE
 765                    WHEN ssh_projects.id IS NOT NULL THEN (
 766                        SELECT ssh_connections.id
 767                        FROM ssh_connections
 768                        WHERE
 769                            ssh_connections.host IS ssh_projects.host AND
 770                            ssh_connections.port IS ssh_projects.port AND
 771                            ssh_connections.user IS ssh_projects.user
 772                    )
 773                    ELSE NULL
 774                END as ssh_connection_id,
 775
 776                workspaces.timestamp,
 777                workspaces.window_state,
 778                workspaces.window_x,
 779                workspaces.window_y,
 780                workspaces.window_width,
 781                workspaces.window_height,
 782                workspaces.display,
 783                workspaces.left_dock_visible,
 784                workspaces.left_dock_active_panel,
 785                workspaces.right_dock_visible,
 786                workspaces.right_dock_active_panel,
 787                workspaces.bottom_dock_visible,
 788                workspaces.bottom_dock_active_panel,
 789                workspaces.left_dock_zoom,
 790                workspaces.right_dock_zoom,
 791                workspaces.bottom_dock_zoom,
 792                workspaces.fullscreen,
 793                workspaces.centered_layout,
 794                workspaces.session_id,
 795                workspaces.window_id
 796            FROM
 797                workspaces LEFT JOIN
 798                ssh_projects ON
 799                workspaces.ssh_project_id = ssh_projects.id;
 800
 801            DELETE FROM workspaces_2
 802            WHERE workspace_id NOT IN (
 803                SELECT MAX(workspace_id)
 804                FROM workspaces_2
 805                GROUP BY ssh_connection_id, paths
 806            );
 807
 808            DROP TABLE ssh_projects;
 809            DROP TABLE workspaces;
 810            ALTER TABLE workspaces_2 RENAME TO workspaces;
 811
 812            CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
 813        ),
 814        // Fix any data from when workspaces.paths were briefly encoded as JSON arrays
 815        sql!(
 816            UPDATE workspaces
 817            SET paths = CASE
 818                WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN
 819                    replace(
 820                        substr(paths, 3, length(paths) - 4),
 821                        '"' || ',' || '"',
 822                        CHAR(10)
 823                    )
 824                ELSE
 825                    replace(paths, ',', CHAR(10))
 826            END
 827            WHERE paths IS NOT NULL
 828        ),
 829        sql!(
 830            CREATE TABLE remote_connections(
 831                id INTEGER PRIMARY KEY,
 832                kind TEXT NOT NULL,
 833                host TEXT,
 834                port INTEGER,
 835                user TEXT,
 836                distro TEXT
 837            );
 838
 839            CREATE TABLE workspaces_2(
 840                workspace_id INTEGER PRIMARY KEY,
 841                paths TEXT,
 842                paths_order TEXT,
 843                remote_connection_id INTEGER REFERENCES remote_connections(id),
 844                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
 845                window_state TEXT,
 846                window_x REAL,
 847                window_y REAL,
 848                window_width REAL,
 849                window_height REAL,
 850                display BLOB,
 851                left_dock_visible INTEGER,
 852                left_dock_active_panel TEXT,
 853                right_dock_visible INTEGER,
 854                right_dock_active_panel TEXT,
 855                bottom_dock_visible INTEGER,
 856                bottom_dock_active_panel TEXT,
 857                left_dock_zoom INTEGER,
 858                right_dock_zoom INTEGER,
 859                bottom_dock_zoom INTEGER,
 860                fullscreen INTEGER,
 861                centered_layout INTEGER,
 862                session_id TEXT,
 863                window_id INTEGER
 864            ) STRICT;
 865
 866            INSERT INTO remote_connections
 867            SELECT
 868                id,
 869                "ssh" as kind,
 870                host,
 871                port,
 872                user,
 873                NULL as distro
 874            FROM ssh_connections;
 875
 876            INSERT
 877            INTO workspaces_2
 878            SELECT
 879                workspace_id,
 880                paths,
 881                paths_order,
 882                ssh_connection_id as remote_connection_id,
 883                timestamp,
 884                window_state,
 885                window_x,
 886                window_y,
 887                window_width,
 888                window_height,
 889                display,
 890                left_dock_visible,
 891                left_dock_active_panel,
 892                right_dock_visible,
 893                right_dock_active_panel,
 894                bottom_dock_visible,
 895                bottom_dock_active_panel,
 896                left_dock_zoom,
 897                right_dock_zoom,
 898                bottom_dock_zoom,
 899                fullscreen,
 900                centered_layout,
 901                session_id,
 902                window_id
 903            FROM
 904                workspaces;
 905
 906            DROP TABLE workspaces;
 907            ALTER TABLE workspaces_2 RENAME TO workspaces;
 908
 909            CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(remote_connection_id, paths);
 910        ),
 911        sql!(CREATE TABLE user_toolchains (
 912            remote_connection_id INTEGER,
 913            workspace_id INTEGER NOT NULL,
 914            worktree_id INTEGER NOT NULL,
 915            relative_worktree_path TEXT NOT NULL,
 916            language_name TEXT NOT NULL,
 917            name TEXT NOT NULL,
 918            path TEXT NOT NULL,
 919            raw_json TEXT NOT NULL,
 920
 921            PRIMARY KEY (workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json)
 922        ) STRICT;),
 923        sql!(
 924            DROP TABLE ssh_connections;
 925        ),
 926        sql!(
 927            ALTER TABLE remote_connections ADD COLUMN name TEXT;
 928            ALTER TABLE remote_connections ADD COLUMN container_id TEXT;
 929        ),
 930        sql!(
 931            CREATE TABLE IF NOT EXISTS trusted_worktrees (
 932                trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
 933                absolute_path TEXT,
 934                user_name TEXT,
 935                host_name TEXT
 936            ) STRICT;
 937        ),
 938        sql!(CREATE TABLE toolchains2 (
 939            workspace_id INTEGER,
 940            worktree_root_path TEXT NOT NULL,
 941            language_name TEXT NOT NULL,
 942            name TEXT NOT NULL,
 943            path TEXT NOT NULL,
 944            raw_json TEXT NOT NULL,
 945            relative_worktree_path TEXT NOT NULL,
 946            PRIMARY KEY (workspace_id, worktree_root_path, language_name, relative_worktree_path)) STRICT;
 947            INSERT OR REPLACE INTO toolchains2
 948                // The `instr(paths, '\n') = 0` part allows us to find all
 949                // workspaces that have a single worktree, as `\n` is used as a
 950                // separator when serializing the workspace paths, so if no `\n` is
 951                // found, we know we have a single worktree.
 952                SELECT toolchains.workspace_id, paths, language_name, name, path, raw_json, relative_worktree_path FROM toolchains INNER JOIN workspaces ON toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
 953            DROP TABLE toolchains;
 954            ALTER TABLE toolchains2 RENAME TO toolchains;
 955        ),
 956        sql!(CREATE TABLE user_toolchains2 (
 957            remote_connection_id INTEGER,
 958            workspace_id INTEGER NOT NULL,
 959            worktree_root_path TEXT NOT NULL,
 960            relative_worktree_path TEXT NOT NULL,
 961            language_name TEXT NOT NULL,
 962            name TEXT NOT NULL,
 963            path TEXT NOT NULL,
 964            raw_json TEXT NOT NULL,
 965
 966            PRIMARY KEY (workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT;
 967            INSERT OR REPLACE INTO user_toolchains2
 968                // The `instr(paths, '\n') = 0` part allows us to find all
 969                // workspaces that have a single worktree, as `\n` is used as a
 970                // separator when serializing the workspace paths, so if no `\n` is
 971                // found, we know we have a single worktree.
 972                SELECT user_toolchains.remote_connection_id, user_toolchains.workspace_id, paths, relative_worktree_path, language_name, name, path, raw_json  FROM user_toolchains INNER JOIN workspaces ON user_toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
 973            DROP TABLE user_toolchains;
 974            ALTER TABLE user_toolchains2 RENAME TO user_toolchains;
 975        ),
 976        sql!(
 977            ALTER TABLE remote_connections ADD COLUMN use_podman BOOLEAN;
 978        ),
 979        sql!(
 980            ALTER TABLE remote_connections ADD COLUMN remote_env TEXT;
 981        ),
 982    ];
 983
 984    // Allow recovering from bad migration that was initially shipped to nightly
 985    // when introducing the ssh_connections table.
 986    fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool {
 987        old.starts_with("CREATE TABLE ssh_connections")
 988            && new.starts_with("CREATE TABLE ssh_connections")
 989    }
 990}
 991
 992db::static_connection!(WorkspaceDb, []);
 993
 994impl WorkspaceDb {
 995    /// Returns a serialized workspace for the given worktree_roots. If the passed array
 996    /// is empty, the most recent workspace is returned instead. If no workspace for the
 997    /// passed roots is stored, returns none.
 998    pub(crate) fn workspace_for_roots<P: AsRef<Path>>(
 999        &self,
1000        worktree_roots: &[P],
1001    ) -> Option<SerializedWorkspace> {
1002        self.workspace_for_roots_internal(worktree_roots, None)
1003    }
1004
1005    pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
1006        &self,
1007        worktree_roots: &[P],
1008        remote_project_id: RemoteConnectionId,
1009    ) -> Option<SerializedWorkspace> {
1010        self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id))
1011    }
1012
1013    pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
1014        &self,
1015        worktree_roots: &[P],
1016        remote_connection_id: Option<RemoteConnectionId>,
1017    ) -> Option<SerializedWorkspace> {
1018        // paths are sorted before db interactions to ensure that the order of the paths
1019        // doesn't affect the workspace selection for existing workspaces
1020        let root_paths = PathList::new(worktree_roots);
1021
1022        // Empty workspaces cannot be matched by paths (all empty workspaces have paths = "").
1023        // They should only be restored via workspace_for_id during session restoration.
1024        if root_paths.is_empty() && remote_connection_id.is_none() {
1025            return None;
1026        }
1027
1028        // Note that we re-assign the workspace_id here in case it's empty
1029        // and we've grabbed the most recent workspace
1030        let (
1031            workspace_id,
1032            paths,
1033            paths_order,
1034            window_bounds,
1035            display,
1036            centered_layout,
1037            docks,
1038            window_id,
1039        ): (
1040            WorkspaceId,
1041            String,
1042            String,
1043            Option<SerializedWindowBounds>,
1044            Option<Uuid>,
1045            Option<bool>,
1046            DockStructure,
1047            Option<u64>,
1048        ) = self
1049            .select_row_bound(sql! {
1050                SELECT
1051                    workspace_id,
1052                    paths,
1053                    paths_order,
1054                    window_state,
1055                    window_x,
1056                    window_y,
1057                    window_width,
1058                    window_height,
1059                    display,
1060                    centered_layout,
1061                    left_dock_visible,
1062                    left_dock_active_panel,
1063                    left_dock_zoom,
1064                    right_dock_visible,
1065                    right_dock_active_panel,
1066                    right_dock_zoom,
1067                    bottom_dock_visible,
1068                    bottom_dock_active_panel,
1069                    bottom_dock_zoom,
1070                    window_id
1071                FROM workspaces
1072                WHERE
1073                    paths IS ? AND
1074                    remote_connection_id IS ?
1075                LIMIT 1
1076            })
1077            .and_then(|mut prepared_statement| {
1078                (prepared_statement)((
1079                    root_paths.serialize().paths,
1080                    remote_connection_id.map(|id| id.0 as i32),
1081                ))
1082            })
1083            .context("No workspaces found")
1084            .warn_on_err()
1085            .flatten()?;
1086
1087        let paths = PathList::deserialize(&SerializedPathList {
1088            paths,
1089            order: paths_order,
1090        });
1091
1092        let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
1093            self.remote_connection(remote_connection_id)
1094                .context("Get remote connection")
1095                .log_err()
1096        } else {
1097            None
1098        };
1099
1100        Some(SerializedWorkspace {
1101            id: workspace_id,
1102            location: match remote_connection_options {
1103                Some(options) => SerializedWorkspaceLocation::Remote(options),
1104                None => SerializedWorkspaceLocation::Local,
1105            },
1106            paths,
1107            center_group: self
1108                .get_center_pane_group(workspace_id)
1109                .context("Getting center group")
1110                .log_err()?,
1111            window_bounds,
1112            centered_layout: centered_layout.unwrap_or(false),
1113            display,
1114            docks,
1115            session_id: None,
1116            breakpoints: self.breakpoints(workspace_id),
1117            window_id,
1118            user_toolchains: self.user_toolchains(workspace_id, remote_connection_id),
1119        })
1120    }
1121
1122    /// Returns the workspace with the given ID, loading all associated data.
1123    pub(crate) fn workspace_for_id(
1124        &self,
1125        workspace_id: WorkspaceId,
1126    ) -> Option<SerializedWorkspace> {
1127        let (
1128            paths,
1129            paths_order,
1130            window_bounds,
1131            display,
1132            centered_layout,
1133            docks,
1134            window_id,
1135            remote_connection_id,
1136        ): (
1137            String,
1138            String,
1139            Option<SerializedWindowBounds>,
1140            Option<Uuid>,
1141            Option<bool>,
1142            DockStructure,
1143            Option<u64>,
1144            Option<i32>,
1145        ) = self
1146            .select_row_bound(sql! {
1147                SELECT
1148                    paths,
1149                    paths_order,
1150                    window_state,
1151                    window_x,
1152                    window_y,
1153                    window_width,
1154                    window_height,
1155                    display,
1156                    centered_layout,
1157                    left_dock_visible,
1158                    left_dock_active_panel,
1159                    left_dock_zoom,
1160                    right_dock_visible,
1161                    right_dock_active_panel,
1162                    right_dock_zoom,
1163                    bottom_dock_visible,
1164                    bottom_dock_active_panel,
1165                    bottom_dock_zoom,
1166                    window_id,
1167                    remote_connection_id
1168                FROM workspaces
1169                WHERE workspace_id = ?
1170            })
1171            .and_then(|mut prepared_statement| (prepared_statement)(workspace_id))
1172            .context("No workspace found for id")
1173            .warn_on_err()
1174            .flatten()?;
1175
1176        let paths = PathList::deserialize(&SerializedPathList {
1177            paths,
1178            order: paths_order,
1179        });
1180
1181        let remote_connection_id = remote_connection_id.map(|id| RemoteConnectionId(id as u64));
1182        let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
1183            self.remote_connection(remote_connection_id)
1184                .context("Get remote connection")
1185                .log_err()
1186        } else {
1187            None
1188        };
1189
1190        Some(SerializedWorkspace {
1191            id: workspace_id,
1192            location: match remote_connection_options {
1193                Some(options) => SerializedWorkspaceLocation::Remote(options),
1194                None => SerializedWorkspaceLocation::Local,
1195            },
1196            paths,
1197            center_group: self
1198                .get_center_pane_group(workspace_id)
1199                .context("Getting center group")
1200                .log_err()?,
1201            window_bounds,
1202            centered_layout: centered_layout.unwrap_or(false),
1203            display,
1204            docks,
1205            session_id: None,
1206            breakpoints: self.breakpoints(workspace_id),
1207            window_id,
1208            user_toolchains: self.user_toolchains(workspace_id, remote_connection_id),
1209        })
1210    }
1211
1212    fn breakpoints(&self, workspace_id: WorkspaceId) -> BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> {
1213        let breakpoints: Result<Vec<(PathBuf, Breakpoint)>> = self
1214            .select_bound(sql! {
1215                SELECT path, breakpoint_location, log_message, condition, hit_condition, state
1216                FROM breakpoints
1217                WHERE workspace_id = ?
1218            })
1219            .and_then(|mut prepared_statement| (prepared_statement)(workspace_id));
1220
1221        match breakpoints {
1222            Ok(bp) => {
1223                if bp.is_empty() {
1224                    log::debug!("Breakpoints are empty after querying database for them");
1225                }
1226
1227                let mut map: BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> = Default::default();
1228
1229                for (path, breakpoint) in bp {
1230                    let path: Arc<Path> = path.into();
1231                    map.entry(path.clone()).or_default().push(SourceBreakpoint {
1232                        row: breakpoint.position,
1233                        path,
1234                        message: breakpoint.message,
1235                        condition: breakpoint.condition,
1236                        hit_condition: breakpoint.hit_condition,
1237                        state: breakpoint.state,
1238                    });
1239                }
1240
1241                for (path, bps) in map.iter() {
1242                    log::info!(
1243                        "Got {} breakpoints from database at path: {}",
1244                        bps.len(),
1245                        path.to_string_lossy()
1246                    );
1247                }
1248
1249                map
1250            }
1251            Err(msg) => {
1252                log::error!("Breakpoints query failed with msg: {msg}");
1253                Default::default()
1254            }
1255        }
1256    }
1257
1258    fn user_toolchains(
1259        &self,
1260        workspace_id: WorkspaceId,
1261        remote_connection_id: Option<RemoteConnectionId>,
1262    ) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
1263        type RowKind = (WorkspaceId, String, String, String, String, String, String);
1264
1265        let toolchains: Vec<RowKind> = self
1266            .select_bound(sql! {
1267                SELECT workspace_id, worktree_root_path, relative_worktree_path,
1268                language_name, name, path, raw_json
1269                FROM user_toolchains WHERE remote_connection_id IS ?1 AND (
1270                      workspace_id IN (0, ?2)
1271                )
1272            })
1273            .and_then(|mut statement| {
1274                (statement)((remote_connection_id.map(|id| id.0), workspace_id))
1275            })
1276            .unwrap_or_default();
1277        let mut ret = BTreeMap::<_, IndexSet<_>>::default();
1278
1279        for (
1280            _workspace_id,
1281            worktree_root_path,
1282            relative_worktree_path,
1283            language_name,
1284            name,
1285            path,
1286            raw_json,
1287        ) in toolchains
1288        {
1289            // INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to
1290            let scope = if _workspace_id == WorkspaceId(0) {
1291                debug_assert_eq!(worktree_root_path, String::default());
1292                debug_assert_eq!(relative_worktree_path, String::default());
1293                ToolchainScope::Global
1294            } else {
1295                debug_assert_eq!(workspace_id, _workspace_id);
1296                debug_assert_eq!(
1297                    worktree_root_path == String::default(),
1298                    relative_worktree_path == String::default()
1299                );
1300
1301                let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else {
1302                    continue;
1303                };
1304                if worktree_root_path != String::default()
1305                    && relative_worktree_path != String::default()
1306                {
1307                    ToolchainScope::Subproject(
1308                        Arc::from(worktree_root_path.as_ref()),
1309                        relative_path.into(),
1310                    )
1311                } else {
1312                    ToolchainScope::Project
1313                }
1314            };
1315            let Ok(as_json) = serde_json::from_str(&raw_json) else {
1316                continue;
1317            };
1318            let toolchain = Toolchain {
1319                name: SharedString::from(name),
1320                path: SharedString::from(path),
1321                language_name: LanguageName::from_proto(language_name),
1322                as_json,
1323            };
1324            ret.entry(scope).or_default().insert(toolchain);
1325        }
1326
1327        ret
1328    }
1329
1330    /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
1331    /// that used this workspace previously
1332    pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
1333        let paths = workspace.paths.serialize();
1334        log::debug!("Saving workspace at location: {:?}", workspace.location);
1335        self.write(move |conn| {
1336            conn.with_savepoint("update_worktrees", || {
1337                let remote_connection_id = match workspace.location.clone() {
1338                    SerializedWorkspaceLocation::Local => None,
1339                    SerializedWorkspaceLocation::Remote(connection_options) => {
1340                        Some(Self::get_or_create_remote_connection_internal(
1341                            conn,
1342                            connection_options
1343                        )?.0)
1344                    }
1345                };
1346
1347                // Clear out panes and pane_groups
1348                conn.exec_bound(sql!(
1349                    DELETE FROM pane_groups WHERE workspace_id = ?1;
1350                    DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
1351                    .context("Clearing old panes")?;
1352
1353                conn.exec_bound(
1354                    sql!(
1355                        DELETE FROM breakpoints WHERE workspace_id = ?1;
1356                    )
1357                )?(workspace.id).context("Clearing old breakpoints")?;
1358
1359                for (path, breakpoints) in workspace.breakpoints {
1360                    for bp in breakpoints {
1361                        let state = BreakpointStateWrapper::from(bp.state);
1362                        match conn.exec_bound(sql!(
1363                            INSERT INTO breakpoints (workspace_id, path, breakpoint_location,  log_message, condition, hit_condition, state)
1364                            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);))?
1365
1366                        ((
1367                            workspace.id,
1368                            path.as_ref(),
1369                            bp.row,
1370                            bp.message,
1371                            bp.condition,
1372                            bp.hit_condition,
1373                            state,
1374                        )) {
1375                            Ok(_) => {
1376                                log::debug!("Stored breakpoint at row: {} in path: {}", bp.row, path.to_string_lossy())
1377                            }
1378                            Err(err) => {
1379                                log::error!("{err}");
1380                                continue;
1381                            }
1382                        }
1383                    }
1384                }
1385
1386                conn.exec_bound(
1387                    sql!(
1388                        DELETE FROM user_toolchains WHERE workspace_id = ?1;
1389                    )
1390                )?(workspace.id).context("Clearing old user toolchains")?;
1391
1392                for (scope, toolchains) in workspace.user_toolchains {
1393                    for toolchain in toolchains {
1394                        let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
1395                        let (workspace_id, worktree_root_path, relative_worktree_path) = match scope {
1396                            ToolchainScope::Subproject(ref worktree_root_path, ref path) => (Some(workspace.id), Some(worktree_root_path.to_string_lossy().into_owned()), Some(path.as_unix_str().to_owned())),
1397                            ToolchainScope::Project => (Some(workspace.id), None, None),
1398                            ToolchainScope::Global => (None, None, None),
1399                        };
1400                        let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), relative_worktree_path.unwrap_or_default(),
1401                        toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string());
1402                        if let Err(err) = conn.exec_bound(query)?(args) {
1403                            log::error!("{err}");
1404                            continue;
1405                        }
1406                    }
1407                }
1408
1409                // Clear out old workspaces with the same paths.
1410                // Skip this for empty workspaces - they are identified by workspace_id, not paths.
1411                // Multiple empty workspaces with different content should coexist.
1412                if !paths.paths.is_empty() {
1413                    conn.exec_bound(sql!(
1414                        DELETE
1415                        FROM workspaces
1416                        WHERE
1417                            workspace_id != ?1 AND
1418                            paths IS ?2 AND
1419                            remote_connection_id IS ?3
1420                    ))?((
1421                        workspace.id,
1422                        paths.paths.clone(),
1423                        remote_connection_id,
1424                    ))
1425                    .context("clearing out old locations")?;
1426                }
1427
1428                // Upsert
1429                let query = sql!(
1430                    INSERT INTO workspaces(
1431                        workspace_id,
1432                        paths,
1433                        paths_order,
1434                        remote_connection_id,
1435                        left_dock_visible,
1436                        left_dock_active_panel,
1437                        left_dock_zoom,
1438                        right_dock_visible,
1439                        right_dock_active_panel,
1440                        right_dock_zoom,
1441                        bottom_dock_visible,
1442                        bottom_dock_active_panel,
1443                        bottom_dock_zoom,
1444                        session_id,
1445                        window_id,
1446                        timestamp
1447                    )
1448                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, CURRENT_TIMESTAMP)
1449                    ON CONFLICT DO
1450                    UPDATE SET
1451                        paths = ?2,
1452                        paths_order = ?3,
1453                        remote_connection_id = ?4,
1454                        left_dock_visible = ?5,
1455                        left_dock_active_panel = ?6,
1456                        left_dock_zoom = ?7,
1457                        right_dock_visible = ?8,
1458                        right_dock_active_panel = ?9,
1459                        right_dock_zoom = ?10,
1460                        bottom_dock_visible = ?11,
1461                        bottom_dock_active_panel = ?12,
1462                        bottom_dock_zoom = ?13,
1463                        session_id = ?14,
1464                        window_id = ?15,
1465                        timestamp = CURRENT_TIMESTAMP
1466                );
1467                let mut prepared_query = conn.exec_bound(query)?;
1468                let args = (
1469                    workspace.id,
1470                    paths.paths.clone(),
1471                    paths.order.clone(),
1472                    remote_connection_id,
1473                    workspace.docks,
1474                    workspace.session_id,
1475                    workspace.window_id,
1476                );
1477
1478                prepared_query(args).context("Updating workspace")?;
1479
1480                // Save center pane group
1481                Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
1482                    .context("save pane group in save workspace")?;
1483
1484                Ok(())
1485            })
1486            .log_err();
1487        })
1488        .await;
1489    }
1490
1491    pub(crate) async fn get_or_create_remote_connection(
1492        &self,
1493        options: RemoteConnectionOptions,
1494    ) -> Result<RemoteConnectionId> {
1495        self.write(move |conn| Self::get_or_create_remote_connection_internal(conn, options))
1496            .await
1497    }
1498
1499    fn get_or_create_remote_connection_internal(
1500        this: &Connection,
1501        options: RemoteConnectionOptions,
1502    ) -> Result<RemoteConnectionId> {
1503        let kind;
1504        let user: Option<String>;
1505        let mut host = None;
1506        let mut port = None;
1507        let mut distro = None;
1508        let mut name = None;
1509        let mut container_id = None;
1510        let mut use_podman = None;
1511        let mut remote_env = None;
1512        match options {
1513            RemoteConnectionOptions::Ssh(options) => {
1514                kind = RemoteConnectionKind::Ssh;
1515                host = Some(options.host.to_string());
1516                port = options.port;
1517                user = options.username;
1518            }
1519            RemoteConnectionOptions::Wsl(options) => {
1520                kind = RemoteConnectionKind::Wsl;
1521                distro = Some(options.distro_name);
1522                user = options.user;
1523            }
1524            RemoteConnectionOptions::Docker(options) => {
1525                kind = RemoteConnectionKind::Docker;
1526                container_id = Some(options.container_id);
1527                name = Some(options.name);
1528                use_podman = Some(options.use_podman);
1529                user = Some(options.remote_user);
1530                remote_env = serde_json::to_string(&options.remote_env).ok();
1531            }
1532            #[cfg(any(test, feature = "test-support"))]
1533            RemoteConnectionOptions::Mock(options) => {
1534                kind = RemoteConnectionKind::Ssh;
1535                host = Some(format!("mock-{}", options.id));
1536                user = Some(format!("mock-user-{}", options.id));
1537            }
1538        }
1539        Self::get_or_create_remote_connection_query(
1540            this,
1541            kind,
1542            host,
1543            port,
1544            user,
1545            distro,
1546            name,
1547            container_id,
1548            use_podman,
1549            remote_env,
1550        )
1551    }
1552
1553    fn get_or_create_remote_connection_query(
1554        this: &Connection,
1555        kind: RemoteConnectionKind,
1556        host: Option<String>,
1557        port: Option<u16>,
1558        user: Option<String>,
1559        distro: Option<String>,
1560        name: Option<String>,
1561        container_id: Option<String>,
1562        use_podman: Option<bool>,
1563        remote_env: Option<String>,
1564    ) -> Result<RemoteConnectionId> {
1565        if let Some(id) = this.select_row_bound(sql!(
1566            SELECT id
1567            FROM remote_connections
1568            WHERE
1569                kind IS ? AND
1570                host IS ? AND
1571                port IS ? AND
1572                user IS ? AND
1573                distro IS ? AND
1574                name IS ? AND
1575                container_id IS ?
1576            LIMIT 1
1577        ))?((
1578            kind.serialize(),
1579            host.clone(),
1580            port,
1581            user.clone(),
1582            distro.clone(),
1583            name.clone(),
1584            container_id.clone(),
1585        ))? {
1586            Ok(RemoteConnectionId(id))
1587        } else {
1588            let id = this.select_row_bound(sql!(
1589                INSERT INTO remote_connections (
1590                    kind,
1591                    host,
1592                    port,
1593                    user,
1594                    distro,
1595                    name,
1596                    container_id,
1597                    use_podman,
1598                    remote_env
1599                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1600                RETURNING id
1601            ))?((
1602                kind.serialize(),
1603                host,
1604                port,
1605                user,
1606                distro,
1607                name,
1608                container_id,
1609                use_podman,
1610                remote_env,
1611            ))?
1612            .context("failed to insert remote project")?;
1613            Ok(RemoteConnectionId(id))
1614        }
1615    }
1616
1617    query! {
1618        pub async fn next_id() -> Result<WorkspaceId> {
1619            INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
1620        }
1621    }
1622
1623    fn recent_workspaces(
1624        &self,
1625    ) -> Result<
1626        Vec<(
1627            WorkspaceId,
1628            PathList,
1629            Option<RemoteConnectionId>,
1630            DateTime<Utc>,
1631        )>,
1632    > {
1633        Ok(self
1634            .recent_workspaces_query()?
1635            .into_iter()
1636            .map(|(id, paths, order, remote_connection_id, timestamp)| {
1637                (
1638                    id,
1639                    PathList::deserialize(&SerializedPathList { paths, order }),
1640                    remote_connection_id.map(RemoteConnectionId),
1641                    parse_timestamp(&timestamp),
1642                )
1643            })
1644            .collect())
1645    }
1646
1647    query! {
1648        fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, String)>> {
1649            SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp
1650            FROM workspaces
1651            WHERE
1652                paths IS NOT NULL OR
1653                remote_connection_id IS NOT NULL
1654            ORDER BY timestamp DESC
1655        }
1656    }
1657
1658    fn session_workspaces(
1659        &self,
1660        session_id: String,
1661    ) -> Result<
1662        Vec<(
1663            WorkspaceId,
1664            PathList,
1665            Option<u64>,
1666            Option<RemoteConnectionId>,
1667        )>,
1668    > {
1669        Ok(self
1670            .session_workspaces_query(session_id)?
1671            .into_iter()
1672            .map(
1673                |(workspace_id, paths, order, window_id, remote_connection_id)| {
1674                    (
1675                        WorkspaceId(workspace_id),
1676                        PathList::deserialize(&SerializedPathList { paths, order }),
1677                        window_id,
1678                        remote_connection_id.map(RemoteConnectionId),
1679                    )
1680                },
1681            )
1682            .collect())
1683    }
1684
1685    query! {
1686        fn session_workspaces_query(session_id: String) -> Result<Vec<(i64, String, String, Option<u64>, Option<u64>)>> {
1687            SELECT workspace_id, paths, paths_order, window_id, remote_connection_id
1688            FROM workspaces
1689            WHERE session_id = ?1
1690            ORDER BY timestamp DESC
1691        }
1692    }
1693
1694    query! {
1695        pub fn breakpoints_for_file(workspace_id: WorkspaceId, file_path: &Path) -> Result<Vec<Breakpoint>> {
1696            SELECT breakpoint_location
1697            FROM breakpoints
1698            WHERE  workspace_id= ?1 AND path = ?2
1699        }
1700    }
1701
1702    query! {
1703        pub fn clear_breakpoints(file_path: &Path) -> Result<()> {
1704            DELETE FROM breakpoints
1705            WHERE file_path = ?2
1706        }
1707    }
1708
1709    fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
1710        Ok(self.select(sql!(
1711            SELECT
1712                id, kind, host, port, user, distro, container_id, name, use_podman, remote_env
1713            FROM
1714                remote_connections
1715        ))?()?
1716        .into_iter()
1717        .filter_map(
1718            |(id, kind, host, port, user, distro, container_id, name, use_podman, remote_env)| {
1719                Some((
1720                    RemoteConnectionId(id),
1721                    Self::remote_connection_from_row(
1722                        kind,
1723                        host,
1724                        port,
1725                        user,
1726                        distro,
1727                        container_id,
1728                        name,
1729                        use_podman,
1730                        remote_env,
1731                    )?,
1732                ))
1733            },
1734        )
1735        .collect())
1736    }
1737
1738    pub(crate) fn remote_connection(
1739        &self,
1740        id: RemoteConnectionId,
1741    ) -> Result<RemoteConnectionOptions> {
1742        let (kind, host, port, user, distro, container_id, name, use_podman, remote_env) =
1743            self.select_row_bound(sql!(
1744                SELECT kind, host, port, user, distro, container_id, name, use_podman, remote_env
1745                FROM remote_connections
1746                WHERE id = ?
1747            ))?(id.0)?
1748            .context("no such remote connection")?;
1749        Self::remote_connection_from_row(
1750            kind,
1751            host,
1752            port,
1753            user,
1754            distro,
1755            container_id,
1756            name,
1757            use_podman,
1758            remote_env,
1759        )
1760        .context("invalid remote_connection row")
1761    }
1762
1763    fn remote_connection_from_row(
1764        kind: String,
1765        host: Option<String>,
1766        port: Option<u16>,
1767        user: Option<String>,
1768        distro: Option<String>,
1769        container_id: Option<String>,
1770        name: Option<String>,
1771        use_podman: Option<bool>,
1772        remote_env: Option<String>,
1773    ) -> Option<RemoteConnectionOptions> {
1774        match RemoteConnectionKind::deserialize(&kind)? {
1775            RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
1776                distro_name: distro?,
1777                user: user,
1778            })),
1779            RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions {
1780                host: host?.into(),
1781                port,
1782                username: user,
1783                ..Default::default()
1784            })),
1785            RemoteConnectionKind::Docker => {
1786                let remote_env: BTreeMap<String, String> =
1787                    serde_json::from_str(&remote_env?).ok()?;
1788                Some(RemoteConnectionOptions::Docker(DockerConnectionOptions {
1789                    container_id: container_id?,
1790                    name: name?,
1791                    remote_user: user?,
1792                    upload_binary_over_docker_exec: false,
1793                    use_podman: use_podman?,
1794                    remote_env,
1795                }))
1796            }
1797        }
1798    }
1799
1800    query! {
1801        pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
1802            DELETE FROM workspaces
1803            WHERE workspace_id IS ?
1804        }
1805    }
1806
1807    async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
1808        let mut any_dir = false;
1809        for path in paths {
1810            match fs.metadata(path).await.ok().flatten() {
1811                None => {
1812                    return false;
1813                }
1814                Some(meta) => {
1815                    if meta.is_dir {
1816                        any_dir = true;
1817                    }
1818                }
1819            }
1820        }
1821        any_dir
1822    }
1823
1824    // Returns the recent locations which are still valid on disk and deletes ones which no longer
1825    // exist.
1826    pub async fn recent_workspaces_on_disk(
1827        &self,
1828        fs: &dyn Fs,
1829    ) -> Result<
1830        Vec<(
1831            WorkspaceId,
1832            SerializedWorkspaceLocation,
1833            PathList,
1834            DateTime<Utc>,
1835        )>,
1836    > {
1837        let mut result = Vec::new();
1838        let mut workspaces_to_delete = Vec::new();
1839        let remote_connections = self.remote_connections()?;
1840        let now = Utc::now();
1841        for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? {
1842            if let Some(remote_connection_id) = remote_connection_id {
1843                if let Some(connection_options) = remote_connections.get(&remote_connection_id) {
1844                    result.push((
1845                        id,
1846                        SerializedWorkspaceLocation::Remote(connection_options.clone()),
1847                        paths,
1848                        timestamp,
1849                    ));
1850                } else {
1851                    workspaces_to_delete.push(id);
1852                }
1853                continue;
1854            }
1855
1856            // Delete the workspace if any of the paths are WSL paths. If a
1857            // local workspace points to WSL, attempting to read its metadata
1858            // will wait for the WSL VM and file server to boot up. This can
1859            // block for many seconds. Supported scenarios use remote
1860            // workspaces.
1861            if cfg!(windows) {
1862                let has_wsl_path = paths
1863                    .paths()
1864                    .iter()
1865                    .any(|path| util::paths::WslPath::from_path(path).is_some());
1866                if has_wsl_path {
1867                    workspaces_to_delete.push(id);
1868                    continue;
1869                }
1870            }
1871
1872            if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
1873                result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
1874            } else if now - timestamp >= chrono::Duration::days(7) {
1875                workspaces_to_delete.push(id);
1876            }
1877        }
1878
1879        futures::future::join_all(
1880            workspaces_to_delete
1881                .into_iter()
1882                .map(|id| self.delete_workspace_by_id(id)),
1883        )
1884        .await;
1885        Ok(result)
1886    }
1887
1888    pub async fn last_workspace(
1889        &self,
1890        fs: &dyn Fs,
1891    ) -> Result<
1892        Option<(
1893            WorkspaceId,
1894            SerializedWorkspaceLocation,
1895            PathList,
1896            DateTime<Utc>,
1897        )>,
1898    > {
1899        Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
1900    }
1901
1902    // Returns the locations of the workspaces that were still opened when the last
1903    // session was closed (i.e. when Zed was quit).
1904    // If `last_session_window_order` is provided, the returned locations are ordered
1905    // according to that.
1906    pub async fn last_session_workspace_locations(
1907        &self,
1908        last_session_id: &str,
1909        last_session_window_stack: Option<Vec<WindowId>>,
1910        fs: &dyn Fs,
1911    ) -> Result<Vec<SessionWorkspace>> {
1912        let mut workspaces = Vec::new();
1913
1914        for (workspace_id, paths, window_id, remote_connection_id) in
1915            self.session_workspaces(last_session_id.to_owned())?
1916        {
1917            let window_id = window_id.map(WindowId::from);
1918
1919            if let Some(remote_connection_id) = remote_connection_id {
1920                workspaces.push(SessionWorkspace {
1921                    workspace_id,
1922                    location: SerializedWorkspaceLocation::Remote(
1923                        self.remote_connection(remote_connection_id)?,
1924                    ),
1925                    paths,
1926                    window_id,
1927                });
1928            } else if paths.is_empty() {
1929                // Empty workspace with items (drafts, files) - include for restoration
1930                workspaces.push(SessionWorkspace {
1931                    workspace_id,
1932                    location: SerializedWorkspaceLocation::Local,
1933                    paths,
1934                    window_id,
1935                });
1936            } else {
1937                if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
1938                    workspaces.push(SessionWorkspace {
1939                        workspace_id,
1940                        location: SerializedWorkspaceLocation::Local,
1941                        paths,
1942                        window_id,
1943                    });
1944                }
1945            }
1946        }
1947
1948        if let Some(stack) = last_session_window_stack {
1949            workspaces.sort_by_key(|workspace| {
1950                workspace
1951                    .window_id
1952                    .and_then(|id| stack.iter().position(|&order_id| order_id == id))
1953                    .unwrap_or(usize::MAX)
1954            });
1955        }
1956
1957        Ok(workspaces)
1958    }
1959
1960    fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
1961        Ok(self
1962            .get_pane_group(workspace_id, None)?
1963            .into_iter()
1964            .next()
1965            .unwrap_or_else(|| {
1966                SerializedPaneGroup::Pane(SerializedPane {
1967                    active: true,
1968                    children: vec![],
1969                    pinned_count: 0,
1970                })
1971            }))
1972    }
1973
1974    fn get_pane_group(
1975        &self,
1976        workspace_id: WorkspaceId,
1977        group_id: Option<GroupId>,
1978    ) -> Result<Vec<SerializedPaneGroup>> {
1979        type GroupKey = (Option<GroupId>, WorkspaceId);
1980        type GroupOrPane = (
1981            Option<GroupId>,
1982            Option<SerializedAxis>,
1983            Option<PaneId>,
1984            Option<bool>,
1985            Option<usize>,
1986            Option<String>,
1987        );
1988        self.select_bound::<GroupKey, GroupOrPane>(sql!(
1989            SELECT group_id, axis, pane_id, active, pinned_count, flexes
1990                FROM (SELECT
1991                        group_id,
1992                        axis,
1993                        NULL as pane_id,
1994                        NULL as active,
1995                        NULL as pinned_count,
1996                        position,
1997                        parent_group_id,
1998                        workspace_id,
1999                        flexes
2000                      FROM pane_groups
2001                    UNION
2002                      SELECT
2003                        NULL,
2004                        NULL,
2005                        center_panes.pane_id,
2006                        panes.active as active,
2007                        pinned_count,
2008                        position,
2009                        parent_group_id,
2010                        panes.workspace_id as workspace_id,
2011                        NULL
2012                      FROM center_panes
2013                      JOIN panes ON center_panes.pane_id = panes.pane_id)
2014                WHERE parent_group_id IS ? AND workspace_id = ?
2015                ORDER BY position
2016        ))?((group_id, workspace_id))?
2017        .into_iter()
2018        .map(|(group_id, axis, pane_id, active, pinned_count, flexes)| {
2019            let maybe_pane = maybe!({ Some((pane_id?, active?, pinned_count?)) });
2020            if let Some((group_id, axis)) = group_id.zip(axis) {
2021                let flexes = flexes
2022                    .map(|flexes: String| serde_json::from_str::<Vec<f32>>(&flexes))
2023                    .transpose()?;
2024
2025                Ok(SerializedPaneGroup::Group {
2026                    axis,
2027                    children: self.get_pane_group(workspace_id, Some(group_id))?,
2028                    flexes,
2029                })
2030            } else if let Some((pane_id, active, pinned_count)) = maybe_pane {
2031                Ok(SerializedPaneGroup::Pane(SerializedPane::new(
2032                    self.get_items(pane_id)?,
2033                    active,
2034                    pinned_count,
2035                )))
2036            } else {
2037                bail!("Pane Group Child was neither a pane group or a pane");
2038            }
2039        })
2040        // Filter out panes and pane groups which don't have any children or items
2041        .filter(|pane_group| match pane_group {
2042            Ok(SerializedPaneGroup::Group { children, .. }) => !children.is_empty(),
2043            Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
2044            _ => true,
2045        })
2046        .collect::<Result<_>>()
2047    }
2048
2049    fn save_pane_group(
2050        conn: &Connection,
2051        workspace_id: WorkspaceId,
2052        pane_group: &SerializedPaneGroup,
2053        parent: Option<(GroupId, usize)>,
2054    ) -> Result<()> {
2055        if parent.is_none() {
2056            log::debug!("Saving a pane group for workspace {workspace_id:?}");
2057        }
2058        match pane_group {
2059            SerializedPaneGroup::Group {
2060                axis,
2061                children,
2062                flexes,
2063            } => {
2064                let (parent_id, position) = parent.unzip();
2065
2066                let flex_string = flexes
2067                    .as_ref()
2068                    .map(|flexes| serde_json::json!(flexes).to_string());
2069
2070                let group_id = conn.select_row_bound::<_, i64>(sql!(
2071                    INSERT INTO pane_groups(
2072                        workspace_id,
2073                        parent_group_id,
2074                        position,
2075                        axis,
2076                        flexes
2077                    )
2078                    VALUES (?, ?, ?, ?, ?)
2079                    RETURNING group_id
2080                ))?((
2081                    workspace_id,
2082                    parent_id,
2083                    position,
2084                    *axis,
2085                    flex_string,
2086                ))?
2087                .context("Couldn't retrieve group_id from inserted pane_group")?;
2088
2089                for (position, group) in children.iter().enumerate() {
2090                    Self::save_pane_group(conn, workspace_id, group, Some((group_id, position)))?
2091                }
2092
2093                Ok(())
2094            }
2095            SerializedPaneGroup::Pane(pane) => {
2096                Self::save_pane(conn, workspace_id, pane, parent)?;
2097                Ok(())
2098            }
2099        }
2100    }
2101
2102    fn save_pane(
2103        conn: &Connection,
2104        workspace_id: WorkspaceId,
2105        pane: &SerializedPane,
2106        parent: Option<(GroupId, usize)>,
2107    ) -> Result<PaneId> {
2108        let pane_id = conn.select_row_bound::<_, i64>(sql!(
2109            INSERT INTO panes(workspace_id, active, pinned_count)
2110            VALUES (?, ?, ?)
2111            RETURNING pane_id
2112        ))?((workspace_id, pane.active, pane.pinned_count))?
2113        .context("Could not retrieve inserted pane_id")?;
2114
2115        let (parent_id, order) = parent.unzip();
2116        conn.exec_bound(sql!(
2117            INSERT INTO center_panes(pane_id, parent_group_id, position)
2118            VALUES (?, ?, ?)
2119        ))?((pane_id, parent_id, order))?;
2120
2121        Self::save_items(conn, workspace_id, pane_id, &pane.children).context("Saving items")?;
2122
2123        Ok(pane_id)
2124    }
2125
2126    fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
2127        self.select_bound(sql!(
2128            SELECT kind, item_id, active, preview FROM items
2129            WHERE pane_id = ?
2130                ORDER BY position
2131        ))?(pane_id)
2132    }
2133
2134    fn save_items(
2135        conn: &Connection,
2136        workspace_id: WorkspaceId,
2137        pane_id: PaneId,
2138        items: &[SerializedItem],
2139    ) -> Result<()> {
2140        let mut insert = conn.exec_bound(sql!(
2141            INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active, preview) VALUES (?, ?, ?, ?, ?, ?, ?)
2142        )).context("Preparing insertion")?;
2143        for (position, item) in items.iter().enumerate() {
2144            insert((workspace_id, pane_id, position, item))?;
2145        }
2146
2147        Ok(())
2148    }
2149
2150    query! {
2151        pub async fn update_timestamp(workspace_id: WorkspaceId) -> Result<()> {
2152            UPDATE workspaces
2153            SET timestamp = CURRENT_TIMESTAMP
2154            WHERE workspace_id = ?
2155        }
2156    }
2157
2158    query! {
2159        pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> {
2160            UPDATE workspaces
2161            SET window_state = ?2,
2162                window_x = ?3,
2163                window_y = ?4,
2164                window_width = ?5,
2165                window_height = ?6,
2166                display = ?7
2167            WHERE workspace_id = ?1
2168        }
2169    }
2170
2171    query! {
2172        pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> {
2173            UPDATE workspaces
2174            SET centered_layout = ?2
2175            WHERE workspace_id = ?1
2176        }
2177    }
2178
2179    query! {
2180        pub(crate) async fn set_session_id(workspace_id: WorkspaceId, session_id: Option<String>) -> Result<()> {
2181            UPDATE workspaces
2182            SET session_id = ?2
2183            WHERE workspace_id = ?1
2184        }
2185    }
2186
2187    query! {
2188        pub(crate) async fn set_session_binding(workspace_id: WorkspaceId, session_id: Option<String>, window_id: Option<u64>) -> Result<()> {
2189            UPDATE workspaces
2190            SET session_id = ?2, window_id = ?3
2191            WHERE workspace_id = ?1
2192        }
2193    }
2194
2195    pub(crate) async fn toolchains(
2196        &self,
2197        workspace_id: WorkspaceId,
2198    ) -> Result<Vec<(Toolchain, Arc<Path>, Arc<RelPath>)>> {
2199        self.write(move |this| {
2200            let mut select = this
2201                .select_bound(sql!(
2202                    SELECT
2203                        name, path, worktree_root_path, relative_worktree_path, language_name, raw_json
2204                    FROM toolchains
2205                    WHERE workspace_id = ?
2206                ))
2207                .context("select toolchains")?;
2208
2209            let toolchain: Vec<(String, String, String, String, String, String)> =
2210                select(workspace_id)?;
2211
2212            Ok(toolchain
2213                .into_iter()
2214                .filter_map(
2215                    |(name, path, worktree_root_path, relative_worktree_path, language, json)| {
2216                        Some((
2217                            Toolchain {
2218                                name: name.into(),
2219                                path: path.into(),
2220                                language_name: LanguageName::new(&language),
2221                                as_json: serde_json::Value::from_str(&json).ok()?,
2222                            },
2223                           Arc::from(worktree_root_path.as_ref()),
2224                            RelPath::from_proto(&relative_worktree_path).log_err()?,
2225                        ))
2226                    },
2227                )
2228                .collect())
2229        })
2230        .await
2231    }
2232
2233    pub async fn set_toolchain(
2234        &self,
2235        workspace_id: WorkspaceId,
2236        worktree_root_path: Arc<Path>,
2237        relative_worktree_path: Arc<RelPath>,
2238        toolchain: Toolchain,
2239    ) -> Result<()> {
2240        log::debug!(
2241            "Setting toolchain for workspace, worktree: {worktree_root_path:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
2242            toolchain.name
2243        );
2244        self.write(move |conn| {
2245            let mut insert = conn
2246                .exec_bound(sql!(
2247                    INSERT INTO toolchains(workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?,  ?, ?)
2248                    ON CONFLICT DO
2249                    UPDATE SET
2250                        name = ?5,
2251                        path = ?6,
2252                        raw_json = ?7
2253                ))
2254                .context("Preparing insertion")?;
2255
2256            insert((
2257                workspace_id,
2258                worktree_root_path.to_string_lossy().into_owned(),
2259                relative_worktree_path.as_unix_str(),
2260                toolchain.language_name.as_ref(),
2261                toolchain.name.as_ref(),
2262                toolchain.path.as_ref(),
2263                toolchain.as_json.to_string(),
2264            ))?;
2265
2266            Ok(())
2267        }).await
2268    }
2269
2270    pub(crate) async fn save_trusted_worktrees(
2271        &self,
2272        trusted_worktrees: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>>,
2273    ) -> anyhow::Result<()> {
2274        use anyhow::Context as _;
2275        use db::sqlez::statement::Statement;
2276        use itertools::Itertools as _;
2277
2278        self.clear_trusted_worktrees()
2279            .await
2280            .context("clearing previous trust state")?;
2281
2282        let trusted_worktrees = trusted_worktrees
2283            .into_iter()
2284            .flat_map(|(host, abs_paths)| {
2285                abs_paths
2286                    .into_iter()
2287                    .map(move |abs_path| (Some(abs_path), host.clone()))
2288            })
2289            .collect::<Vec<_>>();
2290        let mut first_worktree;
2291        let mut last_worktree = 0_usize;
2292        for (count, placeholders) in std::iter::once("(?, ?, ?)")
2293            .cycle()
2294            .take(trusted_worktrees.len())
2295            .chunks(MAX_QUERY_PLACEHOLDERS / 3)
2296            .into_iter()
2297            .map(|chunk| {
2298                let mut count = 0;
2299                let placeholders = chunk
2300                    .inspect(|_| {
2301                        count += 1;
2302                    })
2303                    .join(", ");
2304                (count, placeholders)
2305            })
2306            .collect::<Vec<_>>()
2307        {
2308            first_worktree = last_worktree;
2309            last_worktree = last_worktree + count;
2310            let query = format!(
2311                r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name)
2312VALUES {placeholders};"#
2313            );
2314
2315            let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec();
2316            self.write(move |conn| {
2317                let mut statement = Statement::prepare(conn, query)?;
2318                let mut next_index = 1;
2319                for (abs_path, host) in trusted_worktrees {
2320                    let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy());
2321                    next_index = statement.bind(
2322                        &abs_path.as_ref().map(|abs_path| abs_path.as_ref()),
2323                        next_index,
2324                    )?;
2325                    next_index = statement.bind(
2326                        &host
2327                            .as_ref()
2328                            .and_then(|host| Some(host.user_name.as_ref()?.as_str())),
2329                        next_index,
2330                    )?;
2331                    next_index = statement.bind(
2332                        &host.as_ref().map(|host| host.host_identifier.as_str()),
2333                        next_index,
2334                    )?;
2335                }
2336                statement.exec()
2337            })
2338            .await
2339            .context("inserting new trusted state")?;
2340        }
2341        Ok(())
2342    }
2343
2344    pub fn fetch_trusted_worktrees(&self) -> Result<DbTrustedPaths> {
2345        let trusted_worktrees = self.trusted_worktrees()?;
2346        Ok(trusted_worktrees
2347            .into_iter()
2348            .filter_map(|(abs_path, user_name, host_name)| {
2349                let db_host = match (user_name, host_name) {
2350                    (None, Some(host_name)) => Some(RemoteHostLocation {
2351                        user_name: None,
2352                        host_identifier: SharedString::new(host_name),
2353                    }),
2354                    (Some(user_name), Some(host_name)) => Some(RemoteHostLocation {
2355                        user_name: Some(SharedString::new(user_name)),
2356                        host_identifier: SharedString::new(host_name),
2357                    }),
2358                    _ => None,
2359                };
2360                Some((db_host, abs_path?))
2361            })
2362            .fold(HashMap::default(), |mut acc, (remote_host, abs_path)| {
2363                acc.entry(remote_host)
2364                    .or_insert_with(HashSet::default)
2365                    .insert(abs_path);
2366                acc
2367            }))
2368    }
2369
2370    query! {
2371        fn trusted_worktrees() -> Result<Vec<(Option<PathBuf>, Option<String>, Option<String>)>> {
2372            SELECT absolute_path, user_name, host_name
2373            FROM trusted_worktrees
2374        }
2375    }
2376
2377    query! {
2378        pub async fn clear_trusted_worktrees() -> Result<()> {
2379            DELETE FROM trusted_worktrees
2380        }
2381    }
2382}
2383
2384type WorkspaceEntry = (
2385    WorkspaceId,
2386    SerializedWorkspaceLocation,
2387    PathList,
2388    DateTime<Utc>,
2389);
2390
2391/// Resolves workspace entries whose paths are git linked worktree checkouts
2392/// to their main repository paths.
2393///
2394/// For each workspace entry:
2395/// - If any path is a linked worktree checkout, all worktree paths in that
2396///   entry are resolved to their main repository paths, producing a new
2397///   `PathList`.
2398/// - The resolved entry is then deduplicated against existing entries: if a
2399///   workspace with the same paths already exists, the entry with the most
2400///   recent timestamp is kept.
2401pub async fn resolve_worktree_workspaces(
2402    workspaces: impl IntoIterator<Item = WorkspaceEntry>,
2403    fs: &dyn Fs,
2404) -> Vec<WorkspaceEntry> {
2405    // First pass: resolve worktree paths to main repo paths concurrently.
2406    let resolved = futures::future::join_all(workspaces.into_iter().map(|entry| async move {
2407        let paths = entry.2.paths();
2408        if paths.is_empty() {
2409            return entry;
2410        }
2411
2412        // Resolve each path concurrently
2413        let resolved_paths = futures::future::join_all(
2414            paths
2415                .iter()
2416                .map(|path| project::git_store::resolve_git_worktree_to_main_repo(fs, path)),
2417        )
2418        .await;
2419
2420        // If no paths were resolved, this entry is not a worktree — keep as-is
2421        if resolved_paths.iter().all(|r| r.is_none()) {
2422            return entry;
2423        }
2424
2425        // Build new path list, substituting resolved paths
2426        let new_paths: Vec<PathBuf> = paths
2427            .iter()
2428            .zip(resolved_paths.iter())
2429            .map(|(original, resolved)| {
2430                resolved
2431                    .as_ref()
2432                    .cloned()
2433                    .unwrap_or_else(|| original.clone())
2434            })
2435            .collect();
2436
2437        let new_path_refs: Vec<&Path> = new_paths.iter().map(|p| p.as_path()).collect();
2438        (entry.0, entry.1, PathList::new(&new_path_refs), entry.3)
2439    }))
2440    .await;
2441
2442    // Second pass: deduplicate by PathList.
2443    // When two entries resolve to the same paths, keep the one with the
2444    // more recent timestamp.
2445    let mut seen: collections::HashMap<Vec<PathBuf>, usize> = collections::HashMap::default();
2446    let mut result: Vec<WorkspaceEntry> = Vec::new();
2447
2448    for entry in resolved {
2449        let key: Vec<PathBuf> = entry.2.paths().to_vec();
2450        if let Some(&existing_idx) = seen.get(&key) {
2451            // Keep the entry with the more recent timestamp
2452            if entry.3 > result[existing_idx].3 {
2453                result[existing_idx] = entry;
2454            }
2455        } else {
2456            seen.insert(key, result.len());
2457            result.push(entry);
2458        }
2459    }
2460
2461    result
2462}
2463
2464pub fn delete_unloaded_items(
2465    alive_items: Vec<ItemId>,
2466    workspace_id: WorkspaceId,
2467    table: &'static str,
2468    db: &ThreadSafeConnection,
2469    cx: &mut App,
2470) -> Task<Result<()>> {
2471    let db = db.clone();
2472    cx.spawn(async move |_| {
2473        let placeholders = alive_items
2474            .iter()
2475            .map(|_| "?")
2476            .collect::<Vec<&str>>()
2477            .join(", ");
2478
2479        let query = format!(
2480            "DELETE FROM {table} WHERE workspace_id = ? AND item_id NOT IN ({placeholders})"
2481        );
2482
2483        db.write(move |conn| {
2484            let mut statement = Statement::prepare(conn, query)?;
2485            let mut next_index = statement.bind(&workspace_id, 1)?;
2486            for id in alive_items {
2487                next_index = statement.bind(&id, next_index)?;
2488            }
2489            statement.exec()
2490        })
2491        .await
2492    })
2493}
2494
2495#[cfg(test)]
2496mod tests {
2497    use super::*;
2498    use crate::ProjectGroupKey;
2499    use crate::{
2500        multi_workspace::MultiWorkspace,
2501        persistence::{
2502            model::{
2503                SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
2504                SessionWorkspace,
2505            },
2506            read_multi_workspace_state,
2507        },
2508    };
2509    use feature_flags::FeatureFlagAppExt;
2510    use gpui::AppContext as _;
2511    use pretty_assertions::assert_eq;
2512    use project::Project;
2513    use remote::SshConnectionOptions;
2514    use serde_json::json;
2515    use std::{thread, time::Duration};
2516
2517    /// Creates a unique directory in a FakeFs, returning the path.
2518    /// Uses a UUID suffix to avoid collisions with other tests sharing the global DB.
2519    async fn unique_test_dir(fs: &fs::FakeFs, prefix: &str) -> PathBuf {
2520        let dir = PathBuf::from(format!("/test-dirs/{}-{}", prefix, uuid::Uuid::new_v4()));
2521        fs.insert_tree(&dir, json!({})).await;
2522        dir
2523    }
2524
2525    #[gpui::test]
2526    async fn test_multi_workspace_serializes_on_add_and_remove(cx: &mut gpui::TestAppContext) {
2527        crate::tests::init_test(cx);
2528
2529        cx.update(|cx| {
2530            cx.set_staff(true);
2531        });
2532
2533        let fs = fs::FakeFs::new(cx.executor());
2534        let project1 = Project::test(fs.clone(), [], cx).await;
2535        let project2 = Project::test(fs.clone(), [], cx).await;
2536
2537        let (multi_workspace, cx) =
2538            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
2539
2540        multi_workspace.update(cx, |mw, cx| {
2541            mw.open_sidebar(cx);
2542        });
2543
2544        multi_workspace.update_in(cx, |mw, _, cx| {
2545            mw.set_random_database_id(cx);
2546        });
2547
2548        let window_id =
2549            multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
2550
2551        // --- Add a second workspace ---
2552        let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
2553            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
2554            workspace.update(cx, |ws, _cx| ws.set_random_database_id());
2555            mw.activate(workspace.clone(), window, cx);
2556            workspace
2557        });
2558
2559        // Run background tasks so serialize has a chance to flush.
2560        cx.run_until_parked();
2561
2562        // Read back the persisted state and check that the active workspace ID was written.
2563        let state_after_add = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
2564        let active_workspace2_db_id = workspace2.read_with(cx, |ws, _| ws.database_id());
2565        assert_eq!(
2566            state_after_add.active_workspace_id, active_workspace2_db_id,
2567            "After adding a second workspace, the serialized active_workspace_id should match \
2568             the newly activated workspace's database id"
2569        );
2570
2571        // --- Remove the non-active workspace ---
2572        multi_workspace.update_in(cx, |mw, _window, cx| {
2573            let active = mw.workspace().clone();
2574            let ws = mw
2575                .workspaces()
2576                .find(|ws| *ws != &active)
2577                .expect("should have a non-active workspace");
2578            mw.remove([ws.clone()], |_, _, _| unreachable!(), _window, cx)
2579                .detach_and_log_err(cx);
2580        });
2581
2582        cx.run_until_parked();
2583
2584        let state_after_remove = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
2585        let remaining_db_id =
2586            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
2587        assert_eq!(
2588            state_after_remove.active_workspace_id, remaining_db_id,
2589            "After removing a workspace, the serialized active_workspace_id should match \
2590             the remaining active workspace's database id"
2591        );
2592    }
2593
2594    #[gpui::test]
2595    async fn test_breakpoints() {
2596        zlog::init_test();
2597
2598        let db = WorkspaceDb::open_test_db("test_breakpoints").await;
2599        let id = db.next_id().await.unwrap();
2600
2601        let path = Path::new("/tmp/test.rs");
2602
2603        let breakpoint = Breakpoint {
2604            position: 123,
2605            message: None,
2606            state: BreakpointState::Enabled,
2607            condition: None,
2608            hit_condition: None,
2609        };
2610
2611        let log_breakpoint = Breakpoint {
2612            position: 456,
2613            message: Some("Test log message".into()),
2614            state: BreakpointState::Enabled,
2615            condition: None,
2616            hit_condition: None,
2617        };
2618
2619        let disable_breakpoint = Breakpoint {
2620            position: 578,
2621            message: None,
2622            state: BreakpointState::Disabled,
2623            condition: None,
2624            hit_condition: None,
2625        };
2626
2627        let condition_breakpoint = Breakpoint {
2628            position: 789,
2629            message: None,
2630            state: BreakpointState::Enabled,
2631            condition: Some("x > 5".into()),
2632            hit_condition: None,
2633        };
2634
2635        let hit_condition_breakpoint = Breakpoint {
2636            position: 999,
2637            message: None,
2638            state: BreakpointState::Enabled,
2639            condition: None,
2640            hit_condition: Some(">= 3".into()),
2641        };
2642
2643        let workspace = SerializedWorkspace {
2644            id,
2645            paths: PathList::new(&["/tmp"]),
2646            location: SerializedWorkspaceLocation::Local,
2647            center_group: Default::default(),
2648            window_bounds: Default::default(),
2649            display: Default::default(),
2650            docks: Default::default(),
2651            centered_layout: false,
2652            breakpoints: {
2653                let mut map = collections::BTreeMap::default();
2654                map.insert(
2655                    Arc::from(path),
2656                    vec![
2657                        SourceBreakpoint {
2658                            row: breakpoint.position,
2659                            path: Arc::from(path),
2660                            message: breakpoint.message.clone(),
2661                            state: breakpoint.state,
2662                            condition: breakpoint.condition.clone(),
2663                            hit_condition: breakpoint.hit_condition.clone(),
2664                        },
2665                        SourceBreakpoint {
2666                            row: log_breakpoint.position,
2667                            path: Arc::from(path),
2668                            message: log_breakpoint.message.clone(),
2669                            state: log_breakpoint.state,
2670                            condition: log_breakpoint.condition.clone(),
2671                            hit_condition: log_breakpoint.hit_condition.clone(),
2672                        },
2673                        SourceBreakpoint {
2674                            row: disable_breakpoint.position,
2675                            path: Arc::from(path),
2676                            message: disable_breakpoint.message.clone(),
2677                            state: disable_breakpoint.state,
2678                            condition: disable_breakpoint.condition.clone(),
2679                            hit_condition: disable_breakpoint.hit_condition.clone(),
2680                        },
2681                        SourceBreakpoint {
2682                            row: condition_breakpoint.position,
2683                            path: Arc::from(path),
2684                            message: condition_breakpoint.message.clone(),
2685                            state: condition_breakpoint.state,
2686                            condition: condition_breakpoint.condition.clone(),
2687                            hit_condition: condition_breakpoint.hit_condition.clone(),
2688                        },
2689                        SourceBreakpoint {
2690                            row: hit_condition_breakpoint.position,
2691                            path: Arc::from(path),
2692                            message: hit_condition_breakpoint.message.clone(),
2693                            state: hit_condition_breakpoint.state,
2694                            condition: hit_condition_breakpoint.condition.clone(),
2695                            hit_condition: hit_condition_breakpoint.hit_condition.clone(),
2696                        },
2697                    ],
2698                );
2699                map
2700            },
2701            session_id: None,
2702            window_id: None,
2703            user_toolchains: Default::default(),
2704        };
2705
2706        db.save_workspace(workspace.clone()).await;
2707
2708        let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2709        let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap();
2710
2711        assert_eq!(loaded_breakpoints.len(), 5);
2712
2713        // normal breakpoint
2714        assert_eq!(loaded_breakpoints[0].row, breakpoint.position);
2715        assert_eq!(loaded_breakpoints[0].message, breakpoint.message);
2716        assert_eq!(loaded_breakpoints[0].condition, breakpoint.condition);
2717        assert_eq!(
2718            loaded_breakpoints[0].hit_condition,
2719            breakpoint.hit_condition
2720        );
2721        assert_eq!(loaded_breakpoints[0].state, breakpoint.state);
2722        assert_eq!(loaded_breakpoints[0].path, Arc::from(path));
2723
2724        // enabled breakpoint
2725        assert_eq!(loaded_breakpoints[1].row, log_breakpoint.position);
2726        assert_eq!(loaded_breakpoints[1].message, log_breakpoint.message);
2727        assert_eq!(loaded_breakpoints[1].condition, log_breakpoint.condition);
2728        assert_eq!(
2729            loaded_breakpoints[1].hit_condition,
2730            log_breakpoint.hit_condition
2731        );
2732        assert_eq!(loaded_breakpoints[1].state, log_breakpoint.state);
2733        assert_eq!(loaded_breakpoints[1].path, Arc::from(path));
2734
2735        // disable breakpoint
2736        assert_eq!(loaded_breakpoints[2].row, disable_breakpoint.position);
2737        assert_eq!(loaded_breakpoints[2].message, disable_breakpoint.message);
2738        assert_eq!(
2739            loaded_breakpoints[2].condition,
2740            disable_breakpoint.condition
2741        );
2742        assert_eq!(
2743            loaded_breakpoints[2].hit_condition,
2744            disable_breakpoint.hit_condition
2745        );
2746        assert_eq!(loaded_breakpoints[2].state, disable_breakpoint.state);
2747        assert_eq!(loaded_breakpoints[2].path, Arc::from(path));
2748
2749        // condition breakpoint
2750        assert_eq!(loaded_breakpoints[3].row, condition_breakpoint.position);
2751        assert_eq!(loaded_breakpoints[3].message, condition_breakpoint.message);
2752        assert_eq!(
2753            loaded_breakpoints[3].condition,
2754            condition_breakpoint.condition
2755        );
2756        assert_eq!(
2757            loaded_breakpoints[3].hit_condition,
2758            condition_breakpoint.hit_condition
2759        );
2760        assert_eq!(loaded_breakpoints[3].state, condition_breakpoint.state);
2761        assert_eq!(loaded_breakpoints[3].path, Arc::from(path));
2762
2763        // hit condition breakpoint
2764        assert_eq!(loaded_breakpoints[4].row, hit_condition_breakpoint.position);
2765        assert_eq!(
2766            loaded_breakpoints[4].message,
2767            hit_condition_breakpoint.message
2768        );
2769        assert_eq!(
2770            loaded_breakpoints[4].condition,
2771            hit_condition_breakpoint.condition
2772        );
2773        assert_eq!(
2774            loaded_breakpoints[4].hit_condition,
2775            hit_condition_breakpoint.hit_condition
2776        );
2777        assert_eq!(loaded_breakpoints[4].state, hit_condition_breakpoint.state);
2778        assert_eq!(loaded_breakpoints[4].path, Arc::from(path));
2779    }
2780
2781    #[gpui::test]
2782    async fn test_remove_last_breakpoint() {
2783        zlog::init_test();
2784
2785        let db = WorkspaceDb::open_test_db("test_remove_last_breakpoint").await;
2786        let id = db.next_id().await.unwrap();
2787
2788        let singular_path = Path::new("/tmp/test_remove_last_breakpoint.rs");
2789
2790        let breakpoint_to_remove = Breakpoint {
2791            position: 100,
2792            message: None,
2793            state: BreakpointState::Enabled,
2794            condition: None,
2795            hit_condition: None,
2796        };
2797
2798        let workspace = SerializedWorkspace {
2799            id,
2800            paths: PathList::new(&["/tmp"]),
2801            location: SerializedWorkspaceLocation::Local,
2802            center_group: Default::default(),
2803            window_bounds: Default::default(),
2804            display: Default::default(),
2805            docks: Default::default(),
2806            centered_layout: false,
2807            breakpoints: {
2808                let mut map = collections::BTreeMap::default();
2809                map.insert(
2810                    Arc::from(singular_path),
2811                    vec![SourceBreakpoint {
2812                        row: breakpoint_to_remove.position,
2813                        path: Arc::from(singular_path),
2814                        message: None,
2815                        state: BreakpointState::Enabled,
2816                        condition: None,
2817                        hit_condition: None,
2818                    }],
2819                );
2820                map
2821            },
2822            session_id: None,
2823            window_id: None,
2824            user_toolchains: Default::default(),
2825        };
2826
2827        db.save_workspace(workspace.clone()).await;
2828
2829        let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2830        let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(singular_path)).unwrap();
2831
2832        assert_eq!(loaded_breakpoints.len(), 1);
2833        assert_eq!(loaded_breakpoints[0].row, breakpoint_to_remove.position);
2834        assert_eq!(loaded_breakpoints[0].message, breakpoint_to_remove.message);
2835        assert_eq!(
2836            loaded_breakpoints[0].condition,
2837            breakpoint_to_remove.condition
2838        );
2839        assert_eq!(
2840            loaded_breakpoints[0].hit_condition,
2841            breakpoint_to_remove.hit_condition
2842        );
2843        assert_eq!(loaded_breakpoints[0].state, breakpoint_to_remove.state);
2844        assert_eq!(loaded_breakpoints[0].path, Arc::from(singular_path));
2845
2846        let workspace_without_breakpoint = SerializedWorkspace {
2847            id,
2848            paths: PathList::new(&["/tmp"]),
2849            location: SerializedWorkspaceLocation::Local,
2850            center_group: Default::default(),
2851            window_bounds: Default::default(),
2852            display: Default::default(),
2853            docks: Default::default(),
2854            centered_layout: false,
2855            breakpoints: collections::BTreeMap::default(),
2856            session_id: None,
2857            window_id: None,
2858            user_toolchains: Default::default(),
2859        };
2860
2861        db.save_workspace(workspace_without_breakpoint.clone())
2862            .await;
2863
2864        let loaded_after_remove = db.workspace_for_roots(&["/tmp"]).unwrap();
2865        let empty_breakpoints = loaded_after_remove
2866            .breakpoints
2867            .get(&Arc::from(singular_path));
2868
2869        assert!(empty_breakpoints.is_none());
2870    }
2871
2872    #[gpui::test]
2873    async fn test_next_id_stability() {
2874        zlog::init_test();
2875
2876        let db = WorkspaceDb::open_test_db("test_next_id_stability").await;
2877
2878        db.write(|conn| {
2879            conn.migrate(
2880                "test_table",
2881                &[sql!(
2882                    CREATE TABLE test_table(
2883                        text TEXT,
2884                        workspace_id INTEGER,
2885                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2886                        ON DELETE CASCADE
2887                    ) STRICT;
2888                )],
2889                &mut |_, _, _| false,
2890            )
2891            .unwrap();
2892        })
2893        .await;
2894
2895        let id = db.next_id().await.unwrap();
2896        // Assert the empty row got inserted
2897        assert_eq!(
2898            Some(id),
2899            db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
2900                SELECT workspace_id FROM workspaces WHERE workspace_id = ?
2901            ))
2902            .unwrap()(id)
2903            .unwrap()
2904        );
2905
2906        db.write(move |conn| {
2907            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2908                .unwrap()(("test-text-1", id))
2909            .unwrap()
2910        })
2911        .await;
2912
2913        let test_text_1 = db
2914            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2915            .unwrap()(1)
2916        .unwrap()
2917        .unwrap();
2918        assert_eq!(test_text_1, "test-text-1");
2919    }
2920
2921    #[gpui::test]
2922    async fn test_workspace_id_stability() {
2923        zlog::init_test();
2924
2925        let db = WorkspaceDb::open_test_db("test_workspace_id_stability").await;
2926
2927        db.write(|conn| {
2928            conn.migrate(
2929                "test_table",
2930                &[sql!(
2931                        CREATE TABLE test_table(
2932                            text TEXT,
2933                            workspace_id INTEGER,
2934                            FOREIGN KEY(workspace_id)
2935                                REFERENCES workspaces(workspace_id)
2936                            ON DELETE CASCADE
2937                        ) STRICT;)],
2938                &mut |_, _, _| false,
2939            )
2940        })
2941        .await
2942        .unwrap();
2943
2944        let mut workspace_1 = SerializedWorkspace {
2945            id: WorkspaceId(1),
2946            paths: PathList::new(&["/tmp", "/tmp2"]),
2947            location: SerializedWorkspaceLocation::Local,
2948            center_group: Default::default(),
2949            window_bounds: Default::default(),
2950            display: Default::default(),
2951            docks: Default::default(),
2952            centered_layout: false,
2953            breakpoints: Default::default(),
2954            session_id: None,
2955            window_id: None,
2956            user_toolchains: Default::default(),
2957        };
2958
2959        let workspace_2 = SerializedWorkspace {
2960            id: WorkspaceId(2),
2961            paths: PathList::new(&["/tmp"]),
2962            location: SerializedWorkspaceLocation::Local,
2963            center_group: Default::default(),
2964            window_bounds: Default::default(),
2965            display: Default::default(),
2966            docks: Default::default(),
2967            centered_layout: false,
2968            breakpoints: Default::default(),
2969            session_id: None,
2970            window_id: None,
2971            user_toolchains: Default::default(),
2972        };
2973
2974        db.save_workspace(workspace_1.clone()).await;
2975
2976        db.write(|conn| {
2977            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2978                .unwrap()(("test-text-1", 1))
2979            .unwrap();
2980        })
2981        .await;
2982
2983        db.save_workspace(workspace_2.clone()).await;
2984
2985        db.write(|conn| {
2986            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2987                .unwrap()(("test-text-2", 2))
2988            .unwrap();
2989        })
2990        .await;
2991
2992        workspace_1.paths = PathList::new(&["/tmp", "/tmp3"]);
2993        db.save_workspace(workspace_1.clone()).await;
2994        db.save_workspace(workspace_1).await;
2995        db.save_workspace(workspace_2).await;
2996
2997        let test_text_2 = db
2998            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2999            .unwrap()(2)
3000        .unwrap()
3001        .unwrap();
3002        assert_eq!(test_text_2, "test-text-2");
3003
3004        let test_text_1 = db
3005            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
3006            .unwrap()(1)
3007        .unwrap()
3008        .unwrap();
3009        assert_eq!(test_text_1, "test-text-1");
3010    }
3011
3012    fn group(axis: Axis, children: Vec<SerializedPaneGroup>) -> SerializedPaneGroup {
3013        SerializedPaneGroup::Group {
3014            axis: SerializedAxis(axis),
3015            flexes: None,
3016            children,
3017        }
3018    }
3019
3020    #[gpui::test]
3021    async fn test_full_workspace_serialization() {
3022        zlog::init_test();
3023
3024        let db = WorkspaceDb::open_test_db("test_full_workspace_serialization").await;
3025
3026        //  -----------------
3027        //  | 1,2   | 5,6   |
3028        //  | - - - |       |
3029        //  | 3,4   |       |
3030        //  -----------------
3031        let center_group = group(
3032            Axis::Horizontal,
3033            vec![
3034                group(
3035                    Axis::Vertical,
3036                    vec![
3037                        SerializedPaneGroup::Pane(SerializedPane::new(
3038                            vec![
3039                                SerializedItem::new("Terminal", 5, false, false),
3040                                SerializedItem::new("Terminal", 6, true, false),
3041                            ],
3042                            false,
3043                            0,
3044                        )),
3045                        SerializedPaneGroup::Pane(SerializedPane::new(
3046                            vec![
3047                                SerializedItem::new("Terminal", 7, true, false),
3048                                SerializedItem::new("Terminal", 8, false, false),
3049                            ],
3050                            false,
3051                            0,
3052                        )),
3053                    ],
3054                ),
3055                SerializedPaneGroup::Pane(SerializedPane::new(
3056                    vec![
3057                        SerializedItem::new("Terminal", 9, false, false),
3058                        SerializedItem::new("Terminal", 10, true, false),
3059                    ],
3060                    false,
3061                    0,
3062                )),
3063            ],
3064        );
3065
3066        let workspace = SerializedWorkspace {
3067            id: WorkspaceId(5),
3068            paths: PathList::new(&["/tmp", "/tmp2"]),
3069            location: SerializedWorkspaceLocation::Local,
3070            center_group,
3071            window_bounds: Default::default(),
3072            breakpoints: Default::default(),
3073            display: Default::default(),
3074            docks: Default::default(),
3075            centered_layout: false,
3076            session_id: None,
3077            window_id: Some(999),
3078            user_toolchains: Default::default(),
3079        };
3080
3081        db.save_workspace(workspace.clone()).await;
3082
3083        let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
3084        assert_eq!(workspace, round_trip_workspace.unwrap());
3085
3086        // Test guaranteed duplicate IDs
3087        db.save_workspace(workspace.clone()).await;
3088        db.save_workspace(workspace.clone()).await;
3089
3090        let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
3091        assert_eq!(workspace, round_trip_workspace.unwrap());
3092    }
3093
3094    #[gpui::test]
3095    async fn test_workspace_assignment() {
3096        zlog::init_test();
3097
3098        let db = WorkspaceDb::open_test_db("test_basic_functionality").await;
3099
3100        let workspace_1 = SerializedWorkspace {
3101            id: WorkspaceId(1),
3102            paths: PathList::new(&["/tmp", "/tmp2"]),
3103            location: SerializedWorkspaceLocation::Local,
3104            center_group: Default::default(),
3105            window_bounds: Default::default(),
3106            breakpoints: Default::default(),
3107            display: Default::default(),
3108            docks: Default::default(),
3109            centered_layout: false,
3110            session_id: None,
3111            window_id: Some(1),
3112            user_toolchains: Default::default(),
3113        };
3114
3115        let mut workspace_2 = SerializedWorkspace {
3116            id: WorkspaceId(2),
3117            paths: PathList::new(&["/tmp"]),
3118            location: SerializedWorkspaceLocation::Local,
3119            center_group: Default::default(),
3120            window_bounds: Default::default(),
3121            display: Default::default(),
3122            docks: Default::default(),
3123            centered_layout: false,
3124            breakpoints: Default::default(),
3125            session_id: None,
3126            window_id: Some(2),
3127            user_toolchains: Default::default(),
3128        };
3129
3130        db.save_workspace(workspace_1.clone()).await;
3131        db.save_workspace(workspace_2.clone()).await;
3132
3133        // Test that paths are treated as a set
3134        assert_eq!(
3135            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3136            workspace_1
3137        );
3138        assert_eq!(
3139            db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
3140            workspace_1
3141        );
3142
3143        // Make sure that other keys work
3144        assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
3145        assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
3146
3147        // Test 'mutate' case of updating a pre-existing id
3148        workspace_2.paths = PathList::new(&["/tmp", "/tmp2"]);
3149
3150        db.save_workspace(workspace_2.clone()).await;
3151        assert_eq!(
3152            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3153            workspace_2
3154        );
3155
3156        // Test other mechanism for mutating
3157        let mut workspace_3 = SerializedWorkspace {
3158            id: WorkspaceId(3),
3159            paths: PathList::new(&["/tmp2", "/tmp"]),
3160            location: SerializedWorkspaceLocation::Local,
3161            center_group: Default::default(),
3162            window_bounds: Default::default(),
3163            breakpoints: Default::default(),
3164            display: Default::default(),
3165            docks: Default::default(),
3166            centered_layout: false,
3167            session_id: None,
3168            window_id: Some(3),
3169            user_toolchains: Default::default(),
3170        };
3171
3172        db.save_workspace(workspace_3.clone()).await;
3173        assert_eq!(
3174            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3175            workspace_3
3176        );
3177
3178        // Make sure that updating paths differently also works
3179        workspace_3.paths = PathList::new(&["/tmp3", "/tmp4", "/tmp2"]);
3180        db.save_workspace(workspace_3.clone()).await;
3181        assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
3182        assert_eq!(
3183            db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
3184                .unwrap(),
3185            workspace_3
3186        );
3187    }
3188
3189    #[gpui::test]
3190    async fn test_session_workspaces() {
3191        zlog::init_test();
3192
3193        let db = WorkspaceDb::open_test_db("test_serializing_workspaces_session_id").await;
3194
3195        let workspace_1 = SerializedWorkspace {
3196            id: WorkspaceId(1),
3197            paths: PathList::new(&["/tmp1"]),
3198            location: SerializedWorkspaceLocation::Local,
3199            center_group: Default::default(),
3200            window_bounds: Default::default(),
3201            display: Default::default(),
3202            docks: Default::default(),
3203            centered_layout: false,
3204            breakpoints: Default::default(),
3205            session_id: Some("session-id-1".to_owned()),
3206            window_id: Some(10),
3207            user_toolchains: Default::default(),
3208        };
3209
3210        let workspace_2 = SerializedWorkspace {
3211            id: WorkspaceId(2),
3212            paths: PathList::new(&["/tmp2"]),
3213            location: SerializedWorkspaceLocation::Local,
3214            center_group: Default::default(),
3215            window_bounds: Default::default(),
3216            display: Default::default(),
3217            docks: Default::default(),
3218            centered_layout: false,
3219            breakpoints: Default::default(),
3220            session_id: Some("session-id-1".to_owned()),
3221            window_id: Some(20),
3222            user_toolchains: Default::default(),
3223        };
3224
3225        let workspace_3 = SerializedWorkspace {
3226            id: WorkspaceId(3),
3227            paths: PathList::new(&["/tmp3"]),
3228            location: SerializedWorkspaceLocation::Local,
3229            center_group: Default::default(),
3230            window_bounds: Default::default(),
3231            display: Default::default(),
3232            docks: Default::default(),
3233            centered_layout: false,
3234            breakpoints: Default::default(),
3235            session_id: Some("session-id-2".to_owned()),
3236            window_id: Some(30),
3237            user_toolchains: Default::default(),
3238        };
3239
3240        let workspace_4 = SerializedWorkspace {
3241            id: WorkspaceId(4),
3242            paths: PathList::new(&["/tmp4"]),
3243            location: SerializedWorkspaceLocation::Local,
3244            center_group: Default::default(),
3245            window_bounds: Default::default(),
3246            display: Default::default(),
3247            docks: Default::default(),
3248            centered_layout: false,
3249            breakpoints: Default::default(),
3250            session_id: None,
3251            window_id: None,
3252            user_toolchains: Default::default(),
3253        };
3254
3255        let connection_id = db
3256            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3257                host: "my-host".into(),
3258                port: Some(1234),
3259                ..Default::default()
3260            }))
3261            .await
3262            .unwrap();
3263
3264        let workspace_5 = SerializedWorkspace {
3265            id: WorkspaceId(5),
3266            paths: PathList::default(),
3267            location: SerializedWorkspaceLocation::Remote(
3268                db.remote_connection(connection_id).unwrap(),
3269            ),
3270            center_group: Default::default(),
3271            window_bounds: Default::default(),
3272            display: Default::default(),
3273            docks: Default::default(),
3274            centered_layout: false,
3275            breakpoints: Default::default(),
3276            session_id: Some("session-id-2".to_owned()),
3277            window_id: Some(50),
3278            user_toolchains: Default::default(),
3279        };
3280
3281        let workspace_6 = SerializedWorkspace {
3282            id: WorkspaceId(6),
3283            paths: PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3284            location: SerializedWorkspaceLocation::Local,
3285            center_group: Default::default(),
3286            window_bounds: Default::default(),
3287            breakpoints: Default::default(),
3288            display: Default::default(),
3289            docks: Default::default(),
3290            centered_layout: false,
3291            session_id: Some("session-id-3".to_owned()),
3292            window_id: Some(60),
3293            user_toolchains: Default::default(),
3294        };
3295
3296        db.save_workspace(workspace_1.clone()).await;
3297        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3298        db.save_workspace(workspace_2.clone()).await;
3299        db.save_workspace(workspace_3.clone()).await;
3300        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3301        db.save_workspace(workspace_4.clone()).await;
3302        db.save_workspace(workspace_5.clone()).await;
3303        db.save_workspace(workspace_6.clone()).await;
3304
3305        let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
3306        assert_eq!(locations.len(), 2);
3307        assert_eq!(locations[0].0, WorkspaceId(2));
3308        assert_eq!(locations[0].1, PathList::new(&["/tmp2"]));
3309        assert_eq!(locations[0].2, Some(20));
3310        assert_eq!(locations[1].0, WorkspaceId(1));
3311        assert_eq!(locations[1].1, PathList::new(&["/tmp1"]));
3312        assert_eq!(locations[1].2, Some(10));
3313
3314        let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
3315        assert_eq!(locations.len(), 2);
3316        assert_eq!(locations[0].0, WorkspaceId(5));
3317        assert_eq!(locations[0].1, PathList::default());
3318        assert_eq!(locations[0].2, Some(50));
3319        assert_eq!(locations[0].3, Some(connection_id));
3320        assert_eq!(locations[1].0, WorkspaceId(3));
3321        assert_eq!(locations[1].1, PathList::new(&["/tmp3"]));
3322        assert_eq!(locations[1].2, Some(30));
3323
3324        let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
3325        assert_eq!(locations.len(), 1);
3326        assert_eq!(locations[0].0, WorkspaceId(6));
3327        assert_eq!(
3328            locations[0].1,
3329            PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3330        );
3331        assert_eq!(locations[0].2, Some(60));
3332    }
3333
3334    fn default_workspace<P: AsRef<Path>>(
3335        paths: &[P],
3336        center_group: &SerializedPaneGroup,
3337    ) -> SerializedWorkspace {
3338        SerializedWorkspace {
3339            id: WorkspaceId(4),
3340            paths: PathList::new(paths),
3341            location: SerializedWorkspaceLocation::Local,
3342            center_group: center_group.clone(),
3343            window_bounds: Default::default(),
3344            display: Default::default(),
3345            docks: Default::default(),
3346            breakpoints: Default::default(),
3347            centered_layout: false,
3348            session_id: None,
3349            window_id: None,
3350            user_toolchains: Default::default(),
3351        }
3352    }
3353
3354    #[gpui::test]
3355    async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) {
3356        let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3357        let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3358        let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3359        let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3360
3361        let fs = fs::FakeFs::new(cx.executor());
3362        fs.insert_tree(dir1.path(), json!({})).await;
3363        fs.insert_tree(dir2.path(), json!({})).await;
3364        fs.insert_tree(dir3.path(), json!({})).await;
3365        fs.insert_tree(dir4.path(), json!({})).await;
3366
3367        let db =
3368            WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
3369
3370        let workspaces = [
3371            (1, vec![dir1.path()], 9),
3372            (2, vec![dir2.path()], 5),
3373            (3, vec![dir3.path()], 8),
3374            (4, vec![dir4.path()], 2),
3375            (5, vec![dir1.path(), dir2.path(), dir3.path()], 3),
3376            (6, vec![dir4.path(), dir3.path(), dir2.path()], 4),
3377        ]
3378        .into_iter()
3379        .map(|(id, paths, window_id)| SerializedWorkspace {
3380            id: WorkspaceId(id),
3381            paths: PathList::new(paths.as_slice()),
3382            location: SerializedWorkspaceLocation::Local,
3383            center_group: Default::default(),
3384            window_bounds: Default::default(),
3385            display: Default::default(),
3386            docks: Default::default(),
3387            centered_layout: false,
3388            session_id: Some("one-session".to_owned()),
3389            breakpoints: Default::default(),
3390            window_id: Some(window_id),
3391            user_toolchains: Default::default(),
3392        })
3393        .collect::<Vec<_>>();
3394
3395        for workspace in workspaces.iter() {
3396            db.save_workspace(workspace.clone()).await;
3397        }
3398
3399        let stack = Some(Vec::from([
3400            WindowId::from(2), // Top
3401            WindowId::from(8),
3402            WindowId::from(5),
3403            WindowId::from(9),
3404            WindowId::from(3),
3405            WindowId::from(4), // Bottom
3406        ]));
3407
3408        let locations = db
3409            .last_session_workspace_locations("one-session", stack, fs.as_ref())
3410            .await
3411            .unwrap();
3412        assert_eq!(
3413            locations,
3414            [
3415                SessionWorkspace {
3416                    workspace_id: WorkspaceId(4),
3417                    location: SerializedWorkspaceLocation::Local,
3418                    paths: PathList::new(&[dir4.path()]),
3419                    window_id: Some(WindowId::from(2u64)),
3420                },
3421                SessionWorkspace {
3422                    workspace_id: WorkspaceId(3),
3423                    location: SerializedWorkspaceLocation::Local,
3424                    paths: PathList::new(&[dir3.path()]),
3425                    window_id: Some(WindowId::from(8u64)),
3426                },
3427                SessionWorkspace {
3428                    workspace_id: WorkspaceId(2),
3429                    location: SerializedWorkspaceLocation::Local,
3430                    paths: PathList::new(&[dir2.path()]),
3431                    window_id: Some(WindowId::from(5u64)),
3432                },
3433                SessionWorkspace {
3434                    workspace_id: WorkspaceId(1),
3435                    location: SerializedWorkspaceLocation::Local,
3436                    paths: PathList::new(&[dir1.path()]),
3437                    window_id: Some(WindowId::from(9u64)),
3438                },
3439                SessionWorkspace {
3440                    workspace_id: WorkspaceId(5),
3441                    location: SerializedWorkspaceLocation::Local,
3442                    paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
3443                    window_id: Some(WindowId::from(3u64)),
3444                },
3445                SessionWorkspace {
3446                    workspace_id: WorkspaceId(6),
3447                    location: SerializedWorkspaceLocation::Local,
3448                    paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
3449                    window_id: Some(WindowId::from(4u64)),
3450                },
3451            ]
3452        );
3453    }
3454
3455    #[gpui::test]
3456    async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
3457        let fs = fs::FakeFs::new(cx.executor());
3458        let db =
3459            WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
3460                .await;
3461
3462        let remote_connections = [
3463            ("host-1", "my-user-1"),
3464            ("host-2", "my-user-2"),
3465            ("host-3", "my-user-3"),
3466            ("host-4", "my-user-4"),
3467        ]
3468        .into_iter()
3469        .map(|(host, user)| async {
3470            let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
3471                host: host.into(),
3472                username: Some(user.to_string()),
3473                ..Default::default()
3474            });
3475            db.get_or_create_remote_connection(options.clone())
3476                .await
3477                .unwrap();
3478            options
3479        })
3480        .collect::<Vec<_>>();
3481
3482        let remote_connections = futures::future::join_all(remote_connections).await;
3483
3484        let workspaces = [
3485            (1, remote_connections[0].clone(), 9),
3486            (2, remote_connections[1].clone(), 5),
3487            (3, remote_connections[2].clone(), 8),
3488            (4, remote_connections[3].clone(), 2),
3489        ]
3490        .into_iter()
3491        .map(|(id, remote_connection, window_id)| SerializedWorkspace {
3492            id: WorkspaceId(id),
3493            paths: PathList::default(),
3494            location: SerializedWorkspaceLocation::Remote(remote_connection),
3495            center_group: Default::default(),
3496            window_bounds: Default::default(),
3497            display: Default::default(),
3498            docks: Default::default(),
3499            centered_layout: false,
3500            session_id: Some("one-session".to_owned()),
3501            breakpoints: Default::default(),
3502            window_id: Some(window_id),
3503            user_toolchains: Default::default(),
3504        })
3505        .collect::<Vec<_>>();
3506
3507        for workspace in workspaces.iter() {
3508            db.save_workspace(workspace.clone()).await;
3509        }
3510
3511        let stack = Some(Vec::from([
3512            WindowId::from(2), // Top
3513            WindowId::from(8),
3514            WindowId::from(5),
3515            WindowId::from(9), // Bottom
3516        ]));
3517
3518        let have = db
3519            .last_session_workspace_locations("one-session", stack, fs.as_ref())
3520            .await
3521            .unwrap();
3522        assert_eq!(have.len(), 4);
3523        assert_eq!(
3524            have[0],
3525            SessionWorkspace {
3526                workspace_id: WorkspaceId(4),
3527                location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
3528                paths: PathList::default(),
3529                window_id: Some(WindowId::from(2u64)),
3530            }
3531        );
3532        assert_eq!(
3533            have[1],
3534            SessionWorkspace {
3535                workspace_id: WorkspaceId(3),
3536                location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
3537                paths: PathList::default(),
3538                window_id: Some(WindowId::from(8u64)),
3539            }
3540        );
3541        assert_eq!(
3542            have[2],
3543            SessionWorkspace {
3544                workspace_id: WorkspaceId(2),
3545                location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
3546                paths: PathList::default(),
3547                window_id: Some(WindowId::from(5u64)),
3548            }
3549        );
3550        assert_eq!(
3551            have[3],
3552            SessionWorkspace {
3553                workspace_id: WorkspaceId(1),
3554                location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
3555                paths: PathList::default(),
3556                window_id: Some(WindowId::from(9u64)),
3557            }
3558        );
3559    }
3560
3561    #[gpui::test]
3562    async fn test_get_or_create_ssh_project() {
3563        let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project").await;
3564
3565        let host = "example.com".to_string();
3566        let port = Some(22_u16);
3567        let user = Some("user".to_string());
3568
3569        let connection_id = db
3570            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3571                host: host.clone().into(),
3572                port,
3573                username: user.clone(),
3574                ..Default::default()
3575            }))
3576            .await
3577            .unwrap();
3578
3579        // Test that calling the function again with the same parameters returns the same project
3580        let same_connection = db
3581            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3582                host: host.clone().into(),
3583                port,
3584                username: user.clone(),
3585                ..Default::default()
3586            }))
3587            .await
3588            .unwrap();
3589
3590        assert_eq!(connection_id, same_connection);
3591
3592        // Test with different parameters
3593        let host2 = "otherexample.com".to_string();
3594        let port2 = None;
3595        let user2 = Some("otheruser".to_string());
3596
3597        let different_connection = db
3598            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3599                host: host2.clone().into(),
3600                port: port2,
3601                username: user2.clone(),
3602                ..Default::default()
3603            }))
3604            .await
3605            .unwrap();
3606
3607        assert_ne!(connection_id, different_connection);
3608    }
3609
3610    #[gpui::test]
3611    async fn test_get_or_create_ssh_project_with_null_user() {
3612        let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project_with_null_user").await;
3613
3614        let (host, port, user) = ("example.com".to_string(), None, None);
3615
3616        let connection_id = db
3617            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3618                host: host.clone().into(),
3619                port,
3620                username: None,
3621                ..Default::default()
3622            }))
3623            .await
3624            .unwrap();
3625
3626        let same_connection_id = db
3627            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3628                host: host.clone().into(),
3629                port,
3630                username: user.clone(),
3631                ..Default::default()
3632            }))
3633            .await
3634            .unwrap();
3635
3636        assert_eq!(connection_id, same_connection_id);
3637    }
3638
3639    #[gpui::test]
3640    async fn test_get_remote_connections() {
3641        let db = WorkspaceDb::open_test_db("test_get_remote_connections").await;
3642
3643        let connections = [
3644            ("example.com".to_string(), None, None),
3645            (
3646                "anotherexample.com".to_string(),
3647                Some(123_u16),
3648                Some("user2".to_string()),
3649            ),
3650            ("yetanother.com".to_string(), Some(345_u16), None),
3651        ];
3652
3653        let mut ids = Vec::new();
3654        for (host, port, user) in connections.iter() {
3655            ids.push(
3656                db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
3657                    SshConnectionOptions {
3658                        host: host.clone().into(),
3659                        port: *port,
3660                        username: user.clone(),
3661                        ..Default::default()
3662                    },
3663                ))
3664                .await
3665                .unwrap(),
3666            );
3667        }
3668
3669        let stored_connections = db.remote_connections().unwrap();
3670        assert_eq!(
3671            stored_connections,
3672            [
3673                (
3674                    ids[0],
3675                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3676                        host: "example.com".into(),
3677                        port: None,
3678                        username: None,
3679                        ..Default::default()
3680                    }),
3681                ),
3682                (
3683                    ids[1],
3684                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3685                        host: "anotherexample.com".into(),
3686                        port: Some(123),
3687                        username: Some("user2".into()),
3688                        ..Default::default()
3689                    }),
3690                ),
3691                (
3692                    ids[2],
3693                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3694                        host: "yetanother.com".into(),
3695                        port: Some(345),
3696                        username: None,
3697                        ..Default::default()
3698                    }),
3699                ),
3700            ]
3701            .into_iter()
3702            .collect::<HashMap<_, _>>(),
3703        );
3704    }
3705
3706    #[gpui::test]
3707    async fn test_simple_split() {
3708        zlog::init_test();
3709
3710        let db = WorkspaceDb::open_test_db("simple_split").await;
3711
3712        //  -----------------
3713        //  | 1,2   | 5,6   |
3714        //  | - - - |       |
3715        //  | 3,4   |       |
3716        //  -----------------
3717        let center_pane = group(
3718            Axis::Horizontal,
3719            vec![
3720                group(
3721                    Axis::Vertical,
3722                    vec![
3723                        SerializedPaneGroup::Pane(SerializedPane::new(
3724                            vec![
3725                                SerializedItem::new("Terminal", 1, false, false),
3726                                SerializedItem::new("Terminal", 2, true, false),
3727                            ],
3728                            false,
3729                            0,
3730                        )),
3731                        SerializedPaneGroup::Pane(SerializedPane::new(
3732                            vec![
3733                                SerializedItem::new("Terminal", 4, false, false),
3734                                SerializedItem::new("Terminal", 3, true, false),
3735                            ],
3736                            true,
3737                            0,
3738                        )),
3739                    ],
3740                ),
3741                SerializedPaneGroup::Pane(SerializedPane::new(
3742                    vec![
3743                        SerializedItem::new("Terminal", 5, true, false),
3744                        SerializedItem::new("Terminal", 6, false, false),
3745                    ],
3746                    false,
3747                    0,
3748                )),
3749            ],
3750        );
3751
3752        let workspace = default_workspace(&["/tmp"], &center_pane);
3753
3754        db.save_workspace(workspace.clone()).await;
3755
3756        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
3757
3758        assert_eq!(workspace.center_group, new_workspace.center_group);
3759    }
3760
3761    #[gpui::test]
3762    async fn test_cleanup_panes() {
3763        zlog::init_test();
3764
3765        let db = WorkspaceDb::open_test_db("test_cleanup_panes").await;
3766
3767        let center_pane = group(
3768            Axis::Horizontal,
3769            vec![
3770                group(
3771                    Axis::Vertical,
3772                    vec![
3773                        SerializedPaneGroup::Pane(SerializedPane::new(
3774                            vec![
3775                                SerializedItem::new("Terminal", 1, false, false),
3776                                SerializedItem::new("Terminal", 2, true, false),
3777                            ],
3778                            false,
3779                            0,
3780                        )),
3781                        SerializedPaneGroup::Pane(SerializedPane::new(
3782                            vec![
3783                                SerializedItem::new("Terminal", 4, false, false),
3784                                SerializedItem::new("Terminal", 3, true, false),
3785                            ],
3786                            true,
3787                            0,
3788                        )),
3789                    ],
3790                ),
3791                SerializedPaneGroup::Pane(SerializedPane::new(
3792                    vec![
3793                        SerializedItem::new("Terminal", 5, false, false),
3794                        SerializedItem::new("Terminal", 6, true, false),
3795                    ],
3796                    false,
3797                    0,
3798                )),
3799            ],
3800        );
3801
3802        let id = &["/tmp"];
3803
3804        let mut workspace = default_workspace(id, &center_pane);
3805
3806        db.save_workspace(workspace.clone()).await;
3807
3808        workspace.center_group = group(
3809            Axis::Vertical,
3810            vec![
3811                SerializedPaneGroup::Pane(SerializedPane::new(
3812                    vec![
3813                        SerializedItem::new("Terminal", 1, false, false),
3814                        SerializedItem::new("Terminal", 2, true, false),
3815                    ],
3816                    false,
3817                    0,
3818                )),
3819                SerializedPaneGroup::Pane(SerializedPane::new(
3820                    vec![
3821                        SerializedItem::new("Terminal", 4, true, false),
3822                        SerializedItem::new("Terminal", 3, false, false),
3823                    ],
3824                    true,
3825                    0,
3826                )),
3827            ],
3828        );
3829
3830        db.save_workspace(workspace.clone()).await;
3831
3832        let new_workspace = db.workspace_for_roots(id).unwrap();
3833
3834        assert_eq!(workspace.center_group, new_workspace.center_group);
3835    }
3836
3837    #[gpui::test]
3838    async fn test_empty_workspace_window_bounds() {
3839        zlog::init_test();
3840
3841        let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
3842        let id = db.next_id().await.unwrap();
3843
3844        // Create a workspace with empty paths (empty workspace)
3845        let empty_paths: &[&str] = &[];
3846        let display_uuid = Uuid::new_v4();
3847        let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
3848            origin: point(px(100.0), px(200.0)),
3849            size: size(px(800.0), px(600.0)),
3850        }));
3851
3852        let workspace = SerializedWorkspace {
3853            id,
3854            paths: PathList::new(empty_paths),
3855            location: SerializedWorkspaceLocation::Local,
3856            center_group: Default::default(),
3857            window_bounds: None,
3858            display: None,
3859            docks: Default::default(),
3860            breakpoints: Default::default(),
3861            centered_layout: false,
3862            session_id: None,
3863            window_id: None,
3864            user_toolchains: Default::default(),
3865        };
3866
3867        // Save the workspace (this creates the record with empty paths)
3868        db.save_workspace(workspace.clone()).await;
3869
3870        // Save window bounds separately (as the actual code does via set_window_open_status)
3871        db.set_window_open_status(id, window_bounds, display_uuid)
3872            .await
3873            .unwrap();
3874
3875        // Empty workspaces cannot be retrieved by paths (they'd all match).
3876        // They must be retrieved by workspace_id.
3877        assert!(db.workspace_for_roots(empty_paths).is_none());
3878
3879        // Retrieve using workspace_for_id instead
3880        let retrieved = db.workspace_for_id(id).unwrap();
3881
3882        // Verify window bounds were persisted
3883        assert_eq!(retrieved.id, id);
3884        assert!(retrieved.window_bounds.is_some());
3885        assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
3886        assert!(retrieved.display.is_some());
3887        assert_eq!(retrieved.display.unwrap(), display_uuid);
3888    }
3889
3890    #[gpui::test]
3891    async fn test_last_session_workspace_locations_groups_by_window_id(
3892        cx: &mut gpui::TestAppContext,
3893    ) {
3894        let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3895        let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3896        let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3897        let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3898        let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
3899
3900        let fs = fs::FakeFs::new(cx.executor());
3901        fs.insert_tree(dir1.path(), json!({})).await;
3902        fs.insert_tree(dir2.path(), json!({})).await;
3903        fs.insert_tree(dir3.path(), json!({})).await;
3904        fs.insert_tree(dir4.path(), json!({})).await;
3905        fs.insert_tree(dir5.path(), json!({})).await;
3906
3907        let db =
3908            WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
3909                .await;
3910
3911        // Simulate two MultiWorkspace windows each containing two workspaces,
3912        // plus one single-workspace window:
3913        //   Window 10: workspace 1, workspace 2
3914        //   Window 20: workspace 3, workspace 4
3915        //   Window 30: workspace 5 (only one)
3916        //
3917        // On session restore, the caller should be able to group these by
3918        // window_id to reconstruct the MultiWorkspace windows.
3919        let workspaces_data: Vec<(i64, &Path, u64)> = vec![
3920            (1, dir1.path(), 10),
3921            (2, dir2.path(), 10),
3922            (3, dir3.path(), 20),
3923            (4, dir4.path(), 20),
3924            (5, dir5.path(), 30),
3925        ];
3926
3927        for (id, dir, window_id) in &workspaces_data {
3928            db.save_workspace(SerializedWorkspace {
3929                id: WorkspaceId(*id),
3930                paths: PathList::new(&[*dir]),
3931                location: SerializedWorkspaceLocation::Local,
3932                center_group: Default::default(),
3933                window_bounds: Default::default(),
3934                display: Default::default(),
3935                docks: Default::default(),
3936                centered_layout: false,
3937                session_id: Some("test-session".to_owned()),
3938                breakpoints: Default::default(),
3939                window_id: Some(*window_id),
3940                user_toolchains: Default::default(),
3941            })
3942            .await;
3943        }
3944
3945        let locations = db
3946            .last_session_workspace_locations("test-session", None, fs.as_ref())
3947            .await
3948            .unwrap();
3949
3950        // All 5 workspaces should be returned with their window_ids.
3951        assert_eq!(locations.len(), 5);
3952
3953        // Every entry should have a window_id so the caller can group them.
3954        for session_workspace in &locations {
3955            assert!(
3956                session_workspace.window_id.is_some(),
3957                "workspace {:?} missing window_id",
3958                session_workspace.workspace_id
3959            );
3960        }
3961
3962        // Group by window_id, simulating what the restoration code should do.
3963        let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
3964        for session_workspace in &locations {
3965            if let Some(window_id) = session_workspace.window_id {
3966                by_window
3967                    .entry(window_id)
3968                    .or_default()
3969                    .push(session_workspace.workspace_id);
3970            }
3971        }
3972
3973        // Should produce 3 windows, not 5.
3974        assert_eq!(
3975            by_window.len(),
3976            3,
3977            "Expected 3 window groups, got {}: {:?}",
3978            by_window.len(),
3979            by_window
3980        );
3981
3982        // Window 10 should contain workspaces 1 and 2.
3983        let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
3984        assert_eq!(window_10.len(), 2);
3985        assert!(window_10.contains(&WorkspaceId(1)));
3986        assert!(window_10.contains(&WorkspaceId(2)));
3987
3988        // Window 20 should contain workspaces 3 and 4.
3989        let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
3990        assert_eq!(window_20.len(), 2);
3991        assert!(window_20.contains(&WorkspaceId(3)));
3992        assert!(window_20.contains(&WorkspaceId(4)));
3993
3994        // Window 30 should contain only workspace 5.
3995        let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
3996        assert_eq!(window_30.len(), 1);
3997        assert!(window_30.contains(&WorkspaceId(5)));
3998    }
3999
4000    #[gpui::test]
4001    async fn test_read_serialized_multi_workspaces_with_state(cx: &mut gpui::TestAppContext) {
4002        use crate::persistence::model::MultiWorkspaceState;
4003
4004        // Write multi-workspace state for two windows via the scoped KVP.
4005        let window_10 = WindowId::from(10u64);
4006        let window_20 = WindowId::from(20u64);
4007
4008        let kvp = cx.update(|cx| KeyValueStore::global(cx));
4009
4010        write_multi_workspace_state(
4011            &kvp,
4012            window_10,
4013            MultiWorkspaceState {
4014                active_workspace_id: Some(WorkspaceId(2)),
4015                project_groups: vec![],
4016                sidebar_open: true,
4017                sidebar_state: None,
4018            },
4019        )
4020        .await;
4021
4022        write_multi_workspace_state(
4023            &kvp,
4024            window_20,
4025            MultiWorkspaceState {
4026                active_workspace_id: Some(WorkspaceId(3)),
4027                project_groups: vec![],
4028                sidebar_open: false,
4029                sidebar_state: None,
4030            },
4031        )
4032        .await;
4033
4034        // Build session workspaces: two in window 10, one in window 20, one with no window.
4035        let session_workspaces = vec![
4036            SessionWorkspace {
4037                workspace_id: WorkspaceId(1),
4038                location: SerializedWorkspaceLocation::Local,
4039                paths: PathList::new(&["/a"]),
4040                window_id: Some(window_10),
4041            },
4042            SessionWorkspace {
4043                workspace_id: WorkspaceId(2),
4044                location: SerializedWorkspaceLocation::Local,
4045                paths: PathList::new(&["/b"]),
4046                window_id: Some(window_10),
4047            },
4048            SessionWorkspace {
4049                workspace_id: WorkspaceId(3),
4050                location: SerializedWorkspaceLocation::Local,
4051                paths: PathList::new(&["/c"]),
4052                window_id: Some(window_20),
4053            },
4054            SessionWorkspace {
4055                workspace_id: WorkspaceId(4),
4056                location: SerializedWorkspaceLocation::Local,
4057                paths: PathList::new(&["/d"]),
4058                window_id: None,
4059            },
4060        ];
4061
4062        let results = cx.update(|cx| read_serialized_multi_workspaces(session_workspaces, cx));
4063
4064        // Should produce 3 results: window 10, window 20, and the orphan.
4065        assert_eq!(results.len(), 3);
4066
4067        // Window 10: active_workspace_id = 2 picks workspace 2 (paths /b), sidebar open.
4068        let group_10 = &results[0];
4069        assert_eq!(group_10.active_workspace.workspace_id, WorkspaceId(2));
4070        assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
4071        assert_eq!(group_10.state.sidebar_open, true);
4072
4073        // Window 20: active_workspace_id = 3 picks workspace 3 (paths /c), sidebar closed.
4074        let group_20 = &results[1];
4075        assert_eq!(group_20.active_workspace.workspace_id, WorkspaceId(3));
4076        assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
4077        assert_eq!(group_20.state.sidebar_open, false);
4078
4079        // Orphan: no active_workspace_id, falls back to first workspace (id 4).
4080        let group_none = &results[2];
4081        assert_eq!(group_none.active_workspace.workspace_id, WorkspaceId(4));
4082        assert_eq!(group_none.state.active_workspace_id, None);
4083        assert_eq!(group_none.state.sidebar_open, false);
4084    }
4085
4086    #[gpui::test]
4087    async fn test_flush_serialization_completes_before_quit(cx: &mut gpui::TestAppContext) {
4088        crate::tests::init_test(cx);
4089
4090        cx.update(|cx| {
4091            cx.set_staff(true);
4092        });
4093
4094        let fs = fs::FakeFs::new(cx.executor());
4095        let project = Project::test(fs.clone(), [], cx).await;
4096
4097        let (multi_workspace, cx) =
4098            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4099
4100        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4101
4102        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4103
4104        // Assign a database_id so serialization will actually persist.
4105        let workspace_id = db.next_id().await.unwrap();
4106        workspace.update(cx, |ws, _cx| {
4107            ws.set_database_id(workspace_id);
4108        });
4109
4110        // Mutate some workspace state.
4111        db.set_centered_layout(workspace_id, true).await.unwrap();
4112
4113        // Call flush_serialization and await the returned task directly
4114        // (without run_until_parked — the point is that awaiting the task
4115        // alone is sufficient).
4116        let task = multi_workspace.update_in(cx, |mw, window, cx| {
4117            mw.workspace()
4118                .update(cx, |ws, cx| ws.flush_serialization(window, cx))
4119        });
4120        task.await;
4121
4122        // Read the workspace back from the DB and verify serialization happened.
4123        let serialized = db.workspace_for_id(workspace_id);
4124        assert!(
4125            serialized.is_some(),
4126            "flush_serialization should have persisted the workspace to DB"
4127        );
4128    }
4129
4130    #[gpui::test]
4131    async fn test_create_workspace_serialization(cx: &mut gpui::TestAppContext) {
4132        crate::tests::init_test(cx);
4133
4134        cx.update(|cx| {
4135            cx.set_staff(true);
4136        });
4137
4138        let fs = fs::FakeFs::new(cx.executor());
4139        let project = Project::test(fs.clone(), [], cx).await;
4140
4141        let (multi_workspace, cx) =
4142            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4143
4144        // Give the first workspace a database_id.
4145        multi_workspace.update_in(cx, |mw, _, cx| {
4146            mw.set_random_database_id(cx);
4147        });
4148
4149        let window_id =
4150            multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
4151
4152        // Create a new workspace via the MultiWorkspace API (triggers next_id()).
4153        multi_workspace.update_in(cx, |mw, window, cx| {
4154            mw.create_test_workspace(window, cx).detach();
4155        });
4156
4157        // Let the async next_id() and re-serialization tasks complete.
4158        cx.run_until_parked();
4159
4160        // The new workspace should now have a database_id.
4161        let new_workspace_db_id =
4162            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4163        assert!(
4164            new_workspace_db_id.is_some(),
4165            "New workspace should have a database_id after run_until_parked"
4166        );
4167
4168        // The multi-workspace state should record it as the active workspace.
4169        let state = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
4170        assert_eq!(
4171            state.active_workspace_id, new_workspace_db_id,
4172            "Serialized active_workspace_id should match the new workspace's database_id"
4173        );
4174
4175        // The individual workspace row should exist with real data
4176        // (not just the bare DEFAULT VALUES row from next_id).
4177        let workspace_id = new_workspace_db_id.unwrap();
4178        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4179        let serialized = db.workspace_for_id(workspace_id);
4180        assert!(
4181            serialized.is_some(),
4182            "Newly created workspace should be fully serialized in the DB after database_id assignment"
4183        );
4184    }
4185
4186    #[gpui::test]
4187    async fn test_remove_workspace_clears_session_binding(cx: &mut gpui::TestAppContext) {
4188        crate::tests::init_test(cx);
4189
4190        cx.update(|cx| {
4191            cx.set_staff(true);
4192        });
4193
4194        let fs = fs::FakeFs::new(cx.executor());
4195        let dir = unique_test_dir(&fs, "remove").await;
4196        let project1 = Project::test(fs.clone(), [], cx).await;
4197        let project2 = Project::test(fs.clone(), [], cx).await;
4198
4199        let (multi_workspace, cx) =
4200            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4201
4202        multi_workspace.update(cx, |mw, cx| {
4203            mw.open_sidebar(cx);
4204        });
4205
4206        multi_workspace.update_in(cx, |mw, _, cx| {
4207            mw.set_random_database_id(cx);
4208        });
4209
4210        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4211
4212        // Get a real DB id for workspace2 so the row actually exists.
4213        let workspace2_db_id = db.next_id().await.unwrap();
4214
4215        multi_workspace.update_in(cx, |mw, window, cx| {
4216            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4217            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4218                ws.set_database_id(workspace2_db_id)
4219            });
4220            mw.add(workspace.clone(), window, cx);
4221        });
4222
4223        // Save a full workspace row to the DB directly.
4224        let session_id = format!("remove-test-session-{}", Uuid::new_v4());
4225        db.save_workspace(SerializedWorkspace {
4226            id: workspace2_db_id,
4227            paths: PathList::new(&[&dir]),
4228            location: SerializedWorkspaceLocation::Local,
4229            center_group: Default::default(),
4230            window_bounds: Default::default(),
4231            display: Default::default(),
4232            docks: Default::default(),
4233            centered_layout: false,
4234            session_id: Some(session_id.clone()),
4235            breakpoints: Default::default(),
4236            window_id: Some(99),
4237            user_toolchains: Default::default(),
4238        })
4239        .await;
4240
4241        assert!(
4242            db.workspace_for_id(workspace2_db_id).is_some(),
4243            "Workspace2 should exist in DB before removal"
4244        );
4245
4246        // Remove workspace at index 1 (the second workspace).
4247        multi_workspace.update_in(cx, |mw, window, cx| {
4248            let ws = mw.workspaces().nth(1).unwrap().clone();
4249            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4250                .detach_and_log_err(cx);
4251        });
4252
4253        cx.run_until_parked();
4254
4255        // The row should still exist so it continues to appear in recent
4256        // projects, but the session binding should be cleared so it is not
4257        // restored as part of any future session.
4258        assert!(
4259            db.workspace_for_id(workspace2_db_id).is_some(),
4260            "Removed workspace's DB row should be preserved for recent projects"
4261        );
4262
4263        let session_workspaces = db
4264            .last_session_workspace_locations("remove-test-session", None, fs.as_ref())
4265            .await
4266            .unwrap();
4267        let restored_ids: Vec<WorkspaceId> = session_workspaces
4268            .iter()
4269            .map(|sw| sw.workspace_id)
4270            .collect();
4271        assert!(
4272            !restored_ids.contains(&workspace2_db_id),
4273            "Removed workspace should not appear in session restoration"
4274        );
4275    }
4276
4277    #[gpui::test]
4278    async fn test_remove_workspace_not_restored_as_zombie(cx: &mut gpui::TestAppContext) {
4279        crate::tests::init_test(cx);
4280
4281        cx.update(|cx| {
4282            cx.set_staff(true);
4283        });
4284
4285        let fs = fs::FakeFs::new(cx.executor());
4286        let dir1 = tempfile::TempDir::with_prefix("zombie_test1").unwrap();
4287        let dir2 = tempfile::TempDir::with_prefix("zombie_test2").unwrap();
4288        fs.insert_tree(dir1.path(), json!({})).await;
4289        fs.insert_tree(dir2.path(), json!({})).await;
4290
4291        let project1 = Project::test(fs.clone(), [], cx).await;
4292        let project2 = Project::test(fs.clone(), [], cx).await;
4293
4294        let db = cx.update(|cx| WorkspaceDb::global(cx));
4295
4296        // Get real DB ids so the rows actually exist.
4297        let ws1_id = db.next_id().await.unwrap();
4298        let ws2_id = db.next_id().await.unwrap();
4299
4300        let (multi_workspace, cx) =
4301            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4302
4303        multi_workspace.update(cx, |mw, cx| {
4304            mw.open_sidebar(cx);
4305        });
4306
4307        multi_workspace.update_in(cx, |mw, _, cx| {
4308            mw.workspace().update(cx, |ws, _cx| {
4309                ws.set_database_id(ws1_id);
4310            });
4311        });
4312
4313        multi_workspace.update_in(cx, |mw, window, cx| {
4314            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4315            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4316                ws.set_database_id(ws2_id)
4317            });
4318            mw.add(workspace.clone(), window, cx);
4319        });
4320
4321        let session_id = "test-zombie-session";
4322        let window_id_val: u64 = 42;
4323
4324        db.save_workspace(SerializedWorkspace {
4325            id: ws1_id,
4326            paths: PathList::new(&[dir1.path()]),
4327            location: SerializedWorkspaceLocation::Local,
4328            center_group: Default::default(),
4329            window_bounds: Default::default(),
4330            display: Default::default(),
4331            docks: Default::default(),
4332            centered_layout: false,
4333            session_id: Some(session_id.to_owned()),
4334            breakpoints: Default::default(),
4335            window_id: Some(window_id_val),
4336            user_toolchains: Default::default(),
4337        })
4338        .await;
4339
4340        db.save_workspace(SerializedWorkspace {
4341            id: ws2_id,
4342            paths: PathList::new(&[dir2.path()]),
4343            location: SerializedWorkspaceLocation::Local,
4344            center_group: Default::default(),
4345            window_bounds: Default::default(),
4346            display: Default::default(),
4347            docks: Default::default(),
4348            centered_layout: false,
4349            session_id: Some(session_id.to_owned()),
4350            breakpoints: Default::default(),
4351            window_id: Some(window_id_val),
4352            user_toolchains: Default::default(),
4353        })
4354        .await;
4355
4356        // Remove workspace2 (index 1).
4357        multi_workspace.update_in(cx, |mw, window, cx| {
4358            let ws = mw.workspaces().nth(1).unwrap().clone();
4359            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4360                .detach_and_log_err(cx);
4361        });
4362
4363        cx.run_until_parked();
4364
4365        // The removed workspace should NOT appear in session restoration.
4366        let locations = db
4367            .last_session_workspace_locations(session_id, None, fs.as_ref())
4368            .await
4369            .unwrap();
4370
4371        let restored_ids: Vec<WorkspaceId> = locations.iter().map(|sw| sw.workspace_id).collect();
4372        assert!(
4373            !restored_ids.contains(&ws2_id),
4374            "Removed workspace should not appear in session restoration list. Found: {:?}",
4375            restored_ids
4376        );
4377        assert!(
4378            restored_ids.contains(&ws1_id),
4379            "Remaining workspace should still appear in session restoration list"
4380        );
4381    }
4382
4383    #[gpui::test]
4384    async fn test_pending_removal_tasks_drained_on_flush(cx: &mut gpui::TestAppContext) {
4385        crate::tests::init_test(cx);
4386
4387        cx.update(|cx| {
4388            cx.set_staff(true);
4389        });
4390
4391        let fs = fs::FakeFs::new(cx.executor());
4392        let dir = unique_test_dir(&fs, "pending-removal").await;
4393        let project1 = Project::test(fs.clone(), [], cx).await;
4394        let project2 = Project::test(fs.clone(), [], cx).await;
4395
4396        let db = cx.update(|cx| WorkspaceDb::global(cx));
4397
4398        // Get a real DB id for workspace2 so the row actually exists.
4399        let workspace2_db_id = db.next_id().await.unwrap();
4400
4401        let (multi_workspace, cx) =
4402            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4403
4404        multi_workspace.update(cx, |mw, cx| {
4405            mw.open_sidebar(cx);
4406        });
4407
4408        multi_workspace.update_in(cx, |mw, _, cx| {
4409            mw.set_random_database_id(cx);
4410        });
4411
4412        multi_workspace.update_in(cx, |mw, window, cx| {
4413            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4414            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4415                ws.set_database_id(workspace2_db_id)
4416            });
4417            mw.add(workspace.clone(), window, cx);
4418        });
4419
4420        // Save a full workspace row to the DB directly and let it settle.
4421        let session_id = format!("pending-removal-session-{}", Uuid::new_v4());
4422        db.save_workspace(SerializedWorkspace {
4423            id: workspace2_db_id,
4424            paths: PathList::new(&[&dir]),
4425            location: SerializedWorkspaceLocation::Local,
4426            center_group: Default::default(),
4427            window_bounds: Default::default(),
4428            display: Default::default(),
4429            docks: Default::default(),
4430            centered_layout: false,
4431            session_id: Some(session_id.clone()),
4432            breakpoints: Default::default(),
4433            window_id: Some(88),
4434            user_toolchains: Default::default(),
4435        })
4436        .await;
4437        cx.run_until_parked();
4438
4439        // Remove workspace2 — this pushes a task to pending_removal_tasks.
4440        multi_workspace.update_in(cx, |mw, window, cx| {
4441            let ws = mw.workspaces().nth(1).unwrap().clone();
4442            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4443                .detach_and_log_err(cx);
4444        });
4445
4446        // Simulate the quit handler pattern: collect flush tasks + pending
4447        // removal tasks and await them all.
4448        let all_tasks = multi_workspace.update_in(cx, |mw, window, cx| {
4449            let mut tasks: Vec<Task<()>> = mw
4450                .workspaces()
4451                .map(|workspace| {
4452                    workspace.update(cx, |workspace, cx| {
4453                        workspace.flush_serialization(window, cx)
4454                    })
4455                })
4456                .collect();
4457            let mut removal_tasks = mw.take_pending_removal_tasks();
4458            // Note: removal_tasks may be empty if the background task already
4459            // completed (take_pending_removal_tasks filters out ready tasks).
4460            tasks.append(&mut removal_tasks);
4461            tasks.push(mw.flush_serialization());
4462            tasks
4463        });
4464        futures::future::join_all(all_tasks).await;
4465
4466        // The row should still exist (for recent projects), but the session
4467        // binding should have been cleared by the pending removal task.
4468        assert!(
4469            db.workspace_for_id(workspace2_db_id).is_some(),
4470            "Workspace row should be preserved for recent projects"
4471        );
4472
4473        let session_workspaces = db
4474            .last_session_workspace_locations("pending-removal-session", None, fs.as_ref())
4475            .await
4476            .unwrap();
4477        let restored_ids: Vec<WorkspaceId> = session_workspaces
4478            .iter()
4479            .map(|sw| sw.workspace_id)
4480            .collect();
4481        assert!(
4482            !restored_ids.contains(&workspace2_db_id),
4483            "Pending removal task should have cleared the session binding"
4484        );
4485    }
4486
4487    #[gpui::test]
4488    async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) {
4489        crate::tests::init_test(cx);
4490
4491        cx.update(|cx| {
4492            cx.set_staff(true);
4493        });
4494
4495        let fs = fs::FakeFs::new(cx.executor());
4496        let project = Project::test(fs.clone(), [], cx).await;
4497
4498        let (multi_workspace, cx) =
4499            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4500
4501        multi_workspace.update_in(cx, |mw, _, cx| {
4502            mw.set_random_database_id(cx);
4503        });
4504
4505        let task =
4506            multi_workspace.update_in(cx, |mw, window, cx| mw.create_test_workspace(window, cx));
4507        task.await;
4508
4509        let new_workspace_db_id =
4510            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4511        assert!(
4512            new_workspace_db_id.is_some(),
4513            "After run_until_parked, the workspace should have a database_id"
4514        );
4515
4516        let workspace_id = new_workspace_db_id.unwrap();
4517
4518        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4519
4520        assert!(
4521            db.workspace_for_id(workspace_id).is_some(),
4522            "The workspace row should exist in the DB"
4523        );
4524
4525        cx.simulate_resize(gpui::size(px(1024.0), px(768.0)));
4526
4527        // Advance the clock past the 100ms debounce timer so the bounds
4528        // observer task fires
4529        cx.executor().advance_clock(Duration::from_millis(200));
4530        cx.run_until_parked();
4531
4532        let serialized = db
4533            .workspace_for_id(workspace_id)
4534            .expect("workspace row should still exist");
4535        assert!(
4536            serialized.window_bounds.is_some(),
4537            "The bounds observer should write bounds for the workspace's real DB ID, \
4538             even when the workspace was created via create_workspace (where the ID \
4539             is assigned asynchronously after construction)."
4540        );
4541    }
4542
4543    #[gpui::test]
4544    async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) {
4545        crate::tests::init_test(cx);
4546
4547        cx.update(|cx| {
4548            cx.set_staff(true);
4549        });
4550
4551        let fs = fs::FakeFs::new(cx.executor());
4552        let dir = tempfile::TempDir::with_prefix("flush_bounds_test").unwrap();
4553        fs.insert_tree(dir.path(), json!({})).await;
4554
4555        let project = Project::test(fs.clone(), [dir.path()], cx).await;
4556
4557        let (multi_workspace, cx) =
4558            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4559
4560        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4561        let workspace_id = db.next_id().await.unwrap();
4562        multi_workspace.update_in(cx, |mw, _, cx| {
4563            mw.workspace().update(cx, |ws, _cx| {
4564                ws.set_database_id(workspace_id);
4565            });
4566        });
4567
4568        let task = multi_workspace.update_in(cx, |mw, window, cx| {
4569            mw.workspace()
4570                .update(cx, |ws, cx| ws.flush_serialization(window, cx))
4571        });
4572        task.await;
4573
4574        let after = db
4575            .workspace_for_id(workspace_id)
4576            .expect("workspace row should exist after flush_serialization");
4577        assert!(
4578            !after.paths.is_empty(),
4579            "flush_serialization should have written paths via save_workspace"
4580        );
4581        assert!(
4582            after.window_bounds.is_some(),
4583            "flush_serialization should ensure window bounds are persisted to the DB \
4584             before the process exits."
4585        );
4586    }
4587
4588    #[gpui::test]
4589    async fn test_resolve_worktree_workspaces(cx: &mut gpui::TestAppContext) {
4590        let fs = fs::FakeFs::new(cx.executor());
4591
4592        // Main repo with a linked worktree entry
4593        fs.insert_tree(
4594            "/repo",
4595            json!({
4596                ".git": {
4597                    "worktrees": {
4598                        "feature": {
4599                            "commondir": "../../",
4600                            "HEAD": "ref: refs/heads/feature"
4601                        }
4602                    }
4603                },
4604                "src": { "main.rs": "" }
4605            }),
4606        )
4607        .await;
4608
4609        // Linked worktree checkout pointing back to /repo
4610        fs.insert_tree(
4611            "/worktree",
4612            json!({
4613                ".git": "gitdir: /repo/.git/worktrees/feature",
4614                "src": { "main.rs": "" }
4615            }),
4616        )
4617        .await;
4618
4619        // A plain non-git project
4620        fs.insert_tree(
4621            "/plain-project",
4622            json!({
4623                "src": { "main.rs": "" }
4624            }),
4625        )
4626        .await;
4627
4628        // Another normal git repo (used in mixed-path entry)
4629        fs.insert_tree(
4630            "/other-repo",
4631            json!({
4632                ".git": {},
4633                "src": { "lib.rs": "" }
4634            }),
4635        )
4636        .await;
4637
4638        let t0 = Utc::now() - chrono::Duration::hours(4);
4639        let t1 = Utc::now() - chrono::Duration::hours(3);
4640        let t2 = Utc::now() - chrono::Duration::hours(2);
4641        let t3 = Utc::now() - chrono::Duration::hours(1);
4642
4643        let workspaces = vec![
4644            // 1: Main checkout of /repo (opened earlier)
4645            (
4646                WorkspaceId(1),
4647                SerializedWorkspaceLocation::Local,
4648                PathList::new(&["/repo"]),
4649                t0,
4650            ),
4651            // 2: Linked worktree of /repo (opened more recently)
4652            //    Should dedup with #1; more recent timestamp wins.
4653            (
4654                WorkspaceId(2),
4655                SerializedWorkspaceLocation::Local,
4656                PathList::new(&["/worktree"]),
4657                t1,
4658            ),
4659            // 3: Mixed-path workspace: one root is a linked worktree,
4660            //    the other is a normal repo. The worktree path should be
4661            //    resolved; the normal path kept as-is.
4662            (
4663                WorkspaceId(3),
4664                SerializedWorkspaceLocation::Local,
4665                PathList::new(&["/other-repo", "/worktree"]),
4666                t2,
4667            ),
4668            // 4: Non-git project — passed through unchanged.
4669            (
4670                WorkspaceId(4),
4671                SerializedWorkspaceLocation::Local,
4672                PathList::new(&["/plain-project"]),
4673                t3,
4674            ),
4675        ];
4676
4677        let result = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
4678
4679        // Should have 3 entries: #1 and #2 deduped into one, plus #3 and #4.
4680        assert_eq!(result.len(), 3);
4681
4682        // First entry: /repo — deduplicated from #1 and #2.
4683        // Keeps the position of #1 (first seen), but with #2's later timestamp.
4684        assert_eq!(result[0].2.paths(), &[PathBuf::from("/repo")]);
4685        assert_eq!(result[0].3, t1);
4686
4687        // Second entry: mixed-path workspace with worktree resolved.
4688        // /worktree → /repo, so paths become [/other-repo, /repo] (sorted).
4689        assert_eq!(
4690            result[1].2.paths(),
4691            &[PathBuf::from("/other-repo"), PathBuf::from("/repo")]
4692        );
4693        assert_eq!(result[1].0, WorkspaceId(3));
4694
4695        // Third entry: non-git project, unchanged.
4696        assert_eq!(result[2].2.paths(), &[PathBuf::from("/plain-project")]);
4697        assert_eq!(result[2].0, WorkspaceId(4));
4698    }
4699
4700    #[gpui::test]
4701    async fn test_restore_window_with_linked_worktree_and_multiple_project_groups(
4702        cx: &mut gpui::TestAppContext,
4703    ) {
4704        crate::tests::init_test(cx);
4705
4706        cx.update(|cx| {
4707            cx.set_staff(true);
4708        });
4709
4710        let fs = fs::FakeFs::new(cx.executor());
4711
4712        // Main git repo at /repo
4713        fs.insert_tree(
4714            "/repo",
4715            json!({
4716                ".git": {
4717                    "HEAD": "ref: refs/heads/main",
4718                    "worktrees": {
4719                        "feature": {
4720                            "commondir": "../../",
4721                            "HEAD": "ref: refs/heads/feature"
4722                        }
4723                    }
4724                },
4725                "src": { "main.rs": "" }
4726            }),
4727        )
4728        .await;
4729
4730        // Linked worktree checkout pointing back to /repo
4731        fs.insert_tree(
4732            "/worktree-feature",
4733            json!({
4734                ".git": "gitdir: /repo/.git/worktrees/feature",
4735                "src": { "lib.rs": "" }
4736            }),
4737        )
4738        .await;
4739
4740        // --- Phase 1: Set up the original multi-workspace window ---
4741
4742        let project_1 = Project::test(fs.clone(), ["/repo".as_ref()], cx).await;
4743        let project_1_linked_worktree =
4744            Project::test(fs.clone(), ["/worktree-feature".as_ref()], cx).await;
4745
4746        // Wait for git discovery to finish.
4747        cx.run_until_parked();
4748
4749        // Create a second, unrelated project so we have two distinct project groups.
4750        fs.insert_tree(
4751            "/other-project",
4752            json!({
4753                ".git": { "HEAD": "ref: refs/heads/main" },
4754                "readme.md": ""
4755            }),
4756        )
4757        .await;
4758        let project_2 = Project::test(fs.clone(), ["/other-project".as_ref()], cx).await;
4759        cx.run_until_parked();
4760
4761        // Create the MultiWorkspace with project_2, then add the main repo
4762        // and its linked worktree. The linked worktree is added last and
4763        // becomes the active workspace.
4764        let (multi_workspace, cx) = cx
4765            .add_window_view(|window, cx| MultiWorkspace::test_new(project_2.clone(), window, cx));
4766
4767        multi_workspace.update(cx, |mw, cx| {
4768            mw.open_sidebar(cx);
4769        });
4770
4771        multi_workspace.update_in(cx, |mw, window, cx| {
4772            mw.test_add_workspace(project_1.clone(), window, cx);
4773        });
4774
4775        let workspace_worktree = multi_workspace.update_in(cx, |mw, window, cx| {
4776            mw.test_add_workspace(project_1_linked_worktree.clone(), window, cx)
4777        });
4778
4779        let tasks =
4780            multi_workspace.update_in(cx, |mw, window, cx| mw.flush_all_serialization(window, cx));
4781        cx.run_until_parked();
4782        for task in tasks {
4783            task.await;
4784        }
4785        cx.run_until_parked();
4786
4787        let active_db_id = workspace_worktree.read_with(cx, |ws, _| ws.database_id());
4788        assert!(
4789            active_db_id.is_some(),
4790            "Active workspace should have a database ID"
4791        );
4792
4793        // --- Phase 2: Read back and verify the serialized state ---
4794
4795        let session_id = multi_workspace
4796            .read_with(cx, |mw, cx| mw.workspace().read(cx).session_id())
4797            .unwrap();
4798        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4799        let session_workspaces = db
4800            .last_session_workspace_locations(&session_id, None, fs.as_ref())
4801            .await
4802            .expect("should load session workspaces");
4803        assert!(
4804            !session_workspaces.is_empty(),
4805            "Should have at least one session workspace"
4806        );
4807
4808        let multi_workspaces =
4809            cx.update(|_, cx| read_serialized_multi_workspaces(session_workspaces, cx));
4810        assert_eq!(
4811            multi_workspaces.len(),
4812            1,
4813            "All workspaces share one window, so there should be exactly one multi-workspace"
4814        );
4815
4816        let serialized = &multi_workspaces[0];
4817        assert_eq!(
4818            serialized.active_workspace.workspace_id,
4819            active_db_id.unwrap(),
4820        );
4821        assert_eq!(serialized.state.project_groups.len(), 2,);
4822
4823        // Verify the serialized project group keys round-trip back to the
4824        // originals.
4825        let restored_keys: Vec<ProjectGroupKey> = serialized
4826            .state
4827            .project_groups
4828            .iter()
4829            .cloned()
4830            .map(Into::into)
4831            .collect();
4832        let expected_keys = vec![
4833            ProjectGroupKey::new(None, PathList::new(&["/repo"])),
4834            ProjectGroupKey::new(None, PathList::new(&["/other-project"])),
4835        ];
4836        assert_eq!(
4837            restored_keys, expected_keys,
4838            "Deserialized project group keys should match the originals"
4839        );
4840
4841        // --- Phase 3: Restore the window and verify the result ---
4842
4843        let app_state =
4844            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).app_state().clone());
4845
4846        let serialized_mw = multi_workspaces.into_iter().next().unwrap();
4847        let restored_handle: gpui::WindowHandle<MultiWorkspace> = cx
4848            .update(|_, cx| {
4849                cx.spawn(async move |mut cx| {
4850                    crate::restore_multiworkspace(serialized_mw, app_state, &mut cx).await
4851                })
4852            })
4853            .await
4854            .expect("restore_multiworkspace should succeed");
4855
4856        cx.run_until_parked();
4857
4858        // The restored window should have the same project group keys.
4859        let restored_keys: Vec<ProjectGroupKey> = restored_handle
4860            .read_with(cx, |mw: &MultiWorkspace, _cx| mw.project_group_keys())
4861            .unwrap();
4862        assert_eq!(
4863            restored_keys, expected_keys,
4864            "Restored window should have the same project group keys as the original"
4865        );
4866
4867        // The active workspace in the restored window should have the linked
4868        // worktree paths.
4869        let active_paths: Vec<PathBuf> = restored_handle
4870            .read_with(cx, |mw: &MultiWorkspace, cx| {
4871                mw.workspace()
4872                    .read(cx)
4873                    .root_paths(cx)
4874                    .into_iter()
4875                    .map(|p: Arc<Path>| p.to_path_buf())
4876                    .collect()
4877            })
4878            .unwrap();
4879        assert_eq!(
4880            active_paths,
4881            vec![PathBuf::from("/worktree-feature")],
4882            "The restored active workspace should be the linked worktree project"
4883        );
4884    }
4885
4886    #[gpui::test]
4887    async fn test_remove_project_group_falls_back_to_neighbor(cx: &mut gpui::TestAppContext) {
4888        crate::tests::init_test(cx);
4889        cx.update(|cx| {
4890            cx.set_staff(true);
4891            cx.update_flags(true, vec!["agent-v2".to_string()]);
4892        });
4893
4894        let fs = fs::FakeFs::new(cx.executor());
4895        let dir_a = unique_test_dir(&fs, "group-a").await;
4896        let dir_b = unique_test_dir(&fs, "group-b").await;
4897        let dir_c = unique_test_dir(&fs, "group-c").await;
4898
4899        let project_a = Project::test(fs.clone(), [dir_a.as_path()], cx).await;
4900        let project_b = Project::test(fs.clone(), [dir_b.as_path()], cx).await;
4901        let project_c = Project::test(fs.clone(), [dir_c.as_path()], cx).await;
4902
4903        // Create a multi-workspace with project A, then add B and C.
4904        // project_groups stores newest first: [C, B, A].
4905        // Sidebar displays in the same order: C (top), B (middle), A (bottom).
4906        let (multi_workspace, cx) = cx
4907            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4908
4909        multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
4910
4911        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4912            mw.test_add_workspace(project_b.clone(), window, cx)
4913        });
4914        let _workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
4915            mw.test_add_workspace(project_c.clone(), window, cx)
4916        });
4917        cx.run_until_parked();
4918
4919        let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
4920        let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
4921        let key_c = project_c.read_with(cx, |p, cx| p.project_group_key(cx));
4922
4923        // Activate workspace B so removing its group exercises the fallback.
4924        multi_workspace.update_in(cx, |mw, window, cx| {
4925            mw.activate(workspace_b.clone(), window, cx);
4926        });
4927        cx.run_until_parked();
4928
4929        // --- Remove group B (the middle one). ---
4930        // In the sidebar [C, B, A], "below" B is A.
4931        multi_workspace.update_in(cx, |mw, window, cx| {
4932            mw.remove_project_group(&key_b, window, cx)
4933                .detach_and_log_err(cx);
4934        });
4935        cx.run_until_parked();
4936
4937        let active_paths =
4938            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4939        assert_eq!(
4940            active_paths
4941                .iter()
4942                .map(|p| p.to_path_buf())
4943                .collect::<Vec<_>>(),
4944            vec![dir_a.clone()],
4945            "After removing the middle group, should fall back to the group below (A)"
4946        );
4947
4948        // After removing B, keys = [A, C], sidebar = [C, A].
4949        // Activate workspace A (the bottom) so removing it tests the
4950        // "fall back upward" path.
4951        let workspace_a =
4952            multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
4953        multi_workspace.update_in(cx, |mw, window, cx| {
4954            mw.activate(workspace_a.clone(), window, cx);
4955        });
4956        cx.run_until_parked();
4957
4958        // --- Remove group A (the bottom one in sidebar). ---
4959        // Nothing below A, so should fall back upward to C.
4960        multi_workspace.update_in(cx, |mw, window, cx| {
4961            mw.remove_project_group(&key_a, window, cx)
4962                .detach_and_log_err(cx);
4963        });
4964        cx.run_until_parked();
4965
4966        let active_paths =
4967            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4968        assert_eq!(
4969            active_paths
4970                .iter()
4971                .map(|p| p.to_path_buf())
4972                .collect::<Vec<_>>(),
4973            vec![dir_c.clone()],
4974            "After removing the bottom group, should fall back to the group above (C)"
4975        );
4976
4977        // --- Remove group C (the only one remaining). ---
4978        // Should create an empty workspace.
4979        multi_workspace.update_in(cx, |mw, window, cx| {
4980            mw.remove_project_group(&key_c, window, cx)
4981                .detach_and_log_err(cx);
4982        });
4983        cx.run_until_parked();
4984
4985        let active_paths =
4986            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4987        assert!(
4988            active_paths.is_empty(),
4989            "After removing the only remaining group, should have an empty workspace"
4990        );
4991    }
4992}