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