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