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::PathList;
2499    use crate::ProjectGroupKey;
2500    use crate::{
2501        multi_workspace::MultiWorkspace,
2502        persistence::{
2503            model::{
2504                SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
2505                SessionWorkspace,
2506            },
2507            read_multi_workspace_state,
2508        },
2509    };
2510    use feature_flags::FeatureFlagAppExt;
2511    use gpui::AppContext as _;
2512    use pretty_assertions::assert_eq;
2513    use project::Project;
2514    use remote::SshConnectionOptions;
2515    use serde_json::json;
2516    use std::{thread, time::Duration};
2517
2518    /// Creates a unique directory in a FakeFs, returning the path.
2519    /// Uses a UUID suffix to avoid collisions with other tests sharing the global DB.
2520    async fn unique_test_dir(fs: &fs::FakeFs, prefix: &str) -> PathBuf {
2521        let dir = PathBuf::from(format!("/test-dirs/{}-{}", prefix, uuid::Uuid::new_v4()));
2522        fs.insert_tree(&dir, json!({})).await;
2523        dir
2524    }
2525
2526    #[gpui::test]
2527    async fn test_multi_workspace_serializes_on_add_and_remove(cx: &mut gpui::TestAppContext) {
2528        crate::tests::init_test(cx);
2529
2530        cx.update(|cx| {
2531            cx.set_staff(true);
2532        });
2533
2534        let fs = fs::FakeFs::new(cx.executor());
2535        let project1 = Project::test(fs.clone(), [], cx).await;
2536        let project2 = Project::test(fs.clone(), [], cx).await;
2537
2538        let (multi_workspace, cx) =
2539            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
2540
2541        multi_workspace.update(cx, |mw, cx| {
2542            mw.open_sidebar(cx);
2543        });
2544
2545        multi_workspace.update_in(cx, |mw, _, cx| {
2546            mw.set_random_database_id(cx);
2547        });
2548
2549        let window_id =
2550            multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
2551
2552        // --- Add a second workspace ---
2553        let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
2554            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
2555            workspace.update(cx, |ws, _cx| ws.set_random_database_id());
2556            mw.activate(workspace.clone(), window, cx);
2557            workspace
2558        });
2559
2560        // Run background tasks so serialize has a chance to flush.
2561        cx.run_until_parked();
2562
2563        // Read back the persisted state and check that the active workspace ID was written.
2564        let state_after_add = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
2565        let active_workspace2_db_id = workspace2.read_with(cx, |ws, _| ws.database_id());
2566        assert_eq!(
2567            state_after_add.active_workspace_id, active_workspace2_db_id,
2568            "After adding a second workspace, the serialized active_workspace_id should match \
2569             the newly activated workspace's database id"
2570        );
2571
2572        // --- Remove the non-active workspace ---
2573        multi_workspace.update_in(cx, |mw, _window, cx| {
2574            let active = mw.workspace().clone();
2575            let ws = mw
2576                .workspaces()
2577                .find(|ws| *ws != &active)
2578                .expect("should have a non-active workspace");
2579            mw.remove([ws.clone()], |_, _, _| unreachable!(), _window, cx)
2580                .detach_and_log_err(cx);
2581        });
2582
2583        cx.run_until_parked();
2584
2585        let state_after_remove = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
2586        let remaining_db_id =
2587            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
2588        assert_eq!(
2589            state_after_remove.active_workspace_id, remaining_db_id,
2590            "After removing a workspace, the serialized active_workspace_id should match \
2591             the remaining active workspace's database id"
2592        );
2593    }
2594
2595    #[gpui::test]
2596    async fn test_breakpoints() {
2597        zlog::init_test();
2598
2599        let db = WorkspaceDb::open_test_db("test_breakpoints").await;
2600        let id = db.next_id().await.unwrap();
2601
2602        let path = Path::new("/tmp/test.rs");
2603
2604        let breakpoint = Breakpoint {
2605            position: 123,
2606            message: None,
2607            state: BreakpointState::Enabled,
2608            condition: None,
2609            hit_condition: None,
2610        };
2611
2612        let log_breakpoint = Breakpoint {
2613            position: 456,
2614            message: Some("Test log message".into()),
2615            state: BreakpointState::Enabled,
2616            condition: None,
2617            hit_condition: None,
2618        };
2619
2620        let disable_breakpoint = Breakpoint {
2621            position: 578,
2622            message: None,
2623            state: BreakpointState::Disabled,
2624            condition: None,
2625            hit_condition: None,
2626        };
2627
2628        let condition_breakpoint = Breakpoint {
2629            position: 789,
2630            message: None,
2631            state: BreakpointState::Enabled,
2632            condition: Some("x > 5".into()),
2633            hit_condition: None,
2634        };
2635
2636        let hit_condition_breakpoint = Breakpoint {
2637            position: 999,
2638            message: None,
2639            state: BreakpointState::Enabled,
2640            condition: None,
2641            hit_condition: Some(">= 3".into()),
2642        };
2643
2644        let workspace = SerializedWorkspace {
2645            id,
2646            paths: PathList::new(&["/tmp"]),
2647            location: SerializedWorkspaceLocation::Local,
2648            center_group: Default::default(),
2649            window_bounds: Default::default(),
2650            display: Default::default(),
2651            docks: Default::default(),
2652            centered_layout: false,
2653            breakpoints: {
2654                let mut map = collections::BTreeMap::default();
2655                map.insert(
2656                    Arc::from(path),
2657                    vec![
2658                        SourceBreakpoint {
2659                            row: breakpoint.position,
2660                            path: Arc::from(path),
2661                            message: breakpoint.message.clone(),
2662                            state: breakpoint.state,
2663                            condition: breakpoint.condition.clone(),
2664                            hit_condition: breakpoint.hit_condition.clone(),
2665                        },
2666                        SourceBreakpoint {
2667                            row: log_breakpoint.position,
2668                            path: Arc::from(path),
2669                            message: log_breakpoint.message.clone(),
2670                            state: log_breakpoint.state,
2671                            condition: log_breakpoint.condition.clone(),
2672                            hit_condition: log_breakpoint.hit_condition.clone(),
2673                        },
2674                        SourceBreakpoint {
2675                            row: disable_breakpoint.position,
2676                            path: Arc::from(path),
2677                            message: disable_breakpoint.message.clone(),
2678                            state: disable_breakpoint.state,
2679                            condition: disable_breakpoint.condition.clone(),
2680                            hit_condition: disable_breakpoint.hit_condition.clone(),
2681                        },
2682                        SourceBreakpoint {
2683                            row: condition_breakpoint.position,
2684                            path: Arc::from(path),
2685                            message: condition_breakpoint.message.clone(),
2686                            state: condition_breakpoint.state,
2687                            condition: condition_breakpoint.condition.clone(),
2688                            hit_condition: condition_breakpoint.hit_condition.clone(),
2689                        },
2690                        SourceBreakpoint {
2691                            row: hit_condition_breakpoint.position,
2692                            path: Arc::from(path),
2693                            message: hit_condition_breakpoint.message.clone(),
2694                            state: hit_condition_breakpoint.state,
2695                            condition: hit_condition_breakpoint.condition.clone(),
2696                            hit_condition: hit_condition_breakpoint.hit_condition.clone(),
2697                        },
2698                    ],
2699                );
2700                map
2701            },
2702            session_id: None,
2703            window_id: None,
2704            user_toolchains: Default::default(),
2705        };
2706
2707        db.save_workspace(workspace.clone()).await;
2708
2709        let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2710        let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap();
2711
2712        assert_eq!(loaded_breakpoints.len(), 5);
2713
2714        // normal breakpoint
2715        assert_eq!(loaded_breakpoints[0].row, breakpoint.position);
2716        assert_eq!(loaded_breakpoints[0].message, breakpoint.message);
2717        assert_eq!(loaded_breakpoints[0].condition, breakpoint.condition);
2718        assert_eq!(
2719            loaded_breakpoints[0].hit_condition,
2720            breakpoint.hit_condition
2721        );
2722        assert_eq!(loaded_breakpoints[0].state, breakpoint.state);
2723        assert_eq!(loaded_breakpoints[0].path, Arc::from(path));
2724
2725        // enabled breakpoint
2726        assert_eq!(loaded_breakpoints[1].row, log_breakpoint.position);
2727        assert_eq!(loaded_breakpoints[1].message, log_breakpoint.message);
2728        assert_eq!(loaded_breakpoints[1].condition, log_breakpoint.condition);
2729        assert_eq!(
2730            loaded_breakpoints[1].hit_condition,
2731            log_breakpoint.hit_condition
2732        );
2733        assert_eq!(loaded_breakpoints[1].state, log_breakpoint.state);
2734        assert_eq!(loaded_breakpoints[1].path, Arc::from(path));
2735
2736        // disable breakpoint
2737        assert_eq!(loaded_breakpoints[2].row, disable_breakpoint.position);
2738        assert_eq!(loaded_breakpoints[2].message, disable_breakpoint.message);
2739        assert_eq!(
2740            loaded_breakpoints[2].condition,
2741            disable_breakpoint.condition
2742        );
2743        assert_eq!(
2744            loaded_breakpoints[2].hit_condition,
2745            disable_breakpoint.hit_condition
2746        );
2747        assert_eq!(loaded_breakpoints[2].state, disable_breakpoint.state);
2748        assert_eq!(loaded_breakpoints[2].path, Arc::from(path));
2749
2750        // condition breakpoint
2751        assert_eq!(loaded_breakpoints[3].row, condition_breakpoint.position);
2752        assert_eq!(loaded_breakpoints[3].message, condition_breakpoint.message);
2753        assert_eq!(
2754            loaded_breakpoints[3].condition,
2755            condition_breakpoint.condition
2756        );
2757        assert_eq!(
2758            loaded_breakpoints[3].hit_condition,
2759            condition_breakpoint.hit_condition
2760        );
2761        assert_eq!(loaded_breakpoints[3].state, condition_breakpoint.state);
2762        assert_eq!(loaded_breakpoints[3].path, Arc::from(path));
2763
2764        // hit condition breakpoint
2765        assert_eq!(loaded_breakpoints[4].row, hit_condition_breakpoint.position);
2766        assert_eq!(
2767            loaded_breakpoints[4].message,
2768            hit_condition_breakpoint.message
2769        );
2770        assert_eq!(
2771            loaded_breakpoints[4].condition,
2772            hit_condition_breakpoint.condition
2773        );
2774        assert_eq!(
2775            loaded_breakpoints[4].hit_condition,
2776            hit_condition_breakpoint.hit_condition
2777        );
2778        assert_eq!(loaded_breakpoints[4].state, hit_condition_breakpoint.state);
2779        assert_eq!(loaded_breakpoints[4].path, Arc::from(path));
2780    }
2781
2782    #[gpui::test]
2783    async fn test_remove_last_breakpoint() {
2784        zlog::init_test();
2785
2786        let db = WorkspaceDb::open_test_db("test_remove_last_breakpoint").await;
2787        let id = db.next_id().await.unwrap();
2788
2789        let singular_path = Path::new("/tmp/test_remove_last_breakpoint.rs");
2790
2791        let breakpoint_to_remove = Breakpoint {
2792            position: 100,
2793            message: None,
2794            state: BreakpointState::Enabled,
2795            condition: None,
2796            hit_condition: None,
2797        };
2798
2799        let workspace = SerializedWorkspace {
2800            id,
2801            paths: PathList::new(&["/tmp"]),
2802            location: SerializedWorkspaceLocation::Local,
2803            center_group: Default::default(),
2804            window_bounds: Default::default(),
2805            display: Default::default(),
2806            docks: Default::default(),
2807            centered_layout: false,
2808            breakpoints: {
2809                let mut map = collections::BTreeMap::default();
2810                map.insert(
2811                    Arc::from(singular_path),
2812                    vec![SourceBreakpoint {
2813                        row: breakpoint_to_remove.position,
2814                        path: Arc::from(singular_path),
2815                        message: None,
2816                        state: BreakpointState::Enabled,
2817                        condition: None,
2818                        hit_condition: None,
2819                    }],
2820                );
2821                map
2822            },
2823            session_id: None,
2824            window_id: None,
2825            user_toolchains: Default::default(),
2826        };
2827
2828        db.save_workspace(workspace.clone()).await;
2829
2830        let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2831        let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(singular_path)).unwrap();
2832
2833        assert_eq!(loaded_breakpoints.len(), 1);
2834        assert_eq!(loaded_breakpoints[0].row, breakpoint_to_remove.position);
2835        assert_eq!(loaded_breakpoints[0].message, breakpoint_to_remove.message);
2836        assert_eq!(
2837            loaded_breakpoints[0].condition,
2838            breakpoint_to_remove.condition
2839        );
2840        assert_eq!(
2841            loaded_breakpoints[0].hit_condition,
2842            breakpoint_to_remove.hit_condition
2843        );
2844        assert_eq!(loaded_breakpoints[0].state, breakpoint_to_remove.state);
2845        assert_eq!(loaded_breakpoints[0].path, Arc::from(singular_path));
2846
2847        let workspace_without_breakpoint = SerializedWorkspace {
2848            id,
2849            paths: PathList::new(&["/tmp"]),
2850            location: SerializedWorkspaceLocation::Local,
2851            center_group: Default::default(),
2852            window_bounds: Default::default(),
2853            display: Default::default(),
2854            docks: Default::default(),
2855            centered_layout: false,
2856            breakpoints: collections::BTreeMap::default(),
2857            session_id: None,
2858            window_id: None,
2859            user_toolchains: Default::default(),
2860        };
2861
2862        db.save_workspace(workspace_without_breakpoint.clone())
2863            .await;
2864
2865        let loaded_after_remove = db.workspace_for_roots(&["/tmp"]).unwrap();
2866        let empty_breakpoints = loaded_after_remove
2867            .breakpoints
2868            .get(&Arc::from(singular_path));
2869
2870        assert!(empty_breakpoints.is_none());
2871    }
2872
2873    #[gpui::test]
2874    async fn test_next_id_stability() {
2875        zlog::init_test();
2876
2877        let db = WorkspaceDb::open_test_db("test_next_id_stability").await;
2878
2879        db.write(|conn| {
2880            conn.migrate(
2881                "test_table",
2882                &[sql!(
2883                    CREATE TABLE test_table(
2884                        text TEXT,
2885                        workspace_id INTEGER,
2886                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2887                        ON DELETE CASCADE
2888                    ) STRICT;
2889                )],
2890                &mut |_, _, _| false,
2891            )
2892            .unwrap();
2893        })
2894        .await;
2895
2896        let id = db.next_id().await.unwrap();
2897        // Assert the empty row got inserted
2898        assert_eq!(
2899            Some(id),
2900            db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
2901                SELECT workspace_id FROM workspaces WHERE workspace_id = ?
2902            ))
2903            .unwrap()(id)
2904            .unwrap()
2905        );
2906
2907        db.write(move |conn| {
2908            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2909                .unwrap()(("test-text-1", id))
2910            .unwrap()
2911        })
2912        .await;
2913
2914        let test_text_1 = db
2915            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2916            .unwrap()(1)
2917        .unwrap()
2918        .unwrap();
2919        assert_eq!(test_text_1, "test-text-1");
2920    }
2921
2922    #[gpui::test]
2923    async fn test_workspace_id_stability() {
2924        zlog::init_test();
2925
2926        let db = WorkspaceDb::open_test_db("test_workspace_id_stability").await;
2927
2928        db.write(|conn| {
2929            conn.migrate(
2930                "test_table",
2931                &[sql!(
2932                        CREATE TABLE test_table(
2933                            text TEXT,
2934                            workspace_id INTEGER,
2935                            FOREIGN KEY(workspace_id)
2936                                REFERENCES workspaces(workspace_id)
2937                            ON DELETE CASCADE
2938                        ) STRICT;)],
2939                &mut |_, _, _| false,
2940            )
2941        })
2942        .await
2943        .unwrap();
2944
2945        let mut workspace_1 = SerializedWorkspace {
2946            id: WorkspaceId(1),
2947            paths: PathList::new(&["/tmp", "/tmp2"]),
2948            location: SerializedWorkspaceLocation::Local,
2949            center_group: Default::default(),
2950            window_bounds: Default::default(),
2951            display: Default::default(),
2952            docks: Default::default(),
2953            centered_layout: false,
2954            breakpoints: Default::default(),
2955            session_id: None,
2956            window_id: None,
2957            user_toolchains: Default::default(),
2958        };
2959
2960        let workspace_2 = SerializedWorkspace {
2961            id: WorkspaceId(2),
2962            paths: PathList::new(&["/tmp"]),
2963            location: SerializedWorkspaceLocation::Local,
2964            center_group: Default::default(),
2965            window_bounds: Default::default(),
2966            display: Default::default(),
2967            docks: Default::default(),
2968            centered_layout: false,
2969            breakpoints: Default::default(),
2970            session_id: None,
2971            window_id: None,
2972            user_toolchains: Default::default(),
2973        };
2974
2975        db.save_workspace(workspace_1.clone()).await;
2976
2977        db.write(|conn| {
2978            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2979                .unwrap()(("test-text-1", 1))
2980            .unwrap();
2981        })
2982        .await;
2983
2984        db.save_workspace(workspace_2.clone()).await;
2985
2986        db.write(|conn| {
2987            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2988                .unwrap()(("test-text-2", 2))
2989            .unwrap();
2990        })
2991        .await;
2992
2993        workspace_1.paths = PathList::new(&["/tmp", "/tmp3"]);
2994        db.save_workspace(workspace_1.clone()).await;
2995        db.save_workspace(workspace_1).await;
2996        db.save_workspace(workspace_2).await;
2997
2998        let test_text_2 = db
2999            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
3000            .unwrap()(2)
3001        .unwrap()
3002        .unwrap();
3003        assert_eq!(test_text_2, "test-text-2");
3004
3005        let test_text_1 = db
3006            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
3007            .unwrap()(1)
3008        .unwrap()
3009        .unwrap();
3010        assert_eq!(test_text_1, "test-text-1");
3011    }
3012
3013    fn group(axis: Axis, children: Vec<SerializedPaneGroup>) -> SerializedPaneGroup {
3014        SerializedPaneGroup::Group {
3015            axis: SerializedAxis(axis),
3016            flexes: None,
3017            children,
3018        }
3019    }
3020
3021    #[gpui::test]
3022    async fn test_full_workspace_serialization() {
3023        zlog::init_test();
3024
3025        let db = WorkspaceDb::open_test_db("test_full_workspace_serialization").await;
3026
3027        //  -----------------
3028        //  | 1,2   | 5,6   |
3029        //  | - - - |       |
3030        //  | 3,4   |       |
3031        //  -----------------
3032        let center_group = group(
3033            Axis::Horizontal,
3034            vec![
3035                group(
3036                    Axis::Vertical,
3037                    vec![
3038                        SerializedPaneGroup::Pane(SerializedPane::new(
3039                            vec![
3040                                SerializedItem::new("Terminal", 5, false, false),
3041                                SerializedItem::new("Terminal", 6, true, false),
3042                            ],
3043                            false,
3044                            0,
3045                        )),
3046                        SerializedPaneGroup::Pane(SerializedPane::new(
3047                            vec![
3048                                SerializedItem::new("Terminal", 7, true, false),
3049                                SerializedItem::new("Terminal", 8, false, false),
3050                            ],
3051                            false,
3052                            0,
3053                        )),
3054                    ],
3055                ),
3056                SerializedPaneGroup::Pane(SerializedPane::new(
3057                    vec![
3058                        SerializedItem::new("Terminal", 9, false, false),
3059                        SerializedItem::new("Terminal", 10, true, false),
3060                    ],
3061                    false,
3062                    0,
3063                )),
3064            ],
3065        );
3066
3067        let workspace = SerializedWorkspace {
3068            id: WorkspaceId(5),
3069            paths: PathList::new(&["/tmp", "/tmp2"]),
3070            location: SerializedWorkspaceLocation::Local,
3071            center_group,
3072            window_bounds: Default::default(),
3073            breakpoints: Default::default(),
3074            display: Default::default(),
3075            docks: Default::default(),
3076            centered_layout: false,
3077            session_id: None,
3078            window_id: Some(999),
3079            user_toolchains: Default::default(),
3080        };
3081
3082        db.save_workspace(workspace.clone()).await;
3083
3084        let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
3085        assert_eq!(workspace, round_trip_workspace.unwrap());
3086
3087        // Test guaranteed duplicate IDs
3088        db.save_workspace(workspace.clone()).await;
3089        db.save_workspace(workspace.clone()).await;
3090
3091        let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
3092        assert_eq!(workspace, round_trip_workspace.unwrap());
3093    }
3094
3095    #[gpui::test]
3096    async fn test_workspace_assignment() {
3097        zlog::init_test();
3098
3099        let db = WorkspaceDb::open_test_db("test_basic_functionality").await;
3100
3101        let workspace_1 = SerializedWorkspace {
3102            id: WorkspaceId(1),
3103            paths: PathList::new(&["/tmp", "/tmp2"]),
3104            location: SerializedWorkspaceLocation::Local,
3105            center_group: Default::default(),
3106            window_bounds: Default::default(),
3107            breakpoints: Default::default(),
3108            display: Default::default(),
3109            docks: Default::default(),
3110            centered_layout: false,
3111            session_id: None,
3112            window_id: Some(1),
3113            user_toolchains: Default::default(),
3114        };
3115
3116        let mut workspace_2 = SerializedWorkspace {
3117            id: WorkspaceId(2),
3118            paths: PathList::new(&["/tmp"]),
3119            location: SerializedWorkspaceLocation::Local,
3120            center_group: Default::default(),
3121            window_bounds: Default::default(),
3122            display: Default::default(),
3123            docks: Default::default(),
3124            centered_layout: false,
3125            breakpoints: Default::default(),
3126            session_id: None,
3127            window_id: Some(2),
3128            user_toolchains: Default::default(),
3129        };
3130
3131        db.save_workspace(workspace_1.clone()).await;
3132        db.save_workspace(workspace_2.clone()).await;
3133
3134        // Test that paths are treated as a set
3135        assert_eq!(
3136            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3137            workspace_1
3138        );
3139        assert_eq!(
3140            db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
3141            workspace_1
3142        );
3143
3144        // Make sure that other keys work
3145        assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
3146        assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
3147
3148        // Test 'mutate' case of updating a pre-existing id
3149        workspace_2.paths = PathList::new(&["/tmp", "/tmp2"]);
3150
3151        db.save_workspace(workspace_2.clone()).await;
3152        assert_eq!(
3153            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3154            workspace_2
3155        );
3156
3157        // Test other mechanism for mutating
3158        let mut workspace_3 = SerializedWorkspace {
3159            id: WorkspaceId(3),
3160            paths: PathList::new(&["/tmp2", "/tmp"]),
3161            location: SerializedWorkspaceLocation::Local,
3162            center_group: Default::default(),
3163            window_bounds: Default::default(),
3164            breakpoints: Default::default(),
3165            display: Default::default(),
3166            docks: Default::default(),
3167            centered_layout: false,
3168            session_id: None,
3169            window_id: Some(3),
3170            user_toolchains: Default::default(),
3171        };
3172
3173        db.save_workspace(workspace_3.clone()).await;
3174        assert_eq!(
3175            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3176            workspace_3
3177        );
3178
3179        // Make sure that updating paths differently also works
3180        workspace_3.paths = PathList::new(&["/tmp3", "/tmp4", "/tmp2"]);
3181        db.save_workspace(workspace_3.clone()).await;
3182        assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
3183        assert_eq!(
3184            db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
3185                .unwrap(),
3186            workspace_3
3187        );
3188    }
3189
3190    #[gpui::test]
3191    async fn test_session_workspaces() {
3192        zlog::init_test();
3193
3194        let db = WorkspaceDb::open_test_db("test_serializing_workspaces_session_id").await;
3195
3196        let workspace_1 = SerializedWorkspace {
3197            id: WorkspaceId(1),
3198            paths: PathList::new(&["/tmp1"]),
3199            location: SerializedWorkspaceLocation::Local,
3200            center_group: Default::default(),
3201            window_bounds: Default::default(),
3202            display: Default::default(),
3203            docks: Default::default(),
3204            centered_layout: false,
3205            breakpoints: Default::default(),
3206            session_id: Some("session-id-1".to_owned()),
3207            window_id: Some(10),
3208            user_toolchains: Default::default(),
3209        };
3210
3211        let workspace_2 = SerializedWorkspace {
3212            id: WorkspaceId(2),
3213            paths: PathList::new(&["/tmp2"]),
3214            location: SerializedWorkspaceLocation::Local,
3215            center_group: Default::default(),
3216            window_bounds: Default::default(),
3217            display: Default::default(),
3218            docks: Default::default(),
3219            centered_layout: false,
3220            breakpoints: Default::default(),
3221            session_id: Some("session-id-1".to_owned()),
3222            window_id: Some(20),
3223            user_toolchains: Default::default(),
3224        };
3225
3226        let workspace_3 = SerializedWorkspace {
3227            id: WorkspaceId(3),
3228            paths: PathList::new(&["/tmp3"]),
3229            location: SerializedWorkspaceLocation::Local,
3230            center_group: Default::default(),
3231            window_bounds: Default::default(),
3232            display: Default::default(),
3233            docks: Default::default(),
3234            centered_layout: false,
3235            breakpoints: Default::default(),
3236            session_id: Some("session-id-2".to_owned()),
3237            window_id: Some(30),
3238            user_toolchains: Default::default(),
3239        };
3240
3241        let workspace_4 = SerializedWorkspace {
3242            id: WorkspaceId(4),
3243            paths: PathList::new(&["/tmp4"]),
3244            location: SerializedWorkspaceLocation::Local,
3245            center_group: Default::default(),
3246            window_bounds: Default::default(),
3247            display: Default::default(),
3248            docks: Default::default(),
3249            centered_layout: false,
3250            breakpoints: Default::default(),
3251            session_id: None,
3252            window_id: None,
3253            user_toolchains: Default::default(),
3254        };
3255
3256        let connection_id = db
3257            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3258                host: "my-host".into(),
3259                port: Some(1234),
3260                ..Default::default()
3261            }))
3262            .await
3263            .unwrap();
3264
3265        let workspace_5 = SerializedWorkspace {
3266            id: WorkspaceId(5),
3267            paths: PathList::default(),
3268            location: SerializedWorkspaceLocation::Remote(
3269                db.remote_connection(connection_id).unwrap(),
3270            ),
3271            center_group: Default::default(),
3272            window_bounds: Default::default(),
3273            display: Default::default(),
3274            docks: Default::default(),
3275            centered_layout: false,
3276            breakpoints: Default::default(),
3277            session_id: Some("session-id-2".to_owned()),
3278            window_id: Some(50),
3279            user_toolchains: Default::default(),
3280        };
3281
3282        let workspace_6 = SerializedWorkspace {
3283            id: WorkspaceId(6),
3284            paths: PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3285            location: SerializedWorkspaceLocation::Local,
3286            center_group: Default::default(),
3287            window_bounds: Default::default(),
3288            breakpoints: Default::default(),
3289            display: Default::default(),
3290            docks: Default::default(),
3291            centered_layout: false,
3292            session_id: Some("session-id-3".to_owned()),
3293            window_id: Some(60),
3294            user_toolchains: Default::default(),
3295        };
3296
3297        db.save_workspace(workspace_1.clone()).await;
3298        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3299        db.save_workspace(workspace_2.clone()).await;
3300        db.save_workspace(workspace_3.clone()).await;
3301        thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3302        db.save_workspace(workspace_4.clone()).await;
3303        db.save_workspace(workspace_5.clone()).await;
3304        db.save_workspace(workspace_6.clone()).await;
3305
3306        let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
3307        assert_eq!(locations.len(), 2);
3308        assert_eq!(locations[0].0, WorkspaceId(2));
3309        assert_eq!(locations[0].1, PathList::new(&["/tmp2"]));
3310        assert_eq!(locations[0].2, Some(20));
3311        assert_eq!(locations[1].0, WorkspaceId(1));
3312        assert_eq!(locations[1].1, PathList::new(&["/tmp1"]));
3313        assert_eq!(locations[1].2, Some(10));
3314
3315        let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
3316        assert_eq!(locations.len(), 2);
3317        assert_eq!(locations[0].0, WorkspaceId(5));
3318        assert_eq!(locations[0].1, PathList::default());
3319        assert_eq!(locations[0].2, Some(50));
3320        assert_eq!(locations[0].3, Some(connection_id));
3321        assert_eq!(locations[1].0, WorkspaceId(3));
3322        assert_eq!(locations[1].1, PathList::new(&["/tmp3"]));
3323        assert_eq!(locations[1].2, Some(30));
3324
3325        let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
3326        assert_eq!(locations.len(), 1);
3327        assert_eq!(locations[0].0, WorkspaceId(6));
3328        assert_eq!(
3329            locations[0].1,
3330            PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3331        );
3332        assert_eq!(locations[0].2, Some(60));
3333    }
3334
3335    fn default_workspace<P: AsRef<Path>>(
3336        paths: &[P],
3337        center_group: &SerializedPaneGroup,
3338    ) -> SerializedWorkspace {
3339        SerializedWorkspace {
3340            id: WorkspaceId(4),
3341            paths: PathList::new(paths),
3342            location: SerializedWorkspaceLocation::Local,
3343            center_group: center_group.clone(),
3344            window_bounds: Default::default(),
3345            display: Default::default(),
3346            docks: Default::default(),
3347            breakpoints: Default::default(),
3348            centered_layout: false,
3349            session_id: None,
3350            window_id: None,
3351            user_toolchains: Default::default(),
3352        }
3353    }
3354
3355    #[gpui::test]
3356    async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) {
3357        let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3358        let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3359        let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3360        let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3361
3362        let fs = fs::FakeFs::new(cx.executor());
3363        fs.insert_tree(dir1.path(), json!({})).await;
3364        fs.insert_tree(dir2.path(), json!({})).await;
3365        fs.insert_tree(dir3.path(), json!({})).await;
3366        fs.insert_tree(dir4.path(), json!({})).await;
3367
3368        let db =
3369            WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
3370
3371        let workspaces = [
3372            (1, vec![dir1.path()], 9),
3373            (2, vec![dir2.path()], 5),
3374            (3, vec![dir3.path()], 8),
3375            (4, vec![dir4.path()], 2),
3376            (5, vec![dir1.path(), dir2.path(), dir3.path()], 3),
3377            (6, vec![dir4.path(), dir3.path(), dir2.path()], 4),
3378        ]
3379        .into_iter()
3380        .map(|(id, paths, window_id)| SerializedWorkspace {
3381            id: WorkspaceId(id),
3382            paths: PathList::new(paths.as_slice()),
3383            location: SerializedWorkspaceLocation::Local,
3384            center_group: Default::default(),
3385            window_bounds: Default::default(),
3386            display: Default::default(),
3387            docks: Default::default(),
3388            centered_layout: false,
3389            session_id: Some("one-session".to_owned()),
3390            breakpoints: Default::default(),
3391            window_id: Some(window_id),
3392            user_toolchains: Default::default(),
3393        })
3394        .collect::<Vec<_>>();
3395
3396        for workspace in workspaces.iter() {
3397            db.save_workspace(workspace.clone()).await;
3398        }
3399
3400        let stack = Some(Vec::from([
3401            WindowId::from(2), // Top
3402            WindowId::from(8),
3403            WindowId::from(5),
3404            WindowId::from(9),
3405            WindowId::from(3),
3406            WindowId::from(4), // Bottom
3407        ]));
3408
3409        let locations = db
3410            .last_session_workspace_locations("one-session", stack, fs.as_ref())
3411            .await
3412            .unwrap();
3413        assert_eq!(
3414            locations,
3415            [
3416                SessionWorkspace {
3417                    workspace_id: WorkspaceId(4),
3418                    location: SerializedWorkspaceLocation::Local,
3419                    paths: PathList::new(&[dir4.path()]),
3420                    window_id: Some(WindowId::from(2u64)),
3421                },
3422                SessionWorkspace {
3423                    workspace_id: WorkspaceId(3),
3424                    location: SerializedWorkspaceLocation::Local,
3425                    paths: PathList::new(&[dir3.path()]),
3426                    window_id: Some(WindowId::from(8u64)),
3427                },
3428                SessionWorkspace {
3429                    workspace_id: WorkspaceId(2),
3430                    location: SerializedWorkspaceLocation::Local,
3431                    paths: PathList::new(&[dir2.path()]),
3432                    window_id: Some(WindowId::from(5u64)),
3433                },
3434                SessionWorkspace {
3435                    workspace_id: WorkspaceId(1),
3436                    location: SerializedWorkspaceLocation::Local,
3437                    paths: PathList::new(&[dir1.path()]),
3438                    window_id: Some(WindowId::from(9u64)),
3439                },
3440                SessionWorkspace {
3441                    workspace_id: WorkspaceId(5),
3442                    location: SerializedWorkspaceLocation::Local,
3443                    paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
3444                    window_id: Some(WindowId::from(3u64)),
3445                },
3446                SessionWorkspace {
3447                    workspace_id: WorkspaceId(6),
3448                    location: SerializedWorkspaceLocation::Local,
3449                    paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
3450                    window_id: Some(WindowId::from(4u64)),
3451                },
3452            ]
3453        );
3454    }
3455
3456    #[gpui::test]
3457    async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
3458        let fs = fs::FakeFs::new(cx.executor());
3459        let db =
3460            WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
3461                .await;
3462
3463        let remote_connections = [
3464            ("host-1", "my-user-1"),
3465            ("host-2", "my-user-2"),
3466            ("host-3", "my-user-3"),
3467            ("host-4", "my-user-4"),
3468        ]
3469        .into_iter()
3470        .map(|(host, user)| async {
3471            let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
3472                host: host.into(),
3473                username: Some(user.to_string()),
3474                ..Default::default()
3475            });
3476            db.get_or_create_remote_connection(options.clone())
3477                .await
3478                .unwrap();
3479            options
3480        })
3481        .collect::<Vec<_>>();
3482
3483        let remote_connections = futures::future::join_all(remote_connections).await;
3484
3485        let workspaces = [
3486            (1, remote_connections[0].clone(), 9),
3487            (2, remote_connections[1].clone(), 5),
3488            (3, remote_connections[2].clone(), 8),
3489            (4, remote_connections[3].clone(), 2),
3490        ]
3491        .into_iter()
3492        .map(|(id, remote_connection, window_id)| SerializedWorkspace {
3493            id: WorkspaceId(id),
3494            paths: PathList::default(),
3495            location: SerializedWorkspaceLocation::Remote(remote_connection),
3496            center_group: Default::default(),
3497            window_bounds: Default::default(),
3498            display: Default::default(),
3499            docks: Default::default(),
3500            centered_layout: false,
3501            session_id: Some("one-session".to_owned()),
3502            breakpoints: Default::default(),
3503            window_id: Some(window_id),
3504            user_toolchains: Default::default(),
3505        })
3506        .collect::<Vec<_>>();
3507
3508        for workspace in workspaces.iter() {
3509            db.save_workspace(workspace.clone()).await;
3510        }
3511
3512        let stack = Some(Vec::from([
3513            WindowId::from(2), // Top
3514            WindowId::from(8),
3515            WindowId::from(5),
3516            WindowId::from(9), // Bottom
3517        ]));
3518
3519        let have = db
3520            .last_session_workspace_locations("one-session", stack, fs.as_ref())
3521            .await
3522            .unwrap();
3523        assert_eq!(have.len(), 4);
3524        assert_eq!(
3525            have[0],
3526            SessionWorkspace {
3527                workspace_id: WorkspaceId(4),
3528                location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
3529                paths: PathList::default(),
3530                window_id: Some(WindowId::from(2u64)),
3531            }
3532        );
3533        assert_eq!(
3534            have[1],
3535            SessionWorkspace {
3536                workspace_id: WorkspaceId(3),
3537                location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
3538                paths: PathList::default(),
3539                window_id: Some(WindowId::from(8u64)),
3540            }
3541        );
3542        assert_eq!(
3543            have[2],
3544            SessionWorkspace {
3545                workspace_id: WorkspaceId(2),
3546                location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
3547                paths: PathList::default(),
3548                window_id: Some(WindowId::from(5u64)),
3549            }
3550        );
3551        assert_eq!(
3552            have[3],
3553            SessionWorkspace {
3554                workspace_id: WorkspaceId(1),
3555                location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
3556                paths: PathList::default(),
3557                window_id: Some(WindowId::from(9u64)),
3558            }
3559        );
3560    }
3561
3562    #[gpui::test]
3563    async fn test_get_or_create_ssh_project() {
3564        let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project").await;
3565
3566        let host = "example.com".to_string();
3567        let port = Some(22_u16);
3568        let user = Some("user".to_string());
3569
3570        let connection_id = db
3571            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3572                host: host.clone().into(),
3573                port,
3574                username: user.clone(),
3575                ..Default::default()
3576            }))
3577            .await
3578            .unwrap();
3579
3580        // Test that calling the function again with the same parameters returns the same project
3581        let same_connection = db
3582            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3583                host: host.clone().into(),
3584                port,
3585                username: user.clone(),
3586                ..Default::default()
3587            }))
3588            .await
3589            .unwrap();
3590
3591        assert_eq!(connection_id, same_connection);
3592
3593        // Test with different parameters
3594        let host2 = "otherexample.com".to_string();
3595        let port2 = None;
3596        let user2 = Some("otheruser".to_string());
3597
3598        let different_connection = db
3599            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3600                host: host2.clone().into(),
3601                port: port2,
3602                username: user2.clone(),
3603                ..Default::default()
3604            }))
3605            .await
3606            .unwrap();
3607
3608        assert_ne!(connection_id, different_connection);
3609    }
3610
3611    #[gpui::test]
3612    async fn test_get_or_create_ssh_project_with_null_user() {
3613        let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project_with_null_user").await;
3614
3615        let (host, port, user) = ("example.com".to_string(), None, None);
3616
3617        let connection_id = db
3618            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3619                host: host.clone().into(),
3620                port,
3621                username: None,
3622                ..Default::default()
3623            }))
3624            .await
3625            .unwrap();
3626
3627        let same_connection_id = db
3628            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3629                host: host.clone().into(),
3630                port,
3631                username: user.clone(),
3632                ..Default::default()
3633            }))
3634            .await
3635            .unwrap();
3636
3637        assert_eq!(connection_id, same_connection_id);
3638    }
3639
3640    #[gpui::test]
3641    async fn test_get_remote_connections() {
3642        let db = WorkspaceDb::open_test_db("test_get_remote_connections").await;
3643
3644        let connections = [
3645            ("example.com".to_string(), None, None),
3646            (
3647                "anotherexample.com".to_string(),
3648                Some(123_u16),
3649                Some("user2".to_string()),
3650            ),
3651            ("yetanother.com".to_string(), Some(345_u16), None),
3652        ];
3653
3654        let mut ids = Vec::new();
3655        for (host, port, user) in connections.iter() {
3656            ids.push(
3657                db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
3658                    SshConnectionOptions {
3659                        host: host.clone().into(),
3660                        port: *port,
3661                        username: user.clone(),
3662                        ..Default::default()
3663                    },
3664                ))
3665                .await
3666                .unwrap(),
3667            );
3668        }
3669
3670        let stored_connections = db.remote_connections().unwrap();
3671        assert_eq!(
3672            stored_connections,
3673            [
3674                (
3675                    ids[0],
3676                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3677                        host: "example.com".into(),
3678                        port: None,
3679                        username: None,
3680                        ..Default::default()
3681                    }),
3682                ),
3683                (
3684                    ids[1],
3685                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3686                        host: "anotherexample.com".into(),
3687                        port: Some(123),
3688                        username: Some("user2".into()),
3689                        ..Default::default()
3690                    }),
3691                ),
3692                (
3693                    ids[2],
3694                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
3695                        host: "yetanother.com".into(),
3696                        port: Some(345),
3697                        username: None,
3698                        ..Default::default()
3699                    }),
3700                ),
3701            ]
3702            .into_iter()
3703            .collect::<HashMap<_, _>>(),
3704        );
3705    }
3706
3707    #[gpui::test]
3708    async fn test_simple_split() {
3709        zlog::init_test();
3710
3711        let db = WorkspaceDb::open_test_db("simple_split").await;
3712
3713        //  -----------------
3714        //  | 1,2   | 5,6   |
3715        //  | - - - |       |
3716        //  | 3,4   |       |
3717        //  -----------------
3718        let center_pane = group(
3719            Axis::Horizontal,
3720            vec![
3721                group(
3722                    Axis::Vertical,
3723                    vec![
3724                        SerializedPaneGroup::Pane(SerializedPane::new(
3725                            vec![
3726                                SerializedItem::new("Terminal", 1, false, false),
3727                                SerializedItem::new("Terminal", 2, true, false),
3728                            ],
3729                            false,
3730                            0,
3731                        )),
3732                        SerializedPaneGroup::Pane(SerializedPane::new(
3733                            vec![
3734                                SerializedItem::new("Terminal", 4, false, false),
3735                                SerializedItem::new("Terminal", 3, true, false),
3736                            ],
3737                            true,
3738                            0,
3739                        )),
3740                    ],
3741                ),
3742                SerializedPaneGroup::Pane(SerializedPane::new(
3743                    vec![
3744                        SerializedItem::new("Terminal", 5, true, false),
3745                        SerializedItem::new("Terminal", 6, false, false),
3746                    ],
3747                    false,
3748                    0,
3749                )),
3750            ],
3751        );
3752
3753        let workspace = default_workspace(&["/tmp"], &center_pane);
3754
3755        db.save_workspace(workspace.clone()).await;
3756
3757        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
3758
3759        assert_eq!(workspace.center_group, new_workspace.center_group);
3760    }
3761
3762    #[gpui::test]
3763    async fn test_cleanup_panes() {
3764        zlog::init_test();
3765
3766        let db = WorkspaceDb::open_test_db("test_cleanup_panes").await;
3767
3768        let center_pane = group(
3769            Axis::Horizontal,
3770            vec![
3771                group(
3772                    Axis::Vertical,
3773                    vec![
3774                        SerializedPaneGroup::Pane(SerializedPane::new(
3775                            vec![
3776                                SerializedItem::new("Terminal", 1, false, false),
3777                                SerializedItem::new("Terminal", 2, true, false),
3778                            ],
3779                            false,
3780                            0,
3781                        )),
3782                        SerializedPaneGroup::Pane(SerializedPane::new(
3783                            vec![
3784                                SerializedItem::new("Terminal", 4, false, false),
3785                                SerializedItem::new("Terminal", 3, true, false),
3786                            ],
3787                            true,
3788                            0,
3789                        )),
3790                    ],
3791                ),
3792                SerializedPaneGroup::Pane(SerializedPane::new(
3793                    vec![
3794                        SerializedItem::new("Terminal", 5, false, false),
3795                        SerializedItem::new("Terminal", 6, true, false),
3796                    ],
3797                    false,
3798                    0,
3799                )),
3800            ],
3801        );
3802
3803        let id = &["/tmp"];
3804
3805        let mut workspace = default_workspace(id, &center_pane);
3806
3807        db.save_workspace(workspace.clone()).await;
3808
3809        workspace.center_group = group(
3810            Axis::Vertical,
3811            vec![
3812                SerializedPaneGroup::Pane(SerializedPane::new(
3813                    vec![
3814                        SerializedItem::new("Terminal", 1, false, false),
3815                        SerializedItem::new("Terminal", 2, true, false),
3816                    ],
3817                    false,
3818                    0,
3819                )),
3820                SerializedPaneGroup::Pane(SerializedPane::new(
3821                    vec![
3822                        SerializedItem::new("Terminal", 4, true, false),
3823                        SerializedItem::new("Terminal", 3, false, false),
3824                    ],
3825                    true,
3826                    0,
3827                )),
3828            ],
3829        );
3830
3831        db.save_workspace(workspace.clone()).await;
3832
3833        let new_workspace = db.workspace_for_roots(id).unwrap();
3834
3835        assert_eq!(workspace.center_group, new_workspace.center_group);
3836    }
3837
3838    #[gpui::test]
3839    async fn test_empty_workspace_window_bounds() {
3840        zlog::init_test();
3841
3842        let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
3843        let id = db.next_id().await.unwrap();
3844
3845        // Create a workspace with empty paths (empty workspace)
3846        let empty_paths: &[&str] = &[];
3847        let display_uuid = Uuid::new_v4();
3848        let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
3849            origin: point(px(100.0), px(200.0)),
3850            size: size(px(800.0), px(600.0)),
3851        }));
3852
3853        let workspace = SerializedWorkspace {
3854            id,
3855            paths: PathList::new(empty_paths),
3856            location: SerializedWorkspaceLocation::Local,
3857            center_group: Default::default(),
3858            window_bounds: None,
3859            display: None,
3860            docks: Default::default(),
3861            breakpoints: Default::default(),
3862            centered_layout: false,
3863            session_id: None,
3864            window_id: None,
3865            user_toolchains: Default::default(),
3866        };
3867
3868        // Save the workspace (this creates the record with empty paths)
3869        db.save_workspace(workspace.clone()).await;
3870
3871        // Save window bounds separately (as the actual code does via set_window_open_status)
3872        db.set_window_open_status(id, window_bounds, display_uuid)
3873            .await
3874            .unwrap();
3875
3876        // Empty workspaces cannot be retrieved by paths (they'd all match).
3877        // They must be retrieved by workspace_id.
3878        assert!(db.workspace_for_roots(empty_paths).is_none());
3879
3880        // Retrieve using workspace_for_id instead
3881        let retrieved = db.workspace_for_id(id).unwrap();
3882
3883        // Verify window bounds were persisted
3884        assert_eq!(retrieved.id, id);
3885        assert!(retrieved.window_bounds.is_some());
3886        assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
3887        assert!(retrieved.display.is_some());
3888        assert_eq!(retrieved.display.unwrap(), display_uuid);
3889    }
3890
3891    #[gpui::test]
3892    async fn test_last_session_workspace_locations_groups_by_window_id(
3893        cx: &mut gpui::TestAppContext,
3894    ) {
3895        let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3896        let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3897        let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3898        let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3899        let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
3900
3901        let fs = fs::FakeFs::new(cx.executor());
3902        fs.insert_tree(dir1.path(), json!({})).await;
3903        fs.insert_tree(dir2.path(), json!({})).await;
3904        fs.insert_tree(dir3.path(), json!({})).await;
3905        fs.insert_tree(dir4.path(), json!({})).await;
3906        fs.insert_tree(dir5.path(), json!({})).await;
3907
3908        let db =
3909            WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
3910                .await;
3911
3912        // Simulate two MultiWorkspace windows each containing two workspaces,
3913        // plus one single-workspace window:
3914        //   Window 10: workspace 1, workspace 2
3915        //   Window 20: workspace 3, workspace 4
3916        //   Window 30: workspace 5 (only one)
3917        //
3918        // On session restore, the caller should be able to group these by
3919        // window_id to reconstruct the MultiWorkspace windows.
3920        let workspaces_data: Vec<(i64, &Path, u64)> = vec![
3921            (1, dir1.path(), 10),
3922            (2, dir2.path(), 10),
3923            (3, dir3.path(), 20),
3924            (4, dir4.path(), 20),
3925            (5, dir5.path(), 30),
3926        ];
3927
3928        for (id, dir, window_id) in &workspaces_data {
3929            db.save_workspace(SerializedWorkspace {
3930                id: WorkspaceId(*id),
3931                paths: PathList::new(&[*dir]),
3932                location: SerializedWorkspaceLocation::Local,
3933                center_group: Default::default(),
3934                window_bounds: Default::default(),
3935                display: Default::default(),
3936                docks: Default::default(),
3937                centered_layout: false,
3938                session_id: Some("test-session".to_owned()),
3939                breakpoints: Default::default(),
3940                window_id: Some(*window_id),
3941                user_toolchains: Default::default(),
3942            })
3943            .await;
3944        }
3945
3946        let locations = db
3947            .last_session_workspace_locations("test-session", None, fs.as_ref())
3948            .await
3949            .unwrap();
3950
3951        // All 5 workspaces should be returned with their window_ids.
3952        assert_eq!(locations.len(), 5);
3953
3954        // Every entry should have a window_id so the caller can group them.
3955        for session_workspace in &locations {
3956            assert!(
3957                session_workspace.window_id.is_some(),
3958                "workspace {:?} missing window_id",
3959                session_workspace.workspace_id
3960            );
3961        }
3962
3963        // Group by window_id, simulating what the restoration code should do.
3964        let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
3965        for session_workspace in &locations {
3966            if let Some(window_id) = session_workspace.window_id {
3967                by_window
3968                    .entry(window_id)
3969                    .or_default()
3970                    .push(session_workspace.workspace_id);
3971            }
3972        }
3973
3974        // Should produce 3 windows, not 5.
3975        assert_eq!(
3976            by_window.len(),
3977            3,
3978            "Expected 3 window groups, got {}: {:?}",
3979            by_window.len(),
3980            by_window
3981        );
3982
3983        // Window 10 should contain workspaces 1 and 2.
3984        let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
3985        assert_eq!(window_10.len(), 2);
3986        assert!(window_10.contains(&WorkspaceId(1)));
3987        assert!(window_10.contains(&WorkspaceId(2)));
3988
3989        // Window 20 should contain workspaces 3 and 4.
3990        let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
3991        assert_eq!(window_20.len(), 2);
3992        assert!(window_20.contains(&WorkspaceId(3)));
3993        assert!(window_20.contains(&WorkspaceId(4)));
3994
3995        // Window 30 should contain only workspace 5.
3996        let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
3997        assert_eq!(window_30.len(), 1);
3998        assert!(window_30.contains(&WorkspaceId(5)));
3999    }
4000
4001    #[gpui::test]
4002    async fn test_read_serialized_multi_workspaces_with_state(cx: &mut gpui::TestAppContext) {
4003        use crate::persistence::model::MultiWorkspaceState;
4004
4005        // Write multi-workspace state for two windows via the scoped KVP.
4006        let window_10 = WindowId::from(10u64);
4007        let window_20 = WindowId::from(20u64);
4008
4009        let kvp = cx.update(|cx| KeyValueStore::global(cx));
4010
4011        write_multi_workspace_state(
4012            &kvp,
4013            window_10,
4014            MultiWorkspaceState {
4015                active_workspace_id: Some(WorkspaceId(2)),
4016                project_groups: vec![],
4017                sidebar_open: true,
4018                sidebar_state: None,
4019            },
4020        )
4021        .await;
4022
4023        write_multi_workspace_state(
4024            &kvp,
4025            window_20,
4026            MultiWorkspaceState {
4027                active_workspace_id: Some(WorkspaceId(3)),
4028                project_groups: vec![],
4029                sidebar_open: false,
4030                sidebar_state: None,
4031            },
4032        )
4033        .await;
4034
4035        // Build session workspaces: two in window 10, one in window 20, one with no window.
4036        let session_workspaces = vec![
4037            SessionWorkspace {
4038                workspace_id: WorkspaceId(1),
4039                location: SerializedWorkspaceLocation::Local,
4040                paths: PathList::new(&["/a"]),
4041                window_id: Some(window_10),
4042            },
4043            SessionWorkspace {
4044                workspace_id: WorkspaceId(2),
4045                location: SerializedWorkspaceLocation::Local,
4046                paths: PathList::new(&["/b"]),
4047                window_id: Some(window_10),
4048            },
4049            SessionWorkspace {
4050                workspace_id: WorkspaceId(3),
4051                location: SerializedWorkspaceLocation::Local,
4052                paths: PathList::new(&["/c"]),
4053                window_id: Some(window_20),
4054            },
4055            SessionWorkspace {
4056                workspace_id: WorkspaceId(4),
4057                location: SerializedWorkspaceLocation::Local,
4058                paths: PathList::new(&["/d"]),
4059                window_id: None,
4060            },
4061        ];
4062
4063        let results = cx.update(|cx| read_serialized_multi_workspaces(session_workspaces, cx));
4064
4065        // Should produce 3 results: window 10, window 20, and the orphan.
4066        assert_eq!(results.len(), 3);
4067
4068        // Window 10: active_workspace_id = 2 picks workspace 2 (paths /b), sidebar open.
4069        let group_10 = &results[0];
4070        assert_eq!(group_10.active_workspace.workspace_id, WorkspaceId(2));
4071        assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
4072        assert_eq!(group_10.state.sidebar_open, true);
4073
4074        // Window 20: active_workspace_id = 3 picks workspace 3 (paths /c), sidebar closed.
4075        let group_20 = &results[1];
4076        assert_eq!(group_20.active_workspace.workspace_id, WorkspaceId(3));
4077        assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
4078        assert_eq!(group_20.state.sidebar_open, false);
4079
4080        // Orphan: no active_workspace_id, falls back to first workspace (id 4).
4081        let group_none = &results[2];
4082        assert_eq!(group_none.active_workspace.workspace_id, WorkspaceId(4));
4083        assert_eq!(group_none.state.active_workspace_id, None);
4084        assert_eq!(group_none.state.sidebar_open, false);
4085    }
4086
4087    #[gpui::test]
4088    async fn test_flush_serialization_completes_before_quit(cx: &mut gpui::TestAppContext) {
4089        crate::tests::init_test(cx);
4090
4091        cx.update(|cx| {
4092            cx.set_staff(true);
4093        });
4094
4095        let fs = fs::FakeFs::new(cx.executor());
4096        let project = Project::test(fs.clone(), [], cx).await;
4097
4098        let (multi_workspace, cx) =
4099            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4100
4101        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4102
4103        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4104
4105        // Assign a database_id so serialization will actually persist.
4106        let workspace_id = db.next_id().await.unwrap();
4107        workspace.update(cx, |ws, _cx| {
4108            ws.set_database_id(workspace_id);
4109        });
4110
4111        // Mutate some workspace state.
4112        db.set_centered_layout(workspace_id, true).await.unwrap();
4113
4114        // Call flush_serialization and await the returned task directly
4115        // (without run_until_parked — the point is that awaiting the task
4116        // alone is sufficient).
4117        let task = multi_workspace.update_in(cx, |mw, window, cx| {
4118            mw.workspace()
4119                .update(cx, |ws, cx| ws.flush_serialization(window, cx))
4120        });
4121        task.await;
4122
4123        // Read the workspace back from the DB and verify serialization happened.
4124        let serialized = db.workspace_for_id(workspace_id);
4125        assert!(
4126            serialized.is_some(),
4127            "flush_serialization should have persisted the workspace to DB"
4128        );
4129    }
4130
4131    #[gpui::test]
4132    async fn test_create_workspace_serialization(cx: &mut gpui::TestAppContext) {
4133        crate::tests::init_test(cx);
4134
4135        cx.update(|cx| {
4136            cx.set_staff(true);
4137        });
4138
4139        let fs = fs::FakeFs::new(cx.executor());
4140        let project = Project::test(fs.clone(), [], cx).await;
4141
4142        let (multi_workspace, cx) =
4143            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4144
4145        // Give the first workspace a database_id.
4146        multi_workspace.update_in(cx, |mw, _, cx| {
4147            mw.set_random_database_id(cx);
4148        });
4149
4150        let window_id =
4151            multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
4152
4153        // Create a new workspace via the MultiWorkspace API (triggers next_id()).
4154        multi_workspace.update_in(cx, |mw, window, cx| {
4155            mw.create_test_workspace(window, cx).detach();
4156        });
4157
4158        // Let the async next_id() and re-serialization tasks complete.
4159        cx.run_until_parked();
4160
4161        // The new workspace should now have a database_id.
4162        let new_workspace_db_id =
4163            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4164        assert!(
4165            new_workspace_db_id.is_some(),
4166            "New workspace should have a database_id after run_until_parked"
4167        );
4168
4169        // The multi-workspace state should record it as the active workspace.
4170        let state = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
4171        assert_eq!(
4172            state.active_workspace_id, new_workspace_db_id,
4173            "Serialized active_workspace_id should match the new workspace's database_id"
4174        );
4175
4176        // The individual workspace row should exist with real data
4177        // (not just the bare DEFAULT VALUES row from next_id).
4178        let workspace_id = new_workspace_db_id.unwrap();
4179        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4180        let serialized = db.workspace_for_id(workspace_id);
4181        assert!(
4182            serialized.is_some(),
4183            "Newly created workspace should be fully serialized in the DB after database_id assignment"
4184        );
4185    }
4186
4187    #[gpui::test]
4188    async fn test_remove_workspace_clears_session_binding(cx: &mut gpui::TestAppContext) {
4189        crate::tests::init_test(cx);
4190
4191        cx.update(|cx| {
4192            cx.set_staff(true);
4193        });
4194
4195        let fs = fs::FakeFs::new(cx.executor());
4196        let dir = unique_test_dir(&fs, "remove").await;
4197        let project1 = Project::test(fs.clone(), [], cx).await;
4198        let project2 = Project::test(fs.clone(), [], cx).await;
4199
4200        let (multi_workspace, cx) =
4201            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4202
4203        multi_workspace.update(cx, |mw, cx| {
4204            mw.open_sidebar(cx);
4205        });
4206
4207        multi_workspace.update_in(cx, |mw, _, cx| {
4208            mw.set_random_database_id(cx);
4209        });
4210
4211        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4212
4213        // Get a real DB id for workspace2 so the row actually exists.
4214        let workspace2_db_id = db.next_id().await.unwrap();
4215
4216        multi_workspace.update_in(cx, |mw, window, cx| {
4217            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4218            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4219                ws.set_database_id(workspace2_db_id)
4220            });
4221            mw.add(workspace.clone(), window, cx);
4222        });
4223
4224        // Save a full workspace row to the DB directly.
4225        let session_id = format!("remove-test-session-{}", Uuid::new_v4());
4226        db.save_workspace(SerializedWorkspace {
4227            id: workspace2_db_id,
4228            paths: PathList::new(&[&dir]),
4229            location: SerializedWorkspaceLocation::Local,
4230            center_group: Default::default(),
4231            window_bounds: Default::default(),
4232            display: Default::default(),
4233            docks: Default::default(),
4234            centered_layout: false,
4235            session_id: Some(session_id.clone()),
4236            breakpoints: Default::default(),
4237            window_id: Some(99),
4238            user_toolchains: Default::default(),
4239        })
4240        .await;
4241
4242        assert!(
4243            db.workspace_for_id(workspace2_db_id).is_some(),
4244            "Workspace2 should exist in DB before removal"
4245        );
4246
4247        // Remove workspace at index 1 (the second workspace).
4248        multi_workspace.update_in(cx, |mw, window, cx| {
4249            let ws = mw.workspaces().nth(1).unwrap().clone();
4250            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4251                .detach_and_log_err(cx);
4252        });
4253
4254        cx.run_until_parked();
4255
4256        // The row should still exist so it continues to appear in recent
4257        // projects, but the session binding should be cleared so it is not
4258        // restored as part of any future session.
4259        assert!(
4260            db.workspace_for_id(workspace2_db_id).is_some(),
4261            "Removed workspace's DB row should be preserved for recent projects"
4262        );
4263
4264        let session_workspaces = db
4265            .last_session_workspace_locations("remove-test-session", None, fs.as_ref())
4266            .await
4267            .unwrap();
4268        let restored_ids: Vec<WorkspaceId> = session_workspaces
4269            .iter()
4270            .map(|sw| sw.workspace_id)
4271            .collect();
4272        assert!(
4273            !restored_ids.contains(&workspace2_db_id),
4274            "Removed workspace should not appear in session restoration"
4275        );
4276    }
4277
4278    #[gpui::test]
4279    async fn test_remove_workspace_not_restored_as_zombie(cx: &mut gpui::TestAppContext) {
4280        crate::tests::init_test(cx);
4281
4282        cx.update(|cx| {
4283            cx.set_staff(true);
4284        });
4285
4286        let fs = fs::FakeFs::new(cx.executor());
4287        let dir1 = tempfile::TempDir::with_prefix("zombie_test1").unwrap();
4288        let dir2 = tempfile::TempDir::with_prefix("zombie_test2").unwrap();
4289        fs.insert_tree(dir1.path(), json!({})).await;
4290        fs.insert_tree(dir2.path(), json!({})).await;
4291
4292        let project1 = Project::test(fs.clone(), [], cx).await;
4293        let project2 = Project::test(fs.clone(), [], cx).await;
4294
4295        let db = cx.update(|cx| WorkspaceDb::global(cx));
4296
4297        // Get real DB ids so the rows actually exist.
4298        let ws1_id = db.next_id().await.unwrap();
4299        let ws2_id = db.next_id().await.unwrap();
4300
4301        let (multi_workspace, cx) =
4302            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4303
4304        multi_workspace.update(cx, |mw, cx| {
4305            mw.open_sidebar(cx);
4306        });
4307
4308        multi_workspace.update_in(cx, |mw, _, cx| {
4309            mw.workspace().update(cx, |ws, _cx| {
4310                ws.set_database_id(ws1_id);
4311            });
4312        });
4313
4314        multi_workspace.update_in(cx, |mw, window, cx| {
4315            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4316            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4317                ws.set_database_id(ws2_id)
4318            });
4319            mw.add(workspace.clone(), window, cx);
4320        });
4321
4322        let session_id = "test-zombie-session";
4323        let window_id_val: u64 = 42;
4324
4325        db.save_workspace(SerializedWorkspace {
4326            id: ws1_id,
4327            paths: PathList::new(&[dir1.path()]),
4328            location: SerializedWorkspaceLocation::Local,
4329            center_group: Default::default(),
4330            window_bounds: Default::default(),
4331            display: Default::default(),
4332            docks: Default::default(),
4333            centered_layout: false,
4334            session_id: Some(session_id.to_owned()),
4335            breakpoints: Default::default(),
4336            window_id: Some(window_id_val),
4337            user_toolchains: Default::default(),
4338        })
4339        .await;
4340
4341        db.save_workspace(SerializedWorkspace {
4342            id: ws2_id,
4343            paths: PathList::new(&[dir2.path()]),
4344            location: SerializedWorkspaceLocation::Local,
4345            center_group: Default::default(),
4346            window_bounds: Default::default(),
4347            display: Default::default(),
4348            docks: Default::default(),
4349            centered_layout: false,
4350            session_id: Some(session_id.to_owned()),
4351            breakpoints: Default::default(),
4352            window_id: Some(window_id_val),
4353            user_toolchains: Default::default(),
4354        })
4355        .await;
4356
4357        // Remove workspace2 (index 1).
4358        multi_workspace.update_in(cx, |mw, window, cx| {
4359            let ws = mw.workspaces().nth(1).unwrap().clone();
4360            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4361                .detach_and_log_err(cx);
4362        });
4363
4364        cx.run_until_parked();
4365
4366        // The removed workspace should NOT appear in session restoration.
4367        let locations = db
4368            .last_session_workspace_locations(session_id, None, fs.as_ref())
4369            .await
4370            .unwrap();
4371
4372        let restored_ids: Vec<WorkspaceId> = locations.iter().map(|sw| sw.workspace_id).collect();
4373        assert!(
4374            !restored_ids.contains(&ws2_id),
4375            "Removed workspace should not appear in session restoration list. Found: {:?}",
4376            restored_ids
4377        );
4378        assert!(
4379            restored_ids.contains(&ws1_id),
4380            "Remaining workspace should still appear in session restoration list"
4381        );
4382    }
4383
4384    #[gpui::test]
4385    async fn test_pending_removal_tasks_drained_on_flush(cx: &mut gpui::TestAppContext) {
4386        crate::tests::init_test(cx);
4387
4388        cx.update(|cx| {
4389            cx.set_staff(true);
4390        });
4391
4392        let fs = fs::FakeFs::new(cx.executor());
4393        let dir = unique_test_dir(&fs, "pending-removal").await;
4394        let project1 = Project::test(fs.clone(), [], cx).await;
4395        let project2 = Project::test(fs.clone(), [], cx).await;
4396
4397        let db = cx.update(|cx| WorkspaceDb::global(cx));
4398
4399        // Get a real DB id for workspace2 so the row actually exists.
4400        let workspace2_db_id = db.next_id().await.unwrap();
4401
4402        let (multi_workspace, cx) =
4403            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4404
4405        multi_workspace.update(cx, |mw, cx| {
4406            mw.open_sidebar(cx);
4407        });
4408
4409        multi_workspace.update_in(cx, |mw, _, cx| {
4410            mw.set_random_database_id(cx);
4411        });
4412
4413        multi_workspace.update_in(cx, |mw, window, cx| {
4414            let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4415            workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4416                ws.set_database_id(workspace2_db_id)
4417            });
4418            mw.add(workspace.clone(), window, cx);
4419        });
4420
4421        // Save a full workspace row to the DB directly and let it settle.
4422        let session_id = format!("pending-removal-session-{}", Uuid::new_v4());
4423        db.save_workspace(SerializedWorkspace {
4424            id: workspace2_db_id,
4425            paths: PathList::new(&[&dir]),
4426            location: SerializedWorkspaceLocation::Local,
4427            center_group: Default::default(),
4428            window_bounds: Default::default(),
4429            display: Default::default(),
4430            docks: Default::default(),
4431            centered_layout: false,
4432            session_id: Some(session_id.clone()),
4433            breakpoints: Default::default(),
4434            window_id: Some(88),
4435            user_toolchains: Default::default(),
4436        })
4437        .await;
4438        cx.run_until_parked();
4439
4440        // Remove workspace2 — this pushes a task to pending_removal_tasks.
4441        multi_workspace.update_in(cx, |mw, window, cx| {
4442            let ws = mw.workspaces().nth(1).unwrap().clone();
4443            mw.remove([ws], |_, _, _| unreachable!(), window, cx)
4444                .detach_and_log_err(cx);
4445        });
4446
4447        // Simulate the quit handler pattern: collect flush tasks + pending
4448        // removal tasks and await them all.
4449        let all_tasks = multi_workspace.update_in(cx, |mw, window, cx| {
4450            let mut tasks: Vec<Task<()>> = mw
4451                .workspaces()
4452                .map(|workspace| {
4453                    workspace.update(cx, |workspace, cx| {
4454                        workspace.flush_serialization(window, cx)
4455                    })
4456                })
4457                .collect();
4458            let mut removal_tasks = mw.take_pending_removal_tasks();
4459            // Note: removal_tasks may be empty if the background task already
4460            // completed (take_pending_removal_tasks filters out ready tasks).
4461            tasks.append(&mut removal_tasks);
4462            tasks.push(mw.flush_serialization());
4463            tasks
4464        });
4465        futures::future::join_all(all_tasks).await;
4466
4467        // The row should still exist (for recent projects), but the session
4468        // binding should have been cleared by the pending removal task.
4469        assert!(
4470            db.workspace_for_id(workspace2_db_id).is_some(),
4471            "Workspace row should be preserved for recent projects"
4472        );
4473
4474        let session_workspaces = db
4475            .last_session_workspace_locations("pending-removal-session", None, fs.as_ref())
4476            .await
4477            .unwrap();
4478        let restored_ids: Vec<WorkspaceId> = session_workspaces
4479            .iter()
4480            .map(|sw| sw.workspace_id)
4481            .collect();
4482        assert!(
4483            !restored_ids.contains(&workspace2_db_id),
4484            "Pending removal task should have cleared the session binding"
4485        );
4486    }
4487
4488    #[gpui::test]
4489    async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) {
4490        crate::tests::init_test(cx);
4491
4492        cx.update(|cx| {
4493            cx.set_staff(true);
4494        });
4495
4496        let fs = fs::FakeFs::new(cx.executor());
4497        let project = Project::test(fs.clone(), [], cx).await;
4498
4499        let (multi_workspace, cx) =
4500            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4501
4502        multi_workspace.update_in(cx, |mw, _, cx| {
4503            mw.set_random_database_id(cx);
4504        });
4505
4506        let task =
4507            multi_workspace.update_in(cx, |mw, window, cx| mw.create_test_workspace(window, cx));
4508        task.await;
4509
4510        let new_workspace_db_id =
4511            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4512        assert!(
4513            new_workspace_db_id.is_some(),
4514            "After run_until_parked, the workspace should have a database_id"
4515        );
4516
4517        let workspace_id = new_workspace_db_id.unwrap();
4518
4519        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4520
4521        assert!(
4522            db.workspace_for_id(workspace_id).is_some(),
4523            "The workspace row should exist in the DB"
4524        );
4525
4526        cx.simulate_resize(gpui::size(px(1024.0), px(768.0)));
4527
4528        // Advance the clock past the 100ms debounce timer so the bounds
4529        // observer task fires
4530        cx.executor().advance_clock(Duration::from_millis(200));
4531        cx.run_until_parked();
4532
4533        let serialized = db
4534            .workspace_for_id(workspace_id)
4535            .expect("workspace row should still exist");
4536        assert!(
4537            serialized.window_bounds.is_some(),
4538            "The bounds observer should write bounds for the workspace's real DB ID, \
4539             even when the workspace was created via create_workspace (where the ID \
4540             is assigned asynchronously after construction)."
4541        );
4542    }
4543
4544    #[gpui::test]
4545    async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) {
4546        crate::tests::init_test(cx);
4547
4548        cx.update(|cx| {
4549            cx.set_staff(true);
4550        });
4551
4552        let fs = fs::FakeFs::new(cx.executor());
4553        let dir = tempfile::TempDir::with_prefix("flush_bounds_test").unwrap();
4554        fs.insert_tree(dir.path(), json!({})).await;
4555
4556        let project = Project::test(fs.clone(), [dir.path()], cx).await;
4557
4558        let (multi_workspace, cx) =
4559            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4560
4561        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4562        let workspace_id = db.next_id().await.unwrap();
4563        multi_workspace.update_in(cx, |mw, _, cx| {
4564            mw.workspace().update(cx, |ws, _cx| {
4565                ws.set_database_id(workspace_id);
4566            });
4567        });
4568
4569        let task = multi_workspace.update_in(cx, |mw, window, cx| {
4570            mw.workspace()
4571                .update(cx, |ws, cx| ws.flush_serialization(window, cx))
4572        });
4573        task.await;
4574
4575        let after = db
4576            .workspace_for_id(workspace_id)
4577            .expect("workspace row should exist after flush_serialization");
4578        assert!(
4579            !after.paths.is_empty(),
4580            "flush_serialization should have written paths via save_workspace"
4581        );
4582        assert!(
4583            after.window_bounds.is_some(),
4584            "flush_serialization should ensure window bounds are persisted to the DB \
4585             before the process exits."
4586        );
4587    }
4588
4589    #[gpui::test]
4590    async fn test_resolve_worktree_workspaces(cx: &mut gpui::TestAppContext) {
4591        let fs = fs::FakeFs::new(cx.executor());
4592
4593        // Main repo with a linked worktree entry
4594        fs.insert_tree(
4595            "/repo",
4596            json!({
4597                ".git": {
4598                    "worktrees": {
4599                        "feature": {
4600                            "commondir": "../../",
4601                            "HEAD": "ref: refs/heads/feature"
4602                        }
4603                    }
4604                },
4605                "src": { "main.rs": "" }
4606            }),
4607        )
4608        .await;
4609
4610        // Linked worktree checkout pointing back to /repo
4611        fs.insert_tree(
4612            "/worktree",
4613            json!({
4614                ".git": "gitdir: /repo/.git/worktrees/feature",
4615                "src": { "main.rs": "" }
4616            }),
4617        )
4618        .await;
4619
4620        // A plain non-git project
4621        fs.insert_tree(
4622            "/plain-project",
4623            json!({
4624                "src": { "main.rs": "" }
4625            }),
4626        )
4627        .await;
4628
4629        // Another normal git repo (used in mixed-path entry)
4630        fs.insert_tree(
4631            "/other-repo",
4632            json!({
4633                ".git": {},
4634                "src": { "lib.rs": "" }
4635            }),
4636        )
4637        .await;
4638
4639        let t0 = Utc::now() - chrono::Duration::hours(4);
4640        let t1 = Utc::now() - chrono::Duration::hours(3);
4641        let t2 = Utc::now() - chrono::Duration::hours(2);
4642        let t3 = Utc::now() - chrono::Duration::hours(1);
4643
4644        let workspaces = vec![
4645            // 1: Main checkout of /repo (opened earlier)
4646            (
4647                WorkspaceId(1),
4648                SerializedWorkspaceLocation::Local,
4649                PathList::new(&["/repo"]),
4650                t0,
4651            ),
4652            // 2: Linked worktree of /repo (opened more recently)
4653            //    Should dedup with #1; more recent timestamp wins.
4654            (
4655                WorkspaceId(2),
4656                SerializedWorkspaceLocation::Local,
4657                PathList::new(&["/worktree"]),
4658                t1,
4659            ),
4660            // 3: Mixed-path workspace: one root is a linked worktree,
4661            //    the other is a normal repo. The worktree path should be
4662            //    resolved; the normal path kept as-is.
4663            (
4664                WorkspaceId(3),
4665                SerializedWorkspaceLocation::Local,
4666                PathList::new(&["/other-repo", "/worktree"]),
4667                t2,
4668            ),
4669            // 4: Non-git project — passed through unchanged.
4670            (
4671                WorkspaceId(4),
4672                SerializedWorkspaceLocation::Local,
4673                PathList::new(&["/plain-project"]),
4674                t3,
4675            ),
4676        ];
4677
4678        let result = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
4679
4680        // Should have 3 entries: #1 and #2 deduped into one, plus #3 and #4.
4681        assert_eq!(result.len(), 3);
4682
4683        // First entry: /repo — deduplicated from #1 and #2.
4684        // Keeps the position of #1 (first seen), but with #2's later timestamp.
4685        assert_eq!(result[0].2.paths(), &[PathBuf::from("/repo")]);
4686        assert_eq!(result[0].3, t1);
4687
4688        // Second entry: mixed-path workspace with worktree resolved.
4689        // /worktree → /repo, so paths become [/other-repo, /repo] (sorted).
4690        assert_eq!(
4691            result[1].2.paths(),
4692            &[PathBuf::from("/other-repo"), PathBuf::from("/repo")]
4693        );
4694        assert_eq!(result[1].0, WorkspaceId(3));
4695
4696        // Third entry: non-git project, unchanged.
4697        assert_eq!(result[2].2.paths(), &[PathBuf::from("/plain-project")]);
4698        assert_eq!(result[2].0, WorkspaceId(4));
4699    }
4700
4701    #[gpui::test]
4702    async fn test_restore_window_with_linked_worktree_and_multiple_project_groups(
4703        cx: &mut gpui::TestAppContext,
4704    ) {
4705        crate::tests::init_test(cx);
4706
4707        cx.update(|cx| {
4708            cx.set_staff(true);
4709        });
4710
4711        let fs = fs::FakeFs::new(cx.executor());
4712
4713        // Main git repo at /repo
4714        fs.insert_tree(
4715            "/repo",
4716            json!({
4717                ".git": {
4718                    "HEAD": "ref: refs/heads/main",
4719                    "worktrees": {
4720                        "feature": {
4721                            "commondir": "../../",
4722                            "HEAD": "ref: refs/heads/feature"
4723                        }
4724                    }
4725                },
4726                "src": { "main.rs": "" }
4727            }),
4728        )
4729        .await;
4730
4731        // Linked worktree checkout pointing back to /repo
4732        fs.insert_tree(
4733            "/worktree-feature",
4734            json!({
4735                ".git": "gitdir: /repo/.git/worktrees/feature",
4736                "src": { "lib.rs": "" }
4737            }),
4738        )
4739        .await;
4740
4741        // --- Phase 1: Set up the original multi-workspace window ---
4742
4743        let project_1 = Project::test(fs.clone(), ["/repo".as_ref()], cx).await;
4744        let project_1_linked_worktree =
4745            Project::test(fs.clone(), ["/worktree-feature".as_ref()], cx).await;
4746
4747        // Wait for git discovery to finish.
4748        cx.run_until_parked();
4749
4750        // Create a second, unrelated project so we have two distinct project groups.
4751        fs.insert_tree(
4752            "/other-project",
4753            json!({
4754                ".git": { "HEAD": "ref: refs/heads/main" },
4755                "readme.md": ""
4756            }),
4757        )
4758        .await;
4759        let project_2 = Project::test(fs.clone(), ["/other-project".as_ref()], cx).await;
4760        cx.run_until_parked();
4761
4762        // Create the MultiWorkspace with project_2, then add the main repo
4763        // and its linked worktree. The linked worktree is added last and
4764        // becomes the active workspace.
4765        let (multi_workspace, cx) = cx
4766            .add_window_view(|window, cx| MultiWorkspace::test_new(project_2.clone(), window, cx));
4767
4768        multi_workspace.update(cx, |mw, cx| {
4769            mw.open_sidebar(cx);
4770        });
4771
4772        multi_workspace.update_in(cx, |mw, window, cx| {
4773            mw.test_add_workspace(project_1.clone(), window, cx);
4774        });
4775
4776        let workspace_worktree = multi_workspace.update_in(cx, |mw, window, cx| {
4777            mw.test_add_workspace(project_1_linked_worktree.clone(), window, cx)
4778        });
4779
4780        let tasks =
4781            multi_workspace.update_in(cx, |mw, window, cx| mw.flush_all_serialization(window, cx));
4782        cx.run_until_parked();
4783        for task in tasks {
4784            task.await;
4785        }
4786        cx.run_until_parked();
4787
4788        let active_db_id = workspace_worktree.read_with(cx, |ws, _| ws.database_id());
4789        assert!(
4790            active_db_id.is_some(),
4791            "Active workspace should have a database ID"
4792        );
4793
4794        // --- Phase 2: Read back and verify the serialized state ---
4795
4796        let session_id = multi_workspace
4797            .read_with(cx, |mw, cx| mw.workspace().read(cx).session_id())
4798            .unwrap();
4799        let db = cx.update(|_, cx| WorkspaceDb::global(cx));
4800        let session_workspaces = db
4801            .last_session_workspace_locations(&session_id, None, fs.as_ref())
4802            .await
4803            .expect("should load session workspaces");
4804        assert!(
4805            !session_workspaces.is_empty(),
4806            "Should have at least one session workspace"
4807        );
4808
4809        let multi_workspaces =
4810            cx.update(|_, cx| read_serialized_multi_workspaces(session_workspaces, cx));
4811        assert_eq!(
4812            multi_workspaces.len(),
4813            1,
4814            "All workspaces share one window, so there should be exactly one multi-workspace"
4815        );
4816
4817        let serialized = &multi_workspaces[0];
4818        assert_eq!(
4819            serialized.active_workspace.workspace_id,
4820            active_db_id.unwrap(),
4821        );
4822        assert_eq!(serialized.state.project_groups.len(), 2,);
4823
4824        // Verify the serialized project group keys round-trip back to the
4825        // originals.
4826        let restored_keys: Vec<ProjectGroupKey> = serialized
4827            .state
4828            .project_groups
4829            .iter()
4830            .cloned()
4831            .map(Into::into)
4832            .collect();
4833        let expected_keys = vec![
4834            ProjectGroupKey::new(None, PathList::new(&["/repo"])),
4835            ProjectGroupKey::new(None, PathList::new(&["/other-project"])),
4836        ];
4837        assert_eq!(
4838            restored_keys, expected_keys,
4839            "Deserialized project group keys should match the originals"
4840        );
4841
4842        // --- Phase 3: Restore the window and verify the result ---
4843
4844        let app_state =
4845            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).app_state().clone());
4846
4847        let serialized_mw = multi_workspaces.into_iter().next().unwrap();
4848        let restored_handle: gpui::WindowHandle<MultiWorkspace> = cx
4849            .update(|_, cx| {
4850                cx.spawn(async move |mut cx| {
4851                    crate::restore_multiworkspace(serialized_mw, app_state, &mut cx).await
4852                })
4853            })
4854            .await
4855            .expect("restore_multiworkspace should succeed");
4856
4857        cx.run_until_parked();
4858
4859        // The restored window should have the same project group keys.
4860        let restored_keys: Vec<ProjectGroupKey> = restored_handle
4861            .read_with(cx, |mw: &MultiWorkspace, _cx| mw.project_group_keys())
4862            .unwrap();
4863        assert_eq!(
4864            restored_keys, expected_keys,
4865            "Restored window should have the same project group keys as the original"
4866        );
4867
4868        // The active workspace in the restored window should have the linked
4869        // worktree paths.
4870        let active_paths: Vec<PathBuf> = restored_handle
4871            .read_with(cx, |mw: &MultiWorkspace, cx| {
4872                mw.workspace()
4873                    .read(cx)
4874                    .root_paths(cx)
4875                    .into_iter()
4876                    .map(|p: Arc<Path>| p.to_path_buf())
4877                    .collect()
4878            })
4879            .unwrap();
4880        assert_eq!(
4881            active_paths,
4882            vec![PathBuf::from("/worktree-feature")],
4883            "The restored active workspace should be the linked worktree project"
4884        );
4885    }
4886
4887    #[gpui::test]
4888    async fn test_remove_project_group_falls_back_to_neighbor(cx: &mut gpui::TestAppContext) {
4889        crate::tests::init_test(cx);
4890        cx.update(|cx| {
4891            cx.set_staff(true);
4892            cx.update_flags(true, vec!["agent-v2".to_string()]);
4893        });
4894
4895        let fs = fs::FakeFs::new(cx.executor());
4896        let dir_a = unique_test_dir(&fs, "group-a").await;
4897        let dir_b = unique_test_dir(&fs, "group-b").await;
4898        let dir_c = unique_test_dir(&fs, "group-c").await;
4899
4900        let project_a = Project::test(fs.clone(), [dir_a.as_path()], cx).await;
4901        let project_b = Project::test(fs.clone(), [dir_b.as_path()], cx).await;
4902        let project_c = Project::test(fs.clone(), [dir_c.as_path()], cx).await;
4903
4904        // Create a multi-workspace with project A, then add B and C.
4905        // project_groups stores newest first: [C, B, A].
4906        // Sidebar displays in the same order: C (top), B (middle), A (bottom).
4907        let (multi_workspace, cx) = cx
4908            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4909
4910        multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
4911
4912        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4913            mw.test_add_workspace(project_b.clone(), window, cx)
4914        });
4915        let _workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
4916            mw.test_add_workspace(project_c.clone(), window, cx)
4917        });
4918        cx.run_until_parked();
4919
4920        let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
4921        let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
4922        let key_c = project_c.read_with(cx, |p, cx| p.project_group_key(cx));
4923
4924        // Activate workspace B so removing its group exercises the fallback.
4925        multi_workspace.update_in(cx, |mw, window, cx| {
4926            mw.activate(workspace_b.clone(), window, cx);
4927        });
4928        cx.run_until_parked();
4929
4930        // --- Remove group B (the middle one). ---
4931        // In the sidebar [C, B, A], "below" B is A.
4932        multi_workspace.update_in(cx, |mw, window, cx| {
4933            mw.remove_project_group(&key_b, window, cx)
4934                .detach_and_log_err(cx);
4935        });
4936        cx.run_until_parked();
4937
4938        let active_paths =
4939            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4940        assert_eq!(
4941            active_paths
4942                .iter()
4943                .map(|p| p.to_path_buf())
4944                .collect::<Vec<_>>(),
4945            vec![dir_a.clone()],
4946            "After removing the middle group, should fall back to the group below (A)"
4947        );
4948
4949        // After removing B, keys = [A, C], sidebar = [C, A].
4950        // Activate workspace A (the bottom) so removing it tests the
4951        // "fall back upward" path.
4952        let workspace_a =
4953            multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
4954        multi_workspace.update_in(cx, |mw, window, cx| {
4955            mw.activate(workspace_a.clone(), window, cx);
4956        });
4957        cx.run_until_parked();
4958
4959        // --- Remove group A (the bottom one in sidebar). ---
4960        // Nothing below A, so should fall back upward to C.
4961        multi_workspace.update_in(cx, |mw, window, cx| {
4962            mw.remove_project_group(&key_a, window, cx)
4963                .detach_and_log_err(cx);
4964        });
4965        cx.run_until_parked();
4966
4967        let active_paths =
4968            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4969        assert_eq!(
4970            active_paths
4971                .iter()
4972                .map(|p| p.to_path_buf())
4973                .collect::<Vec<_>>(),
4974            vec![dir_c.clone()],
4975            "After removing the bottom group, should fall back to the group above (C)"
4976        );
4977
4978        // --- Remove group C (the only one remaining). ---
4979        // Should create an empty workspace.
4980        multi_workspace.update_in(cx, |mw, window, cx| {
4981            mw.remove_project_group(&key_c, window, cx)
4982                .detach_and_log_err(cx);
4983        });
4984        cx.run_until_parked();
4985
4986        let active_paths =
4987            multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
4988        assert!(
4989            active_paths.is_empty(),
4990            "After removing the only remaining group, should have an empty workspace"
4991        );
4992    }
4993
4994    /// Regression test for a crash where `find_or_create_local_workspace`
4995    /// returned a workspace that was about to be removed, hitting an assert
4996    /// in `MultiWorkspace::remove`.
4997    ///
4998    /// The scenario: two workspaces share the same root paths (e.g. due to
4999    /// a provisional key mismatch). When the first is removed and the
5000    /// fallback searches for the same paths, `workspace_for_paths` must
5001    /// skip the doomed workspace so the assert in `remove` is satisfied.
5002    #[gpui::test]
5003    async fn test_remove_fallback_skips_excluded_workspaces(cx: &mut gpui::TestAppContext) {
5004        crate::tests::init_test(cx);
5005        cx.update(|cx| {
5006            cx.set_staff(true);
5007            cx.update_flags(true, vec!["agent-v2".to_string()]);
5008        });
5009
5010        let fs = fs::FakeFs::new(cx.executor());
5011        let dir = unique_test_dir(&fs, "shared").await;
5012
5013        // Two projects that open the same directory — this creates two
5014        // workspaces whose root_paths are identical.
5015        let project_a = Project::test(fs.clone(), [dir.as_path()], cx).await;
5016        let project_b = Project::test(fs.clone(), [dir.as_path()], cx).await;
5017
5018        let (multi_workspace, cx) = cx
5019            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5020
5021        multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
5022
5023        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5024            mw.test_add_workspace(project_b.clone(), window, cx)
5025        });
5026        cx.run_until_parked();
5027
5028        // workspace_a is first in the workspaces vec.
5029        let workspace_a =
5030            multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().cloned().unwrap());
5031        assert_ne!(workspace_a, workspace_b);
5032
5033        // Activate workspace_a so removing it triggers the fallback path.
5034        multi_workspace.update_in(cx, |mw, window, cx| {
5035            mw.activate(workspace_a.clone(), window, cx);
5036        });
5037        cx.run_until_parked();
5038
5039        // Remove workspace_a. The fallback searches for the same paths.
5040        // Without the `excluding` parameter, `workspace_for_paths` would
5041        // return workspace_a (first match) and the assert in `remove`
5042        // would fire. With the fix, workspace_a is skipped and
5043        // workspace_b is found instead.
5044        let path_list = PathList::new(std::slice::from_ref(&dir));
5045        let excluded = vec![workspace_a.clone()];
5046        multi_workspace.update_in(cx, |mw, window, cx| {
5047            mw.remove(
5048                vec![workspace_a.clone()],
5049                move |this, window, cx| {
5050                    this.find_or_create_local_workspace(path_list, &excluded, window, cx)
5051                },
5052                window,
5053                cx,
5054            )
5055            .detach_and_log_err(cx);
5056        });
5057        cx.run_until_parked();
5058
5059        // workspace_b should now be active — workspace_a was removed.
5060        multi_workspace.read_with(cx, |mw, _cx| {
5061            assert_eq!(
5062                mw.workspace(),
5063                &workspace_b,
5064                "fallback should have found workspace_b, not the excluded workspace_a"
5065            );
5066        });
5067    }
5068}