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