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