Skip to repository content5955 lines · 217.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:59:08.968Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
persistence.rs
1pub mod model;
2
3use std::{
4 borrow::Cow,
5 collections::BTreeMap,
6 path::{Path, PathBuf},
7 str::FromStr,
8 sync::Arc,
9};
10
11use chrono::{DateTime, NaiveDateTime, Utc};
12use fs::Fs;
13
14use anyhow::{Context as _, Result, bail};
15use collections::{HashMap, HashSet, IndexSet};
16use db::{
17 kvp::KeyValueStore,
18 query,
19 sqlez::{connection::Connection, domain::Domain},
20 sqlez_macros::sql,
21};
22use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
23use project::{
24 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(×tamp),
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"], ¢er_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, ¢er_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