Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:59:08.968Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

persistence.rs

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