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