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