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