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