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::KEY_VALUE_STORE,
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() -> Option<(Uuid, WindowBounds)> {
178 let json_str = KEY_VALUE_STORE
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 bounds: WindowBounds,
190 display_uuid: Uuid,
191) -> anyhow::Result<()> {
192 let persisted = WindowBoundsJson::from(bounds);
193 let json_str = serde_json::to_string(&(display_uuid, persisted))?;
194 KEY_VALUE_STORE
195 .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 multi_workspace_states() -> db::kvp::ScopedKeyValueStore<'static> {
294 KEY_VALUE_STORE.scoped("multi_workspace_state")
295}
296
297fn read_multi_workspace_state(window_id: WindowId) -> model::MultiWorkspaceState {
298 multi_workspace_states()
299 .read(&window_id.as_u64().to_string())
300 .log_err()
301 .flatten()
302 .and_then(|json| serde_json::from_str(&json).ok())
303 .unwrap_or_default()
304}
305
306pub async fn write_multi_workspace_state(window_id: WindowId, state: model::MultiWorkspaceState) {
307 if let Ok(json_str) = serde_json::to_string(&state) {
308 multi_workspace_states()
309 .write(window_id.as_u64().to_string(), json_str)
310 .await
311 .log_err();
312 }
313}
314
315pub fn read_serialized_multi_workspaces(
316 session_workspaces: Vec<model::SessionWorkspace>,
317) -> Vec<model::SerializedMultiWorkspace> {
318 let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
319 let mut window_id_to_group: HashMap<WindowId, usize> = HashMap::default();
320
321 for session_workspace in session_workspaces {
322 match session_workspace.window_id {
323 Some(window_id) => {
324 let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| {
325 window_groups.push(Vec::new());
326 window_groups.len() - 1
327 });
328 window_groups[group_index].push(session_workspace);
329 }
330 None => {
331 window_groups.push(vec![session_workspace]);
332 }
333 }
334 }
335
336 window_groups
337 .into_iter()
338 .map(|group| {
339 let window_id = group.first().and_then(|sw| sw.window_id);
340 let state = window_id
341 .map(read_multi_workspace_state)
342 .unwrap_or_default();
343 model::SerializedMultiWorkspace {
344 id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
345 workspaces: group,
346 state,
347 }
348 })
349 .collect()
350}
351
352const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state";
353
354pub fn read_default_dock_state() -> Option<DockStructure> {
355 let json_str = KEY_VALUE_STORE
356 .read_kvp(DEFAULT_DOCK_STATE_KEY)
357 .log_err()
358 .flatten()?;
359
360 serde_json::from_str::<DockStructure>(&json_str).ok()
361}
362
363pub async fn write_default_dock_state(docks: DockStructure) -> anyhow::Result<()> {
364 let json_str = serde_json::to_string(&docks)?;
365 KEY_VALUE_STORE
366 .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!(DB, 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 DB.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 = DB.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 #[gpui::test]
2406 async fn test_multi_workspace_serializes_on_add_and_remove(cx: &mut gpui::TestAppContext) {
2407 use crate::multi_workspace::MultiWorkspace;
2408 use crate::persistence::read_multi_workspace_state;
2409 use feature_flags::FeatureFlagAppExt;
2410 use gpui::AppContext as _;
2411 use project::Project;
2412
2413 crate::tests::init_test(cx);
2414
2415 cx.update(|cx| {
2416 cx.set_staff(true);
2417 cx.update_flags(true, vec!["agent-v2".to_string()]);
2418 });
2419
2420 let fs = fs::FakeFs::new(cx.executor());
2421 let project1 = Project::test(fs.clone(), [], cx).await;
2422 let project2 = Project::test(fs.clone(), [], cx).await;
2423
2424 let (multi_workspace, cx) =
2425 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
2426
2427 multi_workspace.update_in(cx, |mw, _, cx| {
2428 mw.set_random_database_id(cx);
2429 });
2430
2431 let window_id =
2432 multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
2433
2434 // --- Add a second workspace ---
2435 let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
2436 let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
2437 workspace.update(cx, |ws, _cx| ws.set_random_database_id());
2438 mw.activate(workspace.clone(), cx);
2439 workspace
2440 });
2441
2442 // Run background tasks so serialize has a chance to flush.
2443 cx.run_until_parked();
2444
2445 // Read back the persisted state and check that the active workspace ID was written.
2446 let state_after_add = read_multi_workspace_state(window_id);
2447 let active_workspace2_db_id = workspace2.read_with(cx, |ws, _| ws.database_id());
2448 assert_eq!(
2449 state_after_add.active_workspace_id, active_workspace2_db_id,
2450 "After adding a second workspace, the serialized active_workspace_id should match \
2451 the newly activated workspace's database id"
2452 );
2453
2454 // --- Remove the second workspace (index 1) ---
2455 multi_workspace.update_in(cx, |mw, window, cx| {
2456 mw.remove_workspace(1, window, cx);
2457 });
2458
2459 cx.run_until_parked();
2460
2461 let state_after_remove = read_multi_workspace_state(window_id);
2462 let remaining_db_id =
2463 multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
2464 assert_eq!(
2465 state_after_remove.active_workspace_id, remaining_db_id,
2466 "After removing a workspace, the serialized active_workspace_id should match \
2467 the remaining active workspace's database id"
2468 );
2469 }
2470
2471 #[gpui::test]
2472 async fn test_breakpoints() {
2473 zlog::init_test();
2474
2475 let db = WorkspaceDb::open_test_db("test_breakpoints").await;
2476 let id = db.next_id().await.unwrap();
2477
2478 let path = Path::new("/tmp/test.rs");
2479
2480 let breakpoint = Breakpoint {
2481 position: 123,
2482 message: None,
2483 state: BreakpointState::Enabled,
2484 condition: None,
2485 hit_condition: None,
2486 };
2487
2488 let log_breakpoint = Breakpoint {
2489 position: 456,
2490 message: Some("Test log message".into()),
2491 state: BreakpointState::Enabled,
2492 condition: None,
2493 hit_condition: None,
2494 };
2495
2496 let disable_breakpoint = Breakpoint {
2497 position: 578,
2498 message: None,
2499 state: BreakpointState::Disabled,
2500 condition: None,
2501 hit_condition: None,
2502 };
2503
2504 let condition_breakpoint = Breakpoint {
2505 position: 789,
2506 message: None,
2507 state: BreakpointState::Enabled,
2508 condition: Some("x > 5".into()),
2509 hit_condition: None,
2510 };
2511
2512 let hit_condition_breakpoint = Breakpoint {
2513 position: 999,
2514 message: None,
2515 state: BreakpointState::Enabled,
2516 condition: None,
2517 hit_condition: Some(">= 3".into()),
2518 };
2519
2520 let workspace = SerializedWorkspace {
2521 id,
2522 paths: PathList::new(&["/tmp"]),
2523 location: SerializedWorkspaceLocation::Local,
2524 center_group: Default::default(),
2525 window_bounds: Default::default(),
2526 display: Default::default(),
2527 docks: Default::default(),
2528 centered_layout: false,
2529 breakpoints: {
2530 let mut map = collections::BTreeMap::default();
2531 map.insert(
2532 Arc::from(path),
2533 vec![
2534 SourceBreakpoint {
2535 row: breakpoint.position,
2536 path: Arc::from(path),
2537 message: breakpoint.message.clone(),
2538 state: breakpoint.state,
2539 condition: breakpoint.condition.clone(),
2540 hit_condition: breakpoint.hit_condition.clone(),
2541 },
2542 SourceBreakpoint {
2543 row: log_breakpoint.position,
2544 path: Arc::from(path),
2545 message: log_breakpoint.message.clone(),
2546 state: log_breakpoint.state,
2547 condition: log_breakpoint.condition.clone(),
2548 hit_condition: log_breakpoint.hit_condition.clone(),
2549 },
2550 SourceBreakpoint {
2551 row: disable_breakpoint.position,
2552 path: Arc::from(path),
2553 message: disable_breakpoint.message.clone(),
2554 state: disable_breakpoint.state,
2555 condition: disable_breakpoint.condition.clone(),
2556 hit_condition: disable_breakpoint.hit_condition.clone(),
2557 },
2558 SourceBreakpoint {
2559 row: condition_breakpoint.position,
2560 path: Arc::from(path),
2561 message: condition_breakpoint.message.clone(),
2562 state: condition_breakpoint.state,
2563 condition: condition_breakpoint.condition.clone(),
2564 hit_condition: condition_breakpoint.hit_condition.clone(),
2565 },
2566 SourceBreakpoint {
2567 row: hit_condition_breakpoint.position,
2568 path: Arc::from(path),
2569 message: hit_condition_breakpoint.message.clone(),
2570 state: hit_condition_breakpoint.state,
2571 condition: hit_condition_breakpoint.condition.clone(),
2572 hit_condition: hit_condition_breakpoint.hit_condition.clone(),
2573 },
2574 ],
2575 );
2576 map
2577 },
2578 session_id: None,
2579 window_id: None,
2580 user_toolchains: Default::default(),
2581 };
2582
2583 db.save_workspace(workspace.clone()).await;
2584
2585 let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2586 let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap();
2587
2588 assert_eq!(loaded_breakpoints.len(), 5);
2589
2590 // normal breakpoint
2591 assert_eq!(loaded_breakpoints[0].row, breakpoint.position);
2592 assert_eq!(loaded_breakpoints[0].message, breakpoint.message);
2593 assert_eq!(loaded_breakpoints[0].condition, breakpoint.condition);
2594 assert_eq!(
2595 loaded_breakpoints[0].hit_condition,
2596 breakpoint.hit_condition
2597 );
2598 assert_eq!(loaded_breakpoints[0].state, breakpoint.state);
2599 assert_eq!(loaded_breakpoints[0].path, Arc::from(path));
2600
2601 // enabled breakpoint
2602 assert_eq!(loaded_breakpoints[1].row, log_breakpoint.position);
2603 assert_eq!(loaded_breakpoints[1].message, log_breakpoint.message);
2604 assert_eq!(loaded_breakpoints[1].condition, log_breakpoint.condition);
2605 assert_eq!(
2606 loaded_breakpoints[1].hit_condition,
2607 log_breakpoint.hit_condition
2608 );
2609 assert_eq!(loaded_breakpoints[1].state, log_breakpoint.state);
2610 assert_eq!(loaded_breakpoints[1].path, Arc::from(path));
2611
2612 // disable breakpoint
2613 assert_eq!(loaded_breakpoints[2].row, disable_breakpoint.position);
2614 assert_eq!(loaded_breakpoints[2].message, disable_breakpoint.message);
2615 assert_eq!(
2616 loaded_breakpoints[2].condition,
2617 disable_breakpoint.condition
2618 );
2619 assert_eq!(
2620 loaded_breakpoints[2].hit_condition,
2621 disable_breakpoint.hit_condition
2622 );
2623 assert_eq!(loaded_breakpoints[2].state, disable_breakpoint.state);
2624 assert_eq!(loaded_breakpoints[2].path, Arc::from(path));
2625
2626 // condition breakpoint
2627 assert_eq!(loaded_breakpoints[3].row, condition_breakpoint.position);
2628 assert_eq!(loaded_breakpoints[3].message, condition_breakpoint.message);
2629 assert_eq!(
2630 loaded_breakpoints[3].condition,
2631 condition_breakpoint.condition
2632 );
2633 assert_eq!(
2634 loaded_breakpoints[3].hit_condition,
2635 condition_breakpoint.hit_condition
2636 );
2637 assert_eq!(loaded_breakpoints[3].state, condition_breakpoint.state);
2638 assert_eq!(loaded_breakpoints[3].path, Arc::from(path));
2639
2640 // hit condition breakpoint
2641 assert_eq!(loaded_breakpoints[4].row, hit_condition_breakpoint.position);
2642 assert_eq!(
2643 loaded_breakpoints[4].message,
2644 hit_condition_breakpoint.message
2645 );
2646 assert_eq!(
2647 loaded_breakpoints[4].condition,
2648 hit_condition_breakpoint.condition
2649 );
2650 assert_eq!(
2651 loaded_breakpoints[4].hit_condition,
2652 hit_condition_breakpoint.hit_condition
2653 );
2654 assert_eq!(loaded_breakpoints[4].state, hit_condition_breakpoint.state);
2655 assert_eq!(loaded_breakpoints[4].path, Arc::from(path));
2656 }
2657
2658 #[gpui::test]
2659 async fn test_remove_last_breakpoint() {
2660 zlog::init_test();
2661
2662 let db = WorkspaceDb::open_test_db("test_remove_last_breakpoint").await;
2663 let id = db.next_id().await.unwrap();
2664
2665 let singular_path = Path::new("/tmp/test_remove_last_breakpoint.rs");
2666
2667 let breakpoint_to_remove = Breakpoint {
2668 position: 100,
2669 message: None,
2670 state: BreakpointState::Enabled,
2671 condition: None,
2672 hit_condition: None,
2673 };
2674
2675 let workspace = SerializedWorkspace {
2676 id,
2677 paths: PathList::new(&["/tmp"]),
2678 location: SerializedWorkspaceLocation::Local,
2679 center_group: Default::default(),
2680 window_bounds: Default::default(),
2681 display: Default::default(),
2682 docks: Default::default(),
2683 centered_layout: false,
2684 breakpoints: {
2685 let mut map = collections::BTreeMap::default();
2686 map.insert(
2687 Arc::from(singular_path),
2688 vec![SourceBreakpoint {
2689 row: breakpoint_to_remove.position,
2690 path: Arc::from(singular_path),
2691 message: None,
2692 state: BreakpointState::Enabled,
2693 condition: None,
2694 hit_condition: None,
2695 }],
2696 );
2697 map
2698 },
2699 session_id: None,
2700 window_id: None,
2701 user_toolchains: Default::default(),
2702 };
2703
2704 db.save_workspace(workspace.clone()).await;
2705
2706 let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
2707 let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(singular_path)).unwrap();
2708
2709 assert_eq!(loaded_breakpoints.len(), 1);
2710 assert_eq!(loaded_breakpoints[0].row, breakpoint_to_remove.position);
2711 assert_eq!(loaded_breakpoints[0].message, breakpoint_to_remove.message);
2712 assert_eq!(
2713 loaded_breakpoints[0].condition,
2714 breakpoint_to_remove.condition
2715 );
2716 assert_eq!(
2717 loaded_breakpoints[0].hit_condition,
2718 breakpoint_to_remove.hit_condition
2719 );
2720 assert_eq!(loaded_breakpoints[0].state, breakpoint_to_remove.state);
2721 assert_eq!(loaded_breakpoints[0].path, Arc::from(singular_path));
2722
2723 let workspace_without_breakpoint = SerializedWorkspace {
2724 id,
2725 paths: PathList::new(&["/tmp"]),
2726 location: SerializedWorkspaceLocation::Local,
2727 center_group: Default::default(),
2728 window_bounds: Default::default(),
2729 display: Default::default(),
2730 docks: Default::default(),
2731 centered_layout: false,
2732 breakpoints: collections::BTreeMap::default(),
2733 session_id: None,
2734 window_id: None,
2735 user_toolchains: Default::default(),
2736 };
2737
2738 db.save_workspace(workspace_without_breakpoint.clone())
2739 .await;
2740
2741 let loaded_after_remove = db.workspace_for_roots(&["/tmp"]).unwrap();
2742 let empty_breakpoints = loaded_after_remove
2743 .breakpoints
2744 .get(&Arc::from(singular_path));
2745
2746 assert!(empty_breakpoints.is_none());
2747 }
2748
2749 #[gpui::test]
2750 async fn test_next_id_stability() {
2751 zlog::init_test();
2752
2753 let db = WorkspaceDb::open_test_db("test_next_id_stability").await;
2754
2755 db.write(|conn| {
2756 conn.migrate(
2757 "test_table",
2758 &[sql!(
2759 CREATE TABLE test_table(
2760 text TEXT,
2761 workspace_id INTEGER,
2762 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
2763 ON DELETE CASCADE
2764 ) STRICT;
2765 )],
2766 &mut |_, _, _| false,
2767 )
2768 .unwrap();
2769 })
2770 .await;
2771
2772 let id = db.next_id().await.unwrap();
2773 // Assert the empty row got inserted
2774 assert_eq!(
2775 Some(id),
2776 db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
2777 SELECT workspace_id FROM workspaces WHERE workspace_id = ?
2778 ))
2779 .unwrap()(id)
2780 .unwrap()
2781 );
2782
2783 db.write(move |conn| {
2784 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2785 .unwrap()(("test-text-1", id))
2786 .unwrap()
2787 })
2788 .await;
2789
2790 let test_text_1 = db
2791 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2792 .unwrap()(1)
2793 .unwrap()
2794 .unwrap();
2795 assert_eq!(test_text_1, "test-text-1");
2796 }
2797
2798 #[gpui::test]
2799 async fn test_workspace_id_stability() {
2800 zlog::init_test();
2801
2802 let db = WorkspaceDb::open_test_db("test_workspace_id_stability").await;
2803
2804 db.write(|conn| {
2805 conn.migrate(
2806 "test_table",
2807 &[sql!(
2808 CREATE TABLE test_table(
2809 text TEXT,
2810 workspace_id INTEGER,
2811 FOREIGN KEY(workspace_id)
2812 REFERENCES workspaces(workspace_id)
2813 ON DELETE CASCADE
2814 ) STRICT;)],
2815 &mut |_, _, _| false,
2816 )
2817 })
2818 .await
2819 .unwrap();
2820
2821 let mut workspace_1 = SerializedWorkspace {
2822 id: WorkspaceId(1),
2823 paths: PathList::new(&["/tmp", "/tmp2"]),
2824 location: SerializedWorkspaceLocation::Local,
2825 center_group: Default::default(),
2826 window_bounds: Default::default(),
2827 display: Default::default(),
2828 docks: Default::default(),
2829 centered_layout: false,
2830 breakpoints: Default::default(),
2831 session_id: None,
2832 window_id: None,
2833 user_toolchains: Default::default(),
2834 };
2835
2836 let workspace_2 = SerializedWorkspace {
2837 id: WorkspaceId(2),
2838 paths: PathList::new(&["/tmp"]),
2839 location: SerializedWorkspaceLocation::Local,
2840 center_group: Default::default(),
2841 window_bounds: Default::default(),
2842 display: Default::default(),
2843 docks: Default::default(),
2844 centered_layout: false,
2845 breakpoints: Default::default(),
2846 session_id: None,
2847 window_id: None,
2848 user_toolchains: Default::default(),
2849 };
2850
2851 db.save_workspace(workspace_1.clone()).await;
2852
2853 db.write(|conn| {
2854 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2855 .unwrap()(("test-text-1", 1))
2856 .unwrap();
2857 })
2858 .await;
2859
2860 db.save_workspace(workspace_2.clone()).await;
2861
2862 db.write(|conn| {
2863 conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
2864 .unwrap()(("test-text-2", 2))
2865 .unwrap();
2866 })
2867 .await;
2868
2869 workspace_1.paths = PathList::new(&["/tmp", "/tmp3"]);
2870 db.save_workspace(workspace_1.clone()).await;
2871 db.save_workspace(workspace_1).await;
2872 db.save_workspace(workspace_2).await;
2873
2874 let test_text_2 = db
2875 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2876 .unwrap()(2)
2877 .unwrap()
2878 .unwrap();
2879 assert_eq!(test_text_2, "test-text-2");
2880
2881 let test_text_1 = db
2882 .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
2883 .unwrap()(1)
2884 .unwrap()
2885 .unwrap();
2886 assert_eq!(test_text_1, "test-text-1");
2887 }
2888
2889 fn group(axis: Axis, children: Vec<SerializedPaneGroup>) -> SerializedPaneGroup {
2890 SerializedPaneGroup::Group {
2891 axis: SerializedAxis(axis),
2892 flexes: None,
2893 children,
2894 }
2895 }
2896
2897 #[gpui::test]
2898 async fn test_full_workspace_serialization() {
2899 zlog::init_test();
2900
2901 let db = WorkspaceDb::open_test_db("test_full_workspace_serialization").await;
2902
2903 // -----------------
2904 // | 1,2 | 5,6 |
2905 // | - - - | |
2906 // | 3,4 | |
2907 // -----------------
2908 let center_group = group(
2909 Axis::Horizontal,
2910 vec![
2911 group(
2912 Axis::Vertical,
2913 vec![
2914 SerializedPaneGroup::Pane(SerializedPane::new(
2915 vec![
2916 SerializedItem::new("Terminal", 5, false, false),
2917 SerializedItem::new("Terminal", 6, true, false),
2918 ],
2919 false,
2920 0,
2921 )),
2922 SerializedPaneGroup::Pane(SerializedPane::new(
2923 vec![
2924 SerializedItem::new("Terminal", 7, true, false),
2925 SerializedItem::new("Terminal", 8, false, false),
2926 ],
2927 false,
2928 0,
2929 )),
2930 ],
2931 ),
2932 SerializedPaneGroup::Pane(SerializedPane::new(
2933 vec![
2934 SerializedItem::new("Terminal", 9, false, false),
2935 SerializedItem::new("Terminal", 10, true, false),
2936 ],
2937 false,
2938 0,
2939 )),
2940 ],
2941 );
2942
2943 let workspace = SerializedWorkspace {
2944 id: WorkspaceId(5),
2945 paths: PathList::new(&["/tmp", "/tmp2"]),
2946 location: SerializedWorkspaceLocation::Local,
2947 center_group,
2948 window_bounds: Default::default(),
2949 breakpoints: Default::default(),
2950 display: Default::default(),
2951 docks: Default::default(),
2952 centered_layout: false,
2953 session_id: None,
2954 window_id: Some(999),
2955 user_toolchains: Default::default(),
2956 };
2957
2958 db.save_workspace(workspace.clone()).await;
2959
2960 let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
2961 assert_eq!(workspace, round_trip_workspace.unwrap());
2962
2963 // Test guaranteed duplicate IDs
2964 db.save_workspace(workspace.clone()).await;
2965 db.save_workspace(workspace.clone()).await;
2966
2967 let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
2968 assert_eq!(workspace, round_trip_workspace.unwrap());
2969 }
2970
2971 #[gpui::test]
2972 async fn test_workspace_assignment() {
2973 zlog::init_test();
2974
2975 let db = WorkspaceDb::open_test_db("test_basic_functionality").await;
2976
2977 let workspace_1 = SerializedWorkspace {
2978 id: WorkspaceId(1),
2979 paths: PathList::new(&["/tmp", "/tmp2"]),
2980 location: SerializedWorkspaceLocation::Local,
2981 center_group: Default::default(),
2982 window_bounds: Default::default(),
2983 breakpoints: Default::default(),
2984 display: Default::default(),
2985 docks: Default::default(),
2986 centered_layout: false,
2987 session_id: None,
2988 window_id: Some(1),
2989 user_toolchains: Default::default(),
2990 };
2991
2992 let mut workspace_2 = SerializedWorkspace {
2993 id: WorkspaceId(2),
2994 paths: PathList::new(&["/tmp"]),
2995 location: SerializedWorkspaceLocation::Local,
2996 center_group: Default::default(),
2997 window_bounds: Default::default(),
2998 display: Default::default(),
2999 docks: Default::default(),
3000 centered_layout: false,
3001 breakpoints: Default::default(),
3002 session_id: None,
3003 window_id: Some(2),
3004 user_toolchains: Default::default(),
3005 };
3006
3007 db.save_workspace(workspace_1.clone()).await;
3008 db.save_workspace(workspace_2.clone()).await;
3009
3010 // Test that paths are treated as a set
3011 assert_eq!(
3012 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3013 workspace_1
3014 );
3015 assert_eq!(
3016 db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
3017 workspace_1
3018 );
3019
3020 // Make sure that other keys work
3021 assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
3022 assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
3023
3024 // Test 'mutate' case of updating a pre-existing id
3025 workspace_2.paths = PathList::new(&["/tmp", "/tmp2"]);
3026
3027 db.save_workspace(workspace_2.clone()).await;
3028 assert_eq!(
3029 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3030 workspace_2
3031 );
3032
3033 // Test other mechanism for mutating
3034 let mut workspace_3 = SerializedWorkspace {
3035 id: WorkspaceId(3),
3036 paths: PathList::new(&["/tmp2", "/tmp"]),
3037 location: SerializedWorkspaceLocation::Local,
3038 center_group: Default::default(),
3039 window_bounds: Default::default(),
3040 breakpoints: Default::default(),
3041 display: Default::default(),
3042 docks: Default::default(),
3043 centered_layout: false,
3044 session_id: None,
3045 window_id: Some(3),
3046 user_toolchains: Default::default(),
3047 };
3048
3049 db.save_workspace(workspace_3.clone()).await;
3050 assert_eq!(
3051 db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
3052 workspace_3
3053 );
3054
3055 // Make sure that updating paths differently also works
3056 workspace_3.paths = PathList::new(&["/tmp3", "/tmp4", "/tmp2"]);
3057 db.save_workspace(workspace_3.clone()).await;
3058 assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
3059 assert_eq!(
3060 db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
3061 .unwrap(),
3062 workspace_3
3063 );
3064 }
3065
3066 #[gpui::test]
3067 async fn test_session_workspaces() {
3068 zlog::init_test();
3069
3070 let db = WorkspaceDb::open_test_db("test_serializing_workspaces_session_id").await;
3071
3072 let workspace_1 = SerializedWorkspace {
3073 id: WorkspaceId(1),
3074 paths: PathList::new(&["/tmp1"]),
3075 location: SerializedWorkspaceLocation::Local,
3076 center_group: Default::default(),
3077 window_bounds: Default::default(),
3078 display: Default::default(),
3079 docks: Default::default(),
3080 centered_layout: false,
3081 breakpoints: Default::default(),
3082 session_id: Some("session-id-1".to_owned()),
3083 window_id: Some(10),
3084 user_toolchains: Default::default(),
3085 };
3086
3087 let workspace_2 = SerializedWorkspace {
3088 id: WorkspaceId(2),
3089 paths: PathList::new(&["/tmp2"]),
3090 location: SerializedWorkspaceLocation::Local,
3091 center_group: Default::default(),
3092 window_bounds: Default::default(),
3093 display: Default::default(),
3094 docks: Default::default(),
3095 centered_layout: false,
3096 breakpoints: Default::default(),
3097 session_id: Some("session-id-1".to_owned()),
3098 window_id: Some(20),
3099 user_toolchains: Default::default(),
3100 };
3101
3102 let workspace_3 = SerializedWorkspace {
3103 id: WorkspaceId(3),
3104 paths: PathList::new(&["/tmp3"]),
3105 location: SerializedWorkspaceLocation::Local,
3106 center_group: Default::default(),
3107 window_bounds: Default::default(),
3108 display: Default::default(),
3109 docks: Default::default(),
3110 centered_layout: false,
3111 breakpoints: Default::default(),
3112 session_id: Some("session-id-2".to_owned()),
3113 window_id: Some(30),
3114 user_toolchains: Default::default(),
3115 };
3116
3117 let workspace_4 = SerializedWorkspace {
3118 id: WorkspaceId(4),
3119 paths: PathList::new(&["/tmp4"]),
3120 location: SerializedWorkspaceLocation::Local,
3121 center_group: Default::default(),
3122 window_bounds: Default::default(),
3123 display: Default::default(),
3124 docks: Default::default(),
3125 centered_layout: false,
3126 breakpoints: Default::default(),
3127 session_id: None,
3128 window_id: None,
3129 user_toolchains: Default::default(),
3130 };
3131
3132 let connection_id = db
3133 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3134 host: "my-host".into(),
3135 port: Some(1234),
3136 ..Default::default()
3137 }))
3138 .await
3139 .unwrap();
3140
3141 let workspace_5 = SerializedWorkspace {
3142 id: WorkspaceId(5),
3143 paths: PathList::default(),
3144 location: SerializedWorkspaceLocation::Remote(
3145 db.remote_connection(connection_id).unwrap(),
3146 ),
3147 center_group: Default::default(),
3148 window_bounds: Default::default(),
3149 display: Default::default(),
3150 docks: Default::default(),
3151 centered_layout: false,
3152 breakpoints: Default::default(),
3153 session_id: Some("session-id-2".to_owned()),
3154 window_id: Some(50),
3155 user_toolchains: Default::default(),
3156 };
3157
3158 let workspace_6 = SerializedWorkspace {
3159 id: WorkspaceId(6),
3160 paths: PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3161 location: SerializedWorkspaceLocation::Local,
3162 center_group: Default::default(),
3163 window_bounds: Default::default(),
3164 breakpoints: Default::default(),
3165 display: Default::default(),
3166 docks: Default::default(),
3167 centered_layout: false,
3168 session_id: Some("session-id-3".to_owned()),
3169 window_id: Some(60),
3170 user_toolchains: Default::default(),
3171 };
3172
3173 db.save_workspace(workspace_1.clone()).await;
3174 thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3175 db.save_workspace(workspace_2.clone()).await;
3176 db.save_workspace(workspace_3.clone()).await;
3177 thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
3178 db.save_workspace(workspace_4.clone()).await;
3179 db.save_workspace(workspace_5.clone()).await;
3180 db.save_workspace(workspace_6.clone()).await;
3181
3182 let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
3183 assert_eq!(locations.len(), 2);
3184 assert_eq!(locations[0].0, WorkspaceId(2));
3185 assert_eq!(locations[0].1, PathList::new(&["/tmp2"]));
3186 assert_eq!(locations[0].2, Some(20));
3187 assert_eq!(locations[1].0, WorkspaceId(1));
3188 assert_eq!(locations[1].1, PathList::new(&["/tmp1"]));
3189 assert_eq!(locations[1].2, Some(10));
3190
3191 let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
3192 assert_eq!(locations.len(), 2);
3193 assert_eq!(locations[0].0, WorkspaceId(5));
3194 assert_eq!(locations[0].1, PathList::default());
3195 assert_eq!(locations[0].2, Some(50));
3196 assert_eq!(locations[0].3, Some(connection_id));
3197 assert_eq!(locations[1].0, WorkspaceId(3));
3198 assert_eq!(locations[1].1, PathList::new(&["/tmp3"]));
3199 assert_eq!(locations[1].2, Some(30));
3200
3201 let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
3202 assert_eq!(locations.len(), 1);
3203 assert_eq!(locations[0].0, WorkspaceId(6));
3204 assert_eq!(
3205 locations[0].1,
3206 PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
3207 );
3208 assert_eq!(locations[0].2, Some(60));
3209 }
3210
3211 fn default_workspace<P: AsRef<Path>>(
3212 paths: &[P],
3213 center_group: &SerializedPaneGroup,
3214 ) -> SerializedWorkspace {
3215 SerializedWorkspace {
3216 id: WorkspaceId(4),
3217 paths: PathList::new(paths),
3218 location: SerializedWorkspaceLocation::Local,
3219 center_group: center_group.clone(),
3220 window_bounds: Default::default(),
3221 display: Default::default(),
3222 docks: Default::default(),
3223 breakpoints: Default::default(),
3224 centered_layout: false,
3225 session_id: None,
3226 window_id: None,
3227 user_toolchains: Default::default(),
3228 }
3229 }
3230
3231 #[gpui::test]
3232 async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) {
3233 let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3234 let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3235 let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3236 let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3237
3238 let fs = fs::FakeFs::new(cx.executor());
3239 fs.insert_tree(dir1.path(), json!({})).await;
3240 fs.insert_tree(dir2.path(), json!({})).await;
3241 fs.insert_tree(dir3.path(), json!({})).await;
3242 fs.insert_tree(dir4.path(), json!({})).await;
3243
3244 let db =
3245 WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
3246
3247 let workspaces = [
3248 (1, vec![dir1.path()], 9),
3249 (2, vec![dir2.path()], 5),
3250 (3, vec![dir3.path()], 8),
3251 (4, vec![dir4.path()], 2),
3252 (5, vec![dir1.path(), dir2.path(), dir3.path()], 3),
3253 (6, vec![dir4.path(), dir3.path(), dir2.path()], 4),
3254 ]
3255 .into_iter()
3256 .map(|(id, paths, window_id)| SerializedWorkspace {
3257 id: WorkspaceId(id),
3258 paths: PathList::new(paths.as_slice()),
3259 location: SerializedWorkspaceLocation::Local,
3260 center_group: Default::default(),
3261 window_bounds: Default::default(),
3262 display: Default::default(),
3263 docks: Default::default(),
3264 centered_layout: false,
3265 session_id: Some("one-session".to_owned()),
3266 breakpoints: Default::default(),
3267 window_id: Some(window_id),
3268 user_toolchains: Default::default(),
3269 })
3270 .collect::<Vec<_>>();
3271
3272 for workspace in workspaces.iter() {
3273 db.save_workspace(workspace.clone()).await;
3274 }
3275
3276 let stack = Some(Vec::from([
3277 WindowId::from(2), // Top
3278 WindowId::from(8),
3279 WindowId::from(5),
3280 WindowId::from(9),
3281 WindowId::from(3),
3282 WindowId::from(4), // Bottom
3283 ]));
3284
3285 let locations = db
3286 .last_session_workspace_locations("one-session", stack, fs.as_ref())
3287 .await
3288 .unwrap();
3289 assert_eq!(
3290 locations,
3291 [
3292 SessionWorkspace {
3293 workspace_id: WorkspaceId(4),
3294 location: SerializedWorkspaceLocation::Local,
3295 paths: PathList::new(&[dir4.path()]),
3296 window_id: Some(WindowId::from(2u64)),
3297 },
3298 SessionWorkspace {
3299 workspace_id: WorkspaceId(3),
3300 location: SerializedWorkspaceLocation::Local,
3301 paths: PathList::new(&[dir3.path()]),
3302 window_id: Some(WindowId::from(8u64)),
3303 },
3304 SessionWorkspace {
3305 workspace_id: WorkspaceId(2),
3306 location: SerializedWorkspaceLocation::Local,
3307 paths: PathList::new(&[dir2.path()]),
3308 window_id: Some(WindowId::from(5u64)),
3309 },
3310 SessionWorkspace {
3311 workspace_id: WorkspaceId(1),
3312 location: SerializedWorkspaceLocation::Local,
3313 paths: PathList::new(&[dir1.path()]),
3314 window_id: Some(WindowId::from(9u64)),
3315 },
3316 SessionWorkspace {
3317 workspace_id: WorkspaceId(5),
3318 location: SerializedWorkspaceLocation::Local,
3319 paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
3320 window_id: Some(WindowId::from(3u64)),
3321 },
3322 SessionWorkspace {
3323 workspace_id: WorkspaceId(6),
3324 location: SerializedWorkspaceLocation::Local,
3325 paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
3326 window_id: Some(WindowId::from(4u64)),
3327 },
3328 ]
3329 );
3330 }
3331
3332 #[gpui::test]
3333 async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
3334 let fs = fs::FakeFs::new(cx.executor());
3335 let db =
3336 WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
3337 .await;
3338
3339 let remote_connections = [
3340 ("host-1", "my-user-1"),
3341 ("host-2", "my-user-2"),
3342 ("host-3", "my-user-3"),
3343 ("host-4", "my-user-4"),
3344 ]
3345 .into_iter()
3346 .map(|(host, user)| async {
3347 let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
3348 host: host.into(),
3349 username: Some(user.to_string()),
3350 ..Default::default()
3351 });
3352 db.get_or_create_remote_connection(options.clone())
3353 .await
3354 .unwrap();
3355 options
3356 })
3357 .collect::<Vec<_>>();
3358
3359 let remote_connections = futures::future::join_all(remote_connections).await;
3360
3361 let workspaces = [
3362 (1, remote_connections[0].clone(), 9),
3363 (2, remote_connections[1].clone(), 5),
3364 (3, remote_connections[2].clone(), 8),
3365 (4, remote_connections[3].clone(), 2),
3366 ]
3367 .into_iter()
3368 .map(|(id, remote_connection, window_id)| SerializedWorkspace {
3369 id: WorkspaceId(id),
3370 paths: PathList::default(),
3371 location: SerializedWorkspaceLocation::Remote(remote_connection),
3372 center_group: Default::default(),
3373 window_bounds: Default::default(),
3374 display: Default::default(),
3375 docks: Default::default(),
3376 centered_layout: false,
3377 session_id: Some("one-session".to_owned()),
3378 breakpoints: Default::default(),
3379 window_id: Some(window_id),
3380 user_toolchains: Default::default(),
3381 })
3382 .collect::<Vec<_>>();
3383
3384 for workspace in workspaces.iter() {
3385 db.save_workspace(workspace.clone()).await;
3386 }
3387
3388 let stack = Some(Vec::from([
3389 WindowId::from(2), // Top
3390 WindowId::from(8),
3391 WindowId::from(5),
3392 WindowId::from(9), // Bottom
3393 ]));
3394
3395 let have = db
3396 .last_session_workspace_locations("one-session", stack, fs.as_ref())
3397 .await
3398 .unwrap();
3399 assert_eq!(have.len(), 4);
3400 assert_eq!(
3401 have[0],
3402 SessionWorkspace {
3403 workspace_id: WorkspaceId(4),
3404 location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
3405 paths: PathList::default(),
3406 window_id: Some(WindowId::from(2u64)),
3407 }
3408 );
3409 assert_eq!(
3410 have[1],
3411 SessionWorkspace {
3412 workspace_id: WorkspaceId(3),
3413 location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
3414 paths: PathList::default(),
3415 window_id: Some(WindowId::from(8u64)),
3416 }
3417 );
3418 assert_eq!(
3419 have[2],
3420 SessionWorkspace {
3421 workspace_id: WorkspaceId(2),
3422 location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
3423 paths: PathList::default(),
3424 window_id: Some(WindowId::from(5u64)),
3425 }
3426 );
3427 assert_eq!(
3428 have[3],
3429 SessionWorkspace {
3430 workspace_id: WorkspaceId(1),
3431 location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
3432 paths: PathList::default(),
3433 window_id: Some(WindowId::from(9u64)),
3434 }
3435 );
3436 }
3437
3438 #[gpui::test]
3439 async fn test_get_or_create_ssh_project() {
3440 let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project").await;
3441
3442 let host = "example.com".to_string();
3443 let port = Some(22_u16);
3444 let user = Some("user".to_string());
3445
3446 let connection_id = db
3447 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3448 host: host.clone().into(),
3449 port,
3450 username: user.clone(),
3451 ..Default::default()
3452 }))
3453 .await
3454 .unwrap();
3455
3456 // Test that calling the function again with the same parameters returns the same project
3457 let same_connection = db
3458 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3459 host: host.clone().into(),
3460 port,
3461 username: user.clone(),
3462 ..Default::default()
3463 }))
3464 .await
3465 .unwrap();
3466
3467 assert_eq!(connection_id, same_connection);
3468
3469 // Test with different parameters
3470 let host2 = "otherexample.com".to_string();
3471 let port2 = None;
3472 let user2 = Some("otheruser".to_string());
3473
3474 let different_connection = db
3475 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3476 host: host2.clone().into(),
3477 port: port2,
3478 username: user2.clone(),
3479 ..Default::default()
3480 }))
3481 .await
3482 .unwrap();
3483
3484 assert_ne!(connection_id, different_connection);
3485 }
3486
3487 #[gpui::test]
3488 async fn test_get_or_create_ssh_project_with_null_user() {
3489 let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project_with_null_user").await;
3490
3491 let (host, port, user) = ("example.com".to_string(), None, None);
3492
3493 let connection_id = db
3494 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3495 host: host.clone().into(),
3496 port,
3497 username: None,
3498 ..Default::default()
3499 }))
3500 .await
3501 .unwrap();
3502
3503 let same_connection_id = db
3504 .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
3505 host: host.clone().into(),
3506 port,
3507 username: user.clone(),
3508 ..Default::default()
3509 }))
3510 .await
3511 .unwrap();
3512
3513 assert_eq!(connection_id, same_connection_id);
3514 }
3515
3516 #[gpui::test]
3517 async fn test_get_remote_connections() {
3518 let db = WorkspaceDb::open_test_db("test_get_remote_connections").await;
3519
3520 let connections = [
3521 ("example.com".to_string(), None, None),
3522 (
3523 "anotherexample.com".to_string(),
3524 Some(123_u16),
3525 Some("user2".to_string()),
3526 ),
3527 ("yetanother.com".to_string(), Some(345_u16), None),
3528 ];
3529
3530 let mut ids = Vec::new();
3531 for (host, port, user) in connections.iter() {
3532 ids.push(
3533 db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
3534 SshConnectionOptions {
3535 host: host.clone().into(),
3536 port: *port,
3537 username: user.clone(),
3538 ..Default::default()
3539 },
3540 ))
3541 .await
3542 .unwrap(),
3543 );
3544 }
3545
3546 let stored_connections = db.remote_connections().unwrap();
3547 assert_eq!(
3548 stored_connections,
3549 [
3550 (
3551 ids[0],
3552 RemoteConnectionOptions::Ssh(SshConnectionOptions {
3553 host: "example.com".into(),
3554 port: None,
3555 username: None,
3556 ..Default::default()
3557 }),
3558 ),
3559 (
3560 ids[1],
3561 RemoteConnectionOptions::Ssh(SshConnectionOptions {
3562 host: "anotherexample.com".into(),
3563 port: Some(123),
3564 username: Some("user2".into()),
3565 ..Default::default()
3566 }),
3567 ),
3568 (
3569 ids[2],
3570 RemoteConnectionOptions::Ssh(SshConnectionOptions {
3571 host: "yetanother.com".into(),
3572 port: Some(345),
3573 username: None,
3574 ..Default::default()
3575 }),
3576 ),
3577 ]
3578 .into_iter()
3579 .collect::<HashMap<_, _>>(),
3580 );
3581 }
3582
3583 #[gpui::test]
3584 async fn test_simple_split() {
3585 zlog::init_test();
3586
3587 let db = WorkspaceDb::open_test_db("simple_split").await;
3588
3589 // -----------------
3590 // | 1,2 | 5,6 |
3591 // | - - - | |
3592 // | 3,4 | |
3593 // -----------------
3594 let center_pane = group(
3595 Axis::Horizontal,
3596 vec![
3597 group(
3598 Axis::Vertical,
3599 vec![
3600 SerializedPaneGroup::Pane(SerializedPane::new(
3601 vec![
3602 SerializedItem::new("Terminal", 1, false, false),
3603 SerializedItem::new("Terminal", 2, true, false),
3604 ],
3605 false,
3606 0,
3607 )),
3608 SerializedPaneGroup::Pane(SerializedPane::new(
3609 vec![
3610 SerializedItem::new("Terminal", 4, false, false),
3611 SerializedItem::new("Terminal", 3, true, false),
3612 ],
3613 true,
3614 0,
3615 )),
3616 ],
3617 ),
3618 SerializedPaneGroup::Pane(SerializedPane::new(
3619 vec![
3620 SerializedItem::new("Terminal", 5, true, false),
3621 SerializedItem::new("Terminal", 6, false, false),
3622 ],
3623 false,
3624 0,
3625 )),
3626 ],
3627 );
3628
3629 let workspace = default_workspace(&["/tmp"], ¢er_pane);
3630
3631 db.save_workspace(workspace.clone()).await;
3632
3633 let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
3634
3635 assert_eq!(workspace.center_group, new_workspace.center_group);
3636 }
3637
3638 #[gpui::test]
3639 async fn test_cleanup_panes() {
3640 zlog::init_test();
3641
3642 let db = WorkspaceDb::open_test_db("test_cleanup_panes").await;
3643
3644 let center_pane = group(
3645 Axis::Horizontal,
3646 vec![
3647 group(
3648 Axis::Vertical,
3649 vec![
3650 SerializedPaneGroup::Pane(SerializedPane::new(
3651 vec![
3652 SerializedItem::new("Terminal", 1, false, false),
3653 SerializedItem::new("Terminal", 2, true, false),
3654 ],
3655 false,
3656 0,
3657 )),
3658 SerializedPaneGroup::Pane(SerializedPane::new(
3659 vec![
3660 SerializedItem::new("Terminal", 4, false, false),
3661 SerializedItem::new("Terminal", 3, true, false),
3662 ],
3663 true,
3664 0,
3665 )),
3666 ],
3667 ),
3668 SerializedPaneGroup::Pane(SerializedPane::new(
3669 vec![
3670 SerializedItem::new("Terminal", 5, false, false),
3671 SerializedItem::new("Terminal", 6, true, false),
3672 ],
3673 false,
3674 0,
3675 )),
3676 ],
3677 );
3678
3679 let id = &["/tmp"];
3680
3681 let mut workspace = default_workspace(id, ¢er_pane);
3682
3683 db.save_workspace(workspace.clone()).await;
3684
3685 workspace.center_group = group(
3686 Axis::Vertical,
3687 vec![
3688 SerializedPaneGroup::Pane(SerializedPane::new(
3689 vec![
3690 SerializedItem::new("Terminal", 1, false, false),
3691 SerializedItem::new("Terminal", 2, true, false),
3692 ],
3693 false,
3694 0,
3695 )),
3696 SerializedPaneGroup::Pane(SerializedPane::new(
3697 vec![
3698 SerializedItem::new("Terminal", 4, true, false),
3699 SerializedItem::new("Terminal", 3, false, false),
3700 ],
3701 true,
3702 0,
3703 )),
3704 ],
3705 );
3706
3707 db.save_workspace(workspace.clone()).await;
3708
3709 let new_workspace = db.workspace_for_roots(id).unwrap();
3710
3711 assert_eq!(workspace.center_group, new_workspace.center_group);
3712 }
3713
3714 #[gpui::test]
3715 async fn test_empty_workspace_window_bounds() {
3716 zlog::init_test();
3717
3718 let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
3719 let id = db.next_id().await.unwrap();
3720
3721 // Create a workspace with empty paths (empty workspace)
3722 let empty_paths: &[&str] = &[];
3723 let display_uuid = Uuid::new_v4();
3724 let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
3725 origin: point(px(100.0), px(200.0)),
3726 size: size(px(800.0), px(600.0)),
3727 }));
3728
3729 let workspace = SerializedWorkspace {
3730 id,
3731 paths: PathList::new(empty_paths),
3732 location: SerializedWorkspaceLocation::Local,
3733 center_group: Default::default(),
3734 window_bounds: None,
3735 display: None,
3736 docks: Default::default(),
3737 breakpoints: Default::default(),
3738 centered_layout: false,
3739 session_id: None,
3740 window_id: None,
3741 user_toolchains: Default::default(),
3742 };
3743
3744 // Save the workspace (this creates the record with empty paths)
3745 db.save_workspace(workspace.clone()).await;
3746
3747 // Save window bounds separately (as the actual code does via set_window_open_status)
3748 db.set_window_open_status(id, window_bounds, display_uuid)
3749 .await
3750 .unwrap();
3751
3752 // Empty workspaces cannot be retrieved by paths (they'd all match).
3753 // They must be retrieved by workspace_id.
3754 assert!(db.workspace_for_roots(empty_paths).is_none());
3755
3756 // Retrieve using workspace_for_id instead
3757 let retrieved = db.workspace_for_id(id).unwrap();
3758
3759 // Verify window bounds were persisted
3760 assert_eq!(retrieved.id, id);
3761 assert!(retrieved.window_bounds.is_some());
3762 assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
3763 assert!(retrieved.display.is_some());
3764 assert_eq!(retrieved.display.unwrap(), display_uuid);
3765 }
3766
3767 #[gpui::test]
3768 async fn test_last_session_workspace_locations_groups_by_window_id(
3769 cx: &mut gpui::TestAppContext,
3770 ) {
3771 let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
3772 let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
3773 let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
3774 let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
3775 let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
3776
3777 let fs = fs::FakeFs::new(cx.executor());
3778 fs.insert_tree(dir1.path(), json!({})).await;
3779 fs.insert_tree(dir2.path(), json!({})).await;
3780 fs.insert_tree(dir3.path(), json!({})).await;
3781 fs.insert_tree(dir4.path(), json!({})).await;
3782 fs.insert_tree(dir5.path(), json!({})).await;
3783
3784 let db =
3785 WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
3786 .await;
3787
3788 // Simulate two MultiWorkspace windows each containing two workspaces,
3789 // plus one single-workspace window:
3790 // Window 10: workspace 1, workspace 2
3791 // Window 20: workspace 3, workspace 4
3792 // Window 30: workspace 5 (only one)
3793 //
3794 // On session restore, the caller should be able to group these by
3795 // window_id to reconstruct the MultiWorkspace windows.
3796 let workspaces_data: Vec<(i64, &Path, u64)> = vec![
3797 (1, dir1.path(), 10),
3798 (2, dir2.path(), 10),
3799 (3, dir3.path(), 20),
3800 (4, dir4.path(), 20),
3801 (5, dir5.path(), 30),
3802 ];
3803
3804 for (id, dir, window_id) in &workspaces_data {
3805 db.save_workspace(SerializedWorkspace {
3806 id: WorkspaceId(*id),
3807 paths: PathList::new(&[*dir]),
3808 location: SerializedWorkspaceLocation::Local,
3809 center_group: Default::default(),
3810 window_bounds: Default::default(),
3811 display: Default::default(),
3812 docks: Default::default(),
3813 centered_layout: false,
3814 session_id: Some("test-session".to_owned()),
3815 breakpoints: Default::default(),
3816 window_id: Some(*window_id),
3817 user_toolchains: Default::default(),
3818 })
3819 .await;
3820 }
3821
3822 let locations = db
3823 .last_session_workspace_locations("test-session", None, fs.as_ref())
3824 .await
3825 .unwrap();
3826
3827 // All 5 workspaces should be returned with their window_ids.
3828 assert_eq!(locations.len(), 5);
3829
3830 // Every entry should have a window_id so the caller can group them.
3831 for session_workspace in &locations {
3832 assert!(
3833 session_workspace.window_id.is_some(),
3834 "workspace {:?} missing window_id",
3835 session_workspace.workspace_id
3836 );
3837 }
3838
3839 // Group by window_id, simulating what the restoration code should do.
3840 let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
3841 for session_workspace in &locations {
3842 if let Some(window_id) = session_workspace.window_id {
3843 by_window
3844 .entry(window_id)
3845 .or_default()
3846 .push(session_workspace.workspace_id);
3847 }
3848 }
3849
3850 // Should produce 3 windows, not 5.
3851 assert_eq!(
3852 by_window.len(),
3853 3,
3854 "Expected 3 window groups, got {}: {:?}",
3855 by_window.len(),
3856 by_window
3857 );
3858
3859 // Window 10 should contain workspaces 1 and 2.
3860 let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
3861 assert_eq!(window_10.len(), 2);
3862 assert!(window_10.contains(&WorkspaceId(1)));
3863 assert!(window_10.contains(&WorkspaceId(2)));
3864
3865 // Window 20 should contain workspaces 3 and 4.
3866 let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
3867 assert_eq!(window_20.len(), 2);
3868 assert!(window_20.contains(&WorkspaceId(3)));
3869 assert!(window_20.contains(&WorkspaceId(4)));
3870
3871 // Window 30 should contain only workspace 5.
3872 let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
3873 assert_eq!(window_30.len(), 1);
3874 assert!(window_30.contains(&WorkspaceId(5)));
3875 }
3876
3877 #[gpui::test]
3878 async fn test_read_serialized_multi_workspaces_with_state() {
3879 use crate::persistence::model::MultiWorkspaceState;
3880
3881 // Write multi-workspace state for two windows via the scoped KVP.
3882 let window_10 = WindowId::from(10u64);
3883 let window_20 = WindowId::from(20u64);
3884
3885 write_multi_workspace_state(
3886 window_10,
3887 MultiWorkspaceState {
3888 active_workspace_id: Some(WorkspaceId(2)),
3889 },
3890 )
3891 .await;
3892
3893 write_multi_workspace_state(
3894 window_20,
3895 MultiWorkspaceState {
3896 active_workspace_id: Some(WorkspaceId(3)),
3897 },
3898 )
3899 .await;
3900
3901 // Build session workspaces: two in window 10, one in window 20, one with no window.
3902 let session_workspaces = vec![
3903 SessionWorkspace {
3904 workspace_id: WorkspaceId(1),
3905 location: SerializedWorkspaceLocation::Local,
3906 paths: PathList::new(&["/a"]),
3907 window_id: Some(window_10),
3908 },
3909 SessionWorkspace {
3910 workspace_id: WorkspaceId(2),
3911 location: SerializedWorkspaceLocation::Local,
3912 paths: PathList::new(&["/b"]),
3913 window_id: Some(window_10),
3914 },
3915 SessionWorkspace {
3916 workspace_id: WorkspaceId(3),
3917 location: SerializedWorkspaceLocation::Local,
3918 paths: PathList::new(&["/c"]),
3919 window_id: Some(window_20),
3920 },
3921 SessionWorkspace {
3922 workspace_id: WorkspaceId(4),
3923 location: SerializedWorkspaceLocation::Local,
3924 paths: PathList::new(&["/d"]),
3925 window_id: None,
3926 },
3927 ];
3928
3929 let results = read_serialized_multi_workspaces(session_workspaces);
3930
3931 // Should produce 3 groups: window 10, window 20, and the orphan.
3932 assert_eq!(results.len(), 3);
3933
3934 // Window 10 group: 2 workspaces, active_workspace_id = 2.
3935 let group_10 = &results[0];
3936 assert_eq!(group_10.workspaces.len(), 2);
3937 assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
3938
3939 // Window 20 group: 1 workspace, active_workspace_id = 3.
3940 let group_20 = &results[1];
3941 assert_eq!(group_20.workspaces.len(), 1);
3942 assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
3943
3944 // Orphan group: no window_id, so state is default.
3945 let group_none = &results[2];
3946 assert_eq!(group_none.workspaces.len(), 1);
3947 assert_eq!(group_none.state.active_workspace_id, None);
3948 }
3949
3950 #[gpui::test]
3951 async fn test_flush_serialization_completes_before_quit(cx: &mut gpui::TestAppContext) {
3952 use crate::multi_workspace::MultiWorkspace;
3953 use feature_flags::FeatureFlagAppExt;
3954
3955 use project::Project;
3956
3957 crate::tests::init_test(cx);
3958
3959 cx.update(|cx| {
3960 cx.set_staff(true);
3961 cx.update_flags(true, vec!["agent-v2".to_string()]);
3962 });
3963
3964 let fs = fs::FakeFs::new(cx.executor());
3965 let project = Project::test(fs.clone(), [], cx).await;
3966
3967 let (multi_workspace, cx) =
3968 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3969
3970 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3971
3972 // Assign a database_id so serialization will actually persist.
3973 let workspace_id = DB.next_id().await.unwrap();
3974 workspace.update(cx, |ws, _cx| {
3975 ws.set_database_id(workspace_id);
3976 });
3977
3978 // Mutate some workspace state.
3979 DB.set_centered_layout(workspace_id, true).await.unwrap();
3980
3981 // Call flush_serialization and await the returned task directly
3982 // (without run_until_parked — the point is that awaiting the task
3983 // alone is sufficient).
3984 let task = multi_workspace.update_in(cx, |mw, window, cx| {
3985 mw.workspace()
3986 .update(cx, |ws, cx| ws.flush_serialization(window, cx))
3987 });
3988 task.await;
3989
3990 // Read the workspace back from the DB and verify serialization happened.
3991 let serialized = DB.workspace_for_id(workspace_id);
3992 assert!(
3993 serialized.is_some(),
3994 "flush_serialization should have persisted the workspace to DB"
3995 );
3996 }
3997
3998 #[gpui::test]
3999 async fn test_create_workspace_serializes_active_workspace_id_after_db_id_assigned(
4000 cx: &mut gpui::TestAppContext,
4001 ) {
4002 use crate::multi_workspace::MultiWorkspace;
4003 use crate::persistence::read_multi_workspace_state;
4004 use feature_flags::FeatureFlagAppExt;
4005
4006 use project::Project;
4007
4008 crate::tests::init_test(cx);
4009
4010 cx.update(|cx| {
4011 cx.set_staff(true);
4012 cx.update_flags(true, vec!["agent-v2".to_string()]);
4013 });
4014
4015 let fs = fs::FakeFs::new(cx.executor());
4016 let project = Project::test(fs.clone(), [], cx).await;
4017
4018 let (multi_workspace, cx) =
4019 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4020
4021 // Give the first workspace a database_id.
4022 multi_workspace.update_in(cx, |mw, _, cx| {
4023 mw.set_random_database_id(cx);
4024 });
4025
4026 let window_id =
4027 multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
4028
4029 // Create a new workspace via the MultiWorkspace API (triggers next_id()).
4030 multi_workspace.update_in(cx, |mw, window, cx| {
4031 mw.create_workspace(window, cx);
4032 });
4033
4034 // Let the async next_id() and re-serialization tasks complete.
4035 cx.run_until_parked();
4036
4037 // Read back the multi-workspace state.
4038 let state = read_multi_workspace_state(window_id);
4039
4040 // The new workspace should now have a database_id, and the multi-workspace
4041 // state should record it as the active workspace.
4042 let new_workspace_db_id =
4043 multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4044 assert!(
4045 new_workspace_db_id.is_some(),
4046 "New workspace should have a database_id after run_until_parked"
4047 );
4048 assert_eq!(
4049 state.active_workspace_id, new_workspace_db_id,
4050 "Serialized active_workspace_id should match the new workspace's database_id"
4051 );
4052 }
4053
4054 #[gpui::test]
4055 async fn test_create_workspace_individual_serialization(cx: &mut gpui::TestAppContext) {
4056 use crate::multi_workspace::MultiWorkspace;
4057 use feature_flags::FeatureFlagAppExt;
4058
4059 use project::Project;
4060
4061 crate::tests::init_test(cx);
4062
4063 cx.update(|cx| {
4064 cx.set_staff(true);
4065 cx.update_flags(true, vec!["agent-v2".to_string()]);
4066 });
4067
4068 let fs = fs::FakeFs::new(cx.executor());
4069 let project = Project::test(fs.clone(), [], cx).await;
4070
4071 let (multi_workspace, cx) =
4072 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4073
4074 multi_workspace.update_in(cx, |mw, _, cx| {
4075 mw.set_random_database_id(cx);
4076 });
4077
4078 // Create a new workspace.
4079 multi_workspace.update_in(cx, |mw, window, cx| {
4080 mw.create_workspace(window, cx);
4081 });
4082
4083 cx.run_until_parked();
4084
4085 // Get the new workspace's database_id.
4086 let new_db_id =
4087 multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4088 assert!(
4089 new_db_id.is_some(),
4090 "New workspace should have a database_id"
4091 );
4092
4093 let workspace_id = new_db_id.unwrap();
4094
4095 // The workspace should have been serialized to the DB with real data
4096 // (not just the bare DEFAULT VALUES row from next_id).
4097 let serialized = DB.workspace_for_id(workspace_id);
4098 assert!(
4099 serialized.is_some(),
4100 "Newly created workspace should be fully serialized in the DB after database_id assignment"
4101 );
4102 }
4103
4104 #[gpui::test]
4105 async fn test_remove_workspace_deletes_db_row(cx: &mut gpui::TestAppContext) {
4106 use crate::multi_workspace::MultiWorkspace;
4107 use feature_flags::FeatureFlagAppExt;
4108 use gpui::AppContext as _;
4109 use project::Project;
4110
4111 crate::tests::init_test(cx);
4112
4113 cx.update(|cx| {
4114 cx.set_staff(true);
4115 cx.update_flags(true, vec!["agent-v2".to_string()]);
4116 });
4117
4118 let fs = fs::FakeFs::new(cx.executor());
4119 let project1 = Project::test(fs.clone(), [], cx).await;
4120 let project2 = Project::test(fs.clone(), [], cx).await;
4121
4122 let (multi_workspace, cx) =
4123 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4124
4125 multi_workspace.update_in(cx, |mw, _, cx| {
4126 mw.set_random_database_id(cx);
4127 });
4128
4129 // Get a real DB id for workspace2 so the row actually exists.
4130 let workspace2_db_id = DB.next_id().await.unwrap();
4131
4132 multi_workspace.update_in(cx, |mw, window, cx| {
4133 let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4134 workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4135 ws.set_database_id(workspace2_db_id)
4136 });
4137 mw.activate(workspace.clone(), cx);
4138 });
4139
4140 // Save a full workspace row to the DB directly.
4141 DB.save_workspace(SerializedWorkspace {
4142 id: workspace2_db_id,
4143 paths: PathList::new(&["/tmp/remove_test"]),
4144 location: SerializedWorkspaceLocation::Local,
4145 center_group: Default::default(),
4146 window_bounds: Default::default(),
4147 display: Default::default(),
4148 docks: Default::default(),
4149 centered_layout: false,
4150 session_id: Some("remove-test-session".to_owned()),
4151 breakpoints: Default::default(),
4152 window_id: Some(99),
4153 user_toolchains: Default::default(),
4154 })
4155 .await;
4156
4157 assert!(
4158 DB.workspace_for_id(workspace2_db_id).is_some(),
4159 "Workspace2 should exist in DB before removal"
4160 );
4161
4162 // Remove workspace at index 1 (the second workspace).
4163 multi_workspace.update_in(cx, |mw, window, cx| {
4164 mw.remove_workspace(1, window, cx);
4165 });
4166
4167 cx.run_until_parked();
4168
4169 // The row should be deleted, not just have session_id cleared.
4170 assert!(
4171 DB.workspace_for_id(workspace2_db_id).is_none(),
4172 "Removed workspace's DB row should be deleted entirely"
4173 );
4174 }
4175
4176 #[gpui::test]
4177 async fn test_remove_workspace_not_restored_as_zombie(cx: &mut gpui::TestAppContext) {
4178 use crate::multi_workspace::MultiWorkspace;
4179 use feature_flags::FeatureFlagAppExt;
4180 use gpui::AppContext as _;
4181 use project::Project;
4182
4183 crate::tests::init_test(cx);
4184
4185 cx.update(|cx| {
4186 cx.set_staff(true);
4187 cx.update_flags(true, vec!["agent-v2".to_string()]);
4188 });
4189
4190 let fs = fs::FakeFs::new(cx.executor());
4191 let dir1 = tempfile::TempDir::with_prefix("zombie_test1").unwrap();
4192 let dir2 = tempfile::TempDir::with_prefix("zombie_test2").unwrap();
4193 fs.insert_tree(dir1.path(), json!({})).await;
4194 fs.insert_tree(dir2.path(), json!({})).await;
4195
4196 let project1 = Project::test(fs.clone(), [], cx).await;
4197 let project2 = Project::test(fs.clone(), [], cx).await;
4198
4199 // Get real DB ids so the rows actually exist.
4200 let ws1_id = DB.next_id().await.unwrap();
4201 let ws2_id = DB.next_id().await.unwrap();
4202
4203 let (multi_workspace, cx) =
4204 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4205
4206 multi_workspace.update_in(cx, |mw, _, cx| {
4207 mw.workspace().update(cx, |ws, _cx| {
4208 ws.set_database_id(ws1_id);
4209 });
4210 });
4211
4212 multi_workspace.update_in(cx, |mw, window, cx| {
4213 let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
4214 workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
4215 ws.set_database_id(ws2_id)
4216 });
4217 mw.activate(workspace.clone(), cx);
4218 });
4219
4220 let session_id = "test-zombie-session";
4221 let window_id_val: u64 = 42;
4222
4223 DB.save_workspace(SerializedWorkspace {
4224 id: ws1_id,
4225 paths: PathList::new(&[dir1.path()]),
4226 location: SerializedWorkspaceLocation::Local,
4227 center_group: Default::default(),
4228 window_bounds: Default::default(),
4229 display: Default::default(),
4230 docks: Default::default(),
4231 centered_layout: false,
4232 session_id: Some(session_id.to_owned()),
4233 breakpoints: Default::default(),
4234 window_id: Some(window_id_val),
4235 user_toolchains: Default::default(),
4236 })
4237 .await;
4238
4239 DB.save_workspace(SerializedWorkspace {
4240 id: ws2_id,
4241 paths: PathList::new(&[dir2.path()]),
4242 location: SerializedWorkspaceLocation::Local,
4243 center_group: Default::default(),
4244 window_bounds: Default::default(),
4245 display: Default::default(),
4246 docks: Default::default(),
4247 centered_layout: false,
4248 session_id: Some(session_id.to_owned()),
4249 breakpoints: Default::default(),
4250 window_id: Some(window_id_val),
4251 user_toolchains: Default::default(),
4252 })
4253 .await;
4254
4255 // Remove workspace2 (index 1).
4256 multi_workspace.update_in(cx, |mw, window, cx| {
4257 mw.remove_workspace(1, window, cx);
4258 });
4259
4260 cx.run_until_parked();
4261
4262 // The removed workspace should NOT appear in session restoration.
4263 let locations = DB
4264 .last_session_workspace_locations(session_id, None, fs.as_ref())
4265 .await
4266 .unwrap();
4267
4268 let restored_ids: Vec<WorkspaceId> = locations.iter().map(|sw| sw.workspace_id).collect();
4269 assert!(
4270 !restored_ids.contains(&ws2_id),
4271 "Removed workspace should not appear in session restoration list. Found: {:?}",
4272 restored_ids
4273 );
4274 assert!(
4275 restored_ids.contains(&ws1_id),
4276 "Remaining workspace should still appear in session restoration list"
4277 );
4278 }
4279
4280 #[gpui::test]
4281 async fn test_pending_removal_tasks_drained_on_flush(cx: &mut gpui::TestAppContext) {
4282 use crate::multi_workspace::MultiWorkspace;
4283 use feature_flags::FeatureFlagAppExt;
4284 use gpui::AppContext as _;
4285 use project::Project;
4286
4287 crate::tests::init_test(cx);
4288
4289 cx.update(|cx| {
4290 cx.set_staff(true);
4291 cx.update_flags(true, vec!["agent-v2".to_string()]);
4292 });
4293
4294 let fs = fs::FakeFs::new(cx.executor());
4295 let project1 = Project::test(fs.clone(), [], cx).await;
4296 let project2 = Project::test(fs.clone(), [], cx).await;
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 DB.save_workspace(SerializedWorkspace {
4318 id: workspace2_db_id,
4319 paths: PathList::new(&["/tmp/pending_removal_test"]),
4320 location: SerializedWorkspaceLocation::Local,
4321 center_group: Default::default(),
4322 window_bounds: Default::default(),
4323 display: Default::default(),
4324 docks: Default::default(),
4325 centered_layout: false,
4326 session_id: Some("pending-removal-session".to_owned()),
4327 breakpoints: Default::default(),
4328 window_id: Some(88),
4329 user_toolchains: Default::default(),
4330 })
4331 .await;
4332 cx.run_until_parked();
4333
4334 // Remove workspace2 — this pushes a task to pending_removal_tasks.
4335 multi_workspace.update_in(cx, |mw, window, cx| {
4336 mw.remove_workspace(1, window, cx);
4337 });
4338
4339 // Simulate the quit handler pattern: collect flush tasks + pending
4340 // removal tasks and await them all.
4341 let all_tasks = multi_workspace.update_in(cx, |mw, window, cx| {
4342 let mut tasks: Vec<Task<()>> = mw
4343 .workspaces()
4344 .iter()
4345 .map(|workspace| {
4346 workspace.update(cx, |workspace, cx| {
4347 workspace.flush_serialization(window, cx)
4348 })
4349 })
4350 .collect();
4351 let mut removal_tasks = mw.take_pending_removal_tasks();
4352 // Note: removal_tasks may be empty if the background task already
4353 // completed (take_pending_removal_tasks filters out ready tasks).
4354 tasks.append(&mut removal_tasks);
4355 tasks.push(mw.flush_serialization());
4356 tasks
4357 });
4358 futures::future::join_all(all_tasks).await;
4359
4360 // After awaiting, the DB row should be deleted.
4361 assert!(
4362 DB.workspace_for_id(workspace2_db_id).is_none(),
4363 "Pending removal task should have deleted the workspace row when awaited"
4364 );
4365 }
4366
4367 #[gpui::test]
4368 async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) {
4369 use crate::multi_workspace::MultiWorkspace;
4370 use feature_flags::FeatureFlagAppExt;
4371 use project::Project;
4372
4373 crate::tests::init_test(cx);
4374
4375 cx.update(|cx| {
4376 cx.set_staff(true);
4377 cx.update_flags(true, vec!["agent-v2".to_string()]);
4378 });
4379
4380 let fs = fs::FakeFs::new(cx.executor());
4381 let project = Project::test(fs.clone(), [], cx).await;
4382
4383 let (multi_workspace, cx) =
4384 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4385
4386 multi_workspace.update_in(cx, |mw, _, cx| {
4387 mw.set_random_database_id(cx);
4388 });
4389
4390 multi_workspace.update_in(cx, |mw, window, cx| {
4391 mw.create_workspace(window, cx);
4392 });
4393
4394 cx.run_until_parked();
4395
4396 let new_workspace_db_id =
4397 multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
4398 assert!(
4399 new_workspace_db_id.is_some(),
4400 "After run_until_parked, the workspace should have a database_id"
4401 );
4402
4403 let workspace_id = new_workspace_db_id.unwrap();
4404
4405 assert!(
4406 DB.workspace_for_id(workspace_id).is_some(),
4407 "The workspace row should exist in the DB"
4408 );
4409
4410 cx.simulate_resize(gpui::size(px(1024.0), px(768.0)));
4411
4412 // Advance the clock past the 100ms debounce timer so the bounds
4413 // observer task fires
4414 cx.executor().advance_clock(Duration::from_millis(200));
4415 cx.run_until_parked();
4416
4417 let serialized = DB
4418 .workspace_for_id(workspace_id)
4419 .expect("workspace row should still exist");
4420 assert!(
4421 serialized.window_bounds.is_some(),
4422 "The bounds observer should write bounds for the workspace's real DB ID, \
4423 even when the workspace was created via create_workspace (where the ID \
4424 is assigned asynchronously after construction)."
4425 );
4426 }
4427
4428 #[gpui::test]
4429 async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) {
4430 use crate::multi_workspace::MultiWorkspace;
4431 use feature_flags::FeatureFlagAppExt;
4432 use project::Project;
4433
4434 crate::tests::init_test(cx);
4435
4436 cx.update(|cx| {
4437 cx.set_staff(true);
4438 cx.update_flags(true, vec!["agent-v2".to_string()]);
4439 });
4440
4441 let fs = fs::FakeFs::new(cx.executor());
4442 let dir = tempfile::TempDir::with_prefix("flush_bounds_test").unwrap();
4443 fs.insert_tree(dir.path(), json!({})).await;
4444
4445 let project = Project::test(fs.clone(), [dir.path()], cx).await;
4446
4447 let (multi_workspace, cx) =
4448 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4449
4450 let workspace_id = DB.next_id().await.unwrap();
4451 multi_workspace.update_in(cx, |mw, _, cx| {
4452 mw.workspace().update(cx, |ws, _cx| {
4453 ws.set_database_id(workspace_id);
4454 });
4455 });
4456
4457 let task = multi_workspace.update_in(cx, |mw, window, cx| {
4458 mw.workspace()
4459 .update(cx, |ws, cx| ws.flush_serialization(window, cx))
4460 });
4461 task.await;
4462
4463 let after = DB
4464 .workspace_for_id(workspace_id)
4465 .expect("workspace row should exist after flush_serialization");
4466 assert!(
4467 !after.paths.is_empty(),
4468 "flush_serialization should have written paths via save_workspace"
4469 );
4470 assert!(
4471 after.window_bounds.is_some(),
4472 "flush_serialization should ensure window bounds are persisted to the DB \
4473 before the process exits."
4474 );
4475 }
4476}