Files
oak-gpui/crates/workspace/src/persistence.rs
T
John Tur f348240a8c Don't probe for local workspaces pointing to WSL filesystem on startup (#40142)
We automatically delete a local workspace if the folders comprising it
no longer exist.
If a local workspace points to folders in the WSL filesystem, checking
whether those folders exist will make us wait for the WSL VM and file
server to boot up. This can block Zed startup for many seconds.

Supported scenarios use remote workspaces, so delete these local
workspaces to ensure that we don't try to access their folders on the
startup path.

Release Notes:

- N/A
2025-10-14 14:24:03 -04:00

3048 lines
108 KiB
Rust

pub mod model;
use std::{
borrow::Cow,
collections::BTreeMap,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use anyhow::{Context as _, Result, bail};
use collections::{HashMap, IndexSet};
use db::{
query,
sqlez::{connection::Connection, domain::Domain},
sqlez_macros::sql,
};
use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
use language::{LanguageName, Toolchain, ToolchainScope};
use project::WorktreeId;
use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions};
use sqlez::{
bindable::{Bind, Column, StaticColumnCount},
statement::Statement,
thread_safe_connection::ThreadSafeConnection,
};
use ui::{App, SharedString, px};
use util::{ResultExt, maybe, rel_path::RelPath};
use uuid::Uuid;
use crate::{
WorkspaceId,
path_list::{PathList, SerializedPathList},
persistence::model::RemoteConnectionKind,
};
use model::{
GroupId, ItemId, PaneId, RemoteConnectionId, SerializedItem, SerializedPane,
SerializedPaneGroup, SerializedWorkspace,
};
use self::model::{DockStructure, SerializedWorkspaceLocation};
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
impl sqlez::bindable::Bind for SerializedAxis {
fn bind(
&self,
statement: &sqlez::statement::Statement,
start_index: i32,
) -> anyhow::Result<i32> {
match self.0 {
gpui::Axis::Horizontal => "Horizontal",
gpui::Axis::Vertical => "Vertical",
}
.bind(statement, start_index)
}
}
impl sqlez::bindable::Column for SerializedAxis {
fn column(
statement: &mut sqlez::statement::Statement,
start_index: i32,
) -> anyhow::Result<(Self, i32)> {
String::column(statement, start_index).and_then(|(axis_text, next_index)| {
Ok((
match axis_text.as_str() {
"Horizontal" => Self(Axis::Horizontal),
"Vertical" => Self(Axis::Vertical),
_ => anyhow::bail!("Stored serialized item kind is incorrect"),
},
next_index,
))
})
}
}
#[derive(Copy, Clone, Debug, PartialEq, Default)]
pub(crate) struct SerializedWindowBounds(pub(crate) WindowBounds);
impl StaticColumnCount for SerializedWindowBounds {
fn column_count() -> usize {
5
}
}
impl Bind for SerializedWindowBounds {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
match self.0 {
WindowBounds::Windowed(bounds) => {
let next_index = statement.bind(&"Windowed", start_index)?;
statement.bind(
&(
SerializedPixels(bounds.origin.x),
SerializedPixels(bounds.origin.y),
SerializedPixels(bounds.size.width),
SerializedPixels(bounds.size.height),
),
next_index,
)
}
WindowBounds::Maximized(bounds) => {
let next_index = statement.bind(&"Maximized", start_index)?;
statement.bind(
&(
SerializedPixels(bounds.origin.x),
SerializedPixels(bounds.origin.y),
SerializedPixels(bounds.size.width),
SerializedPixels(bounds.size.height),
),
next_index,
)
}
WindowBounds::Fullscreen(bounds) => {
let next_index = statement.bind(&"FullScreen", start_index)?;
statement.bind(
&(
SerializedPixels(bounds.origin.x),
SerializedPixels(bounds.origin.y),
SerializedPixels(bounds.size.width),
SerializedPixels(bounds.size.height),
),
next_index,
)
}
}
}
}
impl Column for SerializedWindowBounds {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (window_state, next_index) = String::column(statement, start_index)?;
let ((x, y, width, height), _): ((i32, i32, i32, i32), _) =
Column::column(statement, next_index)?;
let bounds = Bounds {
origin: point(px(x as f32), px(y as f32)),
size: size(px(width as f32), px(height as f32)),
};
let status = match window_state.as_str() {
"Windowed" | "Fixed" => SerializedWindowBounds(WindowBounds::Windowed(bounds)),
"Maximized" => SerializedWindowBounds(WindowBounds::Maximized(bounds)),
"FullScreen" => SerializedWindowBounds(WindowBounds::Fullscreen(bounds)),
_ => bail!("Window State did not have a valid string"),
};
Ok((status, next_index + 4))
}
}
#[derive(Debug)]
pub struct Breakpoint {
pub position: u32,
pub message: Option<Arc<str>>,
pub condition: Option<Arc<str>>,
pub hit_condition: Option<Arc<str>>,
pub state: BreakpointState,
}
/// Wrapper for DB type of a breakpoint
struct BreakpointStateWrapper<'a>(Cow<'a, BreakpointState>);
impl From<BreakpointState> for BreakpointStateWrapper<'static> {
fn from(kind: BreakpointState) -> Self {
BreakpointStateWrapper(Cow::Owned(kind))
}
}
impl StaticColumnCount for BreakpointStateWrapper<'_> {
fn column_count() -> usize {
1
}
}
impl Bind for BreakpointStateWrapper<'_> {
fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
statement.bind(&self.0.to_int(), start_index)
}
}
impl Column for BreakpointStateWrapper<'_> {
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
let state = statement.column_int(start_index)?;
match state {
0 => Ok((BreakpointState::Enabled.into(), start_index + 1)),
1 => Ok((BreakpointState::Disabled.into(), start_index + 1)),
_ => anyhow::bail!("Invalid BreakpointState discriminant {state}"),
}
}
}
impl sqlez::bindable::StaticColumnCount for Breakpoint {
fn column_count() -> usize {
// Position, log message, condition message, and hit condition message
4 + BreakpointStateWrapper::column_count()
}
}
impl sqlez::bindable::Bind for Breakpoint {
fn bind(
&self,
statement: &sqlez::statement::Statement,
start_index: i32,
) -> anyhow::Result<i32> {
let next_index = statement.bind(&self.position, start_index)?;
let next_index = statement.bind(&self.message, next_index)?;
let next_index = statement.bind(&self.condition, next_index)?;
let next_index = statement.bind(&self.hit_condition, next_index)?;
statement.bind(
&BreakpointStateWrapper(Cow::Borrowed(&self.state)),
next_index,
)
}
}
impl Column for Breakpoint {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let position = statement
.column_int(start_index)
.with_context(|| format!("Failed to read BreakPoint at index {start_index}"))?
as u32;
let (message, next_index) = Option::<String>::column(statement, start_index + 1)?;
let (condition, next_index) = Option::<String>::column(statement, next_index)?;
let (hit_condition, next_index) = Option::<String>::column(statement, next_index)?;
let (state, next_index) = BreakpointStateWrapper::column(statement, next_index)?;
Ok((
Breakpoint {
position,
message: message.map(Arc::from),
condition: condition.map(Arc::from),
hit_condition: hit_condition.map(Arc::from),
state: state.0.into_owned(),
},
next_index,
))
}
}
#[derive(Clone, Debug, PartialEq)]
struct SerializedPixels(gpui::Pixels);
impl sqlez::bindable::StaticColumnCount for SerializedPixels {}
impl sqlez::bindable::Bind for SerializedPixels {
fn bind(
&self,
statement: &sqlez::statement::Statement,
start_index: i32,
) -> anyhow::Result<i32> {
let this: i32 = u32::from(self.0) as _;
this.bind(statement, start_index)
}
}
pub struct WorkspaceDb(ThreadSafeConnection);
impl Domain for WorkspaceDb {
const NAME: &str = stringify!(WorkspaceDb);
const MIGRATIONS: &[&str] = &[
sql!(
CREATE TABLE workspaces(
workspace_id INTEGER PRIMARY KEY,
workspace_location BLOB UNIQUE,
dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
left_sidebar_open INTEGER, // Boolean
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
) STRICT;
CREATE TABLE pane_groups(
group_id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL,
parent_group_id INTEGER, // NULL indicates that this is a root node
position INTEGER, // NULL indicates that this is a root node
axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE panes(
pane_id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL,
active INTEGER NOT NULL, // Boolean
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE
) STRICT;
CREATE TABLE center_panes(
pane_id INTEGER PRIMARY KEY,
parent_group_id INTEGER, // NULL means that this is a root pane
position INTEGER, // NULL means that this is a root pane
FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
ON DELETE CASCADE,
FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE items(
item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
workspace_id INTEGER NOT NULL,
pane_id INTEGER NOT NULL,
kind TEXT NOT NULL,
position INTEGER NOT NULL,
active INTEGER NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
ON DELETE CASCADE,
PRIMARY KEY(item_id, workspace_id)
) STRICT;
),
sql!(
ALTER TABLE workspaces ADD COLUMN window_state TEXT;
ALTER TABLE workspaces ADD COLUMN window_x REAL;
ALTER TABLE workspaces ADD COLUMN window_y REAL;
ALTER TABLE workspaces ADD COLUMN window_width REAL;
ALTER TABLE workspaces ADD COLUMN window_height REAL;
ALTER TABLE workspaces ADD COLUMN display BLOB;
),
// Drop foreign key constraint from workspaces.dock_pane to panes table.
sql!(
CREATE TABLE workspaces_2(
workspace_id INTEGER PRIMARY KEY,
workspace_location BLOB UNIQUE,
dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
left_sidebar_open INTEGER, // Boolean
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
window_state TEXT,
window_x REAL,
window_y REAL,
window_width REAL,
window_height REAL,
display BLOB
) STRICT;
INSERT INTO workspaces_2 SELECT * FROM workspaces;
DROP TABLE workspaces;
ALTER TABLE workspaces_2 RENAME TO workspaces;
),
// Add panels related information
sql!(
ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
),
// Add panel zoom persistence
sql!(
ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
),
// Add pane group flex data
sql!(
ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
),
// Add fullscreen field to workspace
// Deprecated, `WindowBounds` holds the fullscreen state now.
// Preserving so users can downgrade Zed.
sql!(
ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
),
// Add preview field to items
sql!(
ALTER TABLE items ADD COLUMN preview INTEGER; //bool
),
// Add centered_layout field to workspace
sql!(
ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
),
sql!(
CREATE TABLE remote_projects (
remote_project_id INTEGER NOT NULL UNIQUE,
path TEXT,
dev_server_name TEXT
);
ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
),
sql!(
DROP TABLE remote_projects;
CREATE TABLE dev_server_projects (
id INTEGER NOT NULL UNIQUE,
path TEXT,
dev_server_name TEXT
);
ALTER TABLE workspaces DROP COLUMN remote_project_id;
ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
),
sql!(
ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
),
sql!(
ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
),
sql!(
ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL;
),
sql!(
ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
),
sql!(
CREATE TABLE ssh_projects (
id INTEGER PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER,
path TEXT NOT NULL,
user TEXT
);
ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
),
sql!(
ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
),
sql!(
CREATE TABLE toolchains (
workspace_id INTEGER,
worktree_id INTEGER,
language_name TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (workspace_id, worktree_id, language_name)
);
),
sql!(
ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
),
sql!(
CREATE TABLE breakpoints (
workspace_id INTEGER NOT NULL,
path TEXT NOT NULL,
breakpoint_location INTEGER NOT NULL,
kind INTEGER NOT NULL,
log_message TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
),
sql!(
ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT;
CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array);
ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT;
),
sql!(
ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL
),
sql!(
ALTER TABLE breakpoints DROP COLUMN kind
),
sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL),
sql!(
ALTER TABLE breakpoints ADD COLUMN condition TEXT;
ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT;
),
sql!(CREATE TABLE toolchains2 (
workspace_id INTEGER,
worktree_id INTEGER,
language_name TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
raw_json TEXT NOT NULL,
relative_worktree_path TEXT NOT NULL,
PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT;
INSERT INTO toolchains2
SELECT * FROM toolchains;
DROP TABLE toolchains;
ALTER TABLE toolchains2 RENAME TO toolchains;
),
sql!(
CREATE TABLE ssh_connections (
id INTEGER PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER,
user TEXT
);
INSERT INTO ssh_connections (host, port, user)
SELECT DISTINCT host, port, user
FROM ssh_projects;
CREATE TABLE workspaces_2(
workspace_id INTEGER PRIMARY KEY,
paths TEXT,
paths_order TEXT,
ssh_connection_id INTEGER REFERENCES ssh_connections(id),
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
window_state TEXT,
window_x REAL,
window_y REAL,
window_width REAL,
window_height REAL,
display BLOB,
left_dock_visible INTEGER,
left_dock_active_panel TEXT,
right_dock_visible INTEGER,
right_dock_active_panel TEXT,
bottom_dock_visible INTEGER,
bottom_dock_active_panel TEXT,
left_dock_zoom INTEGER,
right_dock_zoom INTEGER,
bottom_dock_zoom INTEGER,
fullscreen INTEGER,
centered_layout INTEGER,
session_id TEXT,
window_id INTEGER
) STRICT;
INSERT
INTO workspaces_2
SELECT
workspaces.workspace_id,
CASE
WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
ELSE
CASE
WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
NULL
ELSE
replace(workspaces.local_paths_array, ',', CHAR(10))
END
END as paths,
CASE
WHEN ssh_projects.id IS NOT NULL THEN ""
ELSE workspaces.local_paths_order_array
END as paths_order,
CASE
WHEN ssh_projects.id IS NOT NULL THEN (
SELECT ssh_connections.id
FROM ssh_connections
WHERE
ssh_connections.host IS ssh_projects.host AND
ssh_connections.port IS ssh_projects.port AND
ssh_connections.user IS ssh_projects.user
)
ELSE NULL
END as ssh_connection_id,
workspaces.timestamp,
workspaces.window_state,
workspaces.window_x,
workspaces.window_y,
workspaces.window_width,
workspaces.window_height,
workspaces.display,
workspaces.left_dock_visible,
workspaces.left_dock_active_panel,
workspaces.right_dock_visible,
workspaces.right_dock_active_panel,
workspaces.bottom_dock_visible,
workspaces.bottom_dock_active_panel,
workspaces.left_dock_zoom,
workspaces.right_dock_zoom,
workspaces.bottom_dock_zoom,
workspaces.fullscreen,
workspaces.centered_layout,
workspaces.session_id,
workspaces.window_id
FROM
workspaces LEFT JOIN
ssh_projects ON
workspaces.ssh_project_id = ssh_projects.id;
DELETE FROM workspaces_2
WHERE workspace_id NOT IN (
SELECT MAX(workspace_id)
FROM workspaces_2
GROUP BY ssh_connection_id, paths
);
DROP TABLE ssh_projects;
DROP TABLE workspaces;
ALTER TABLE workspaces_2 RENAME TO workspaces;
CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
),
// Fix any data from when workspaces.paths were briefly encoded as JSON arrays
sql!(
UPDATE workspaces
SET paths = CASE
WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN
replace(
substr(paths, 3, length(paths) - 4),
'"' || ',' || '"',
CHAR(10)
)
ELSE
replace(paths, ',', CHAR(10))
END
WHERE paths IS NOT NULL
),
sql!(
CREATE TABLE remote_connections(
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
host TEXT,
port INTEGER,
user TEXT,
distro TEXT
);
CREATE TABLE workspaces_2(
workspace_id INTEGER PRIMARY KEY,
paths TEXT,
paths_order TEXT,
remote_connection_id INTEGER REFERENCES remote_connections(id),
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
window_state TEXT,
window_x REAL,
window_y REAL,
window_width REAL,
window_height REAL,
display BLOB,
left_dock_visible INTEGER,
left_dock_active_panel TEXT,
right_dock_visible INTEGER,
right_dock_active_panel TEXT,
bottom_dock_visible INTEGER,
bottom_dock_active_panel TEXT,
left_dock_zoom INTEGER,
right_dock_zoom INTEGER,
bottom_dock_zoom INTEGER,
fullscreen INTEGER,
centered_layout INTEGER,
session_id TEXT,
window_id INTEGER
) STRICT;
INSERT INTO remote_connections
SELECT
id,
"ssh" as kind,
host,
port,
user,
NULL as distro
FROM ssh_connections;
INSERT
INTO workspaces_2
SELECT
workspace_id,
paths,
paths_order,
ssh_connection_id as remote_connection_id,
timestamp,
window_state,
window_x,
window_y,
window_width,
window_height,
display,
left_dock_visible,
left_dock_active_panel,
right_dock_visible,
right_dock_active_panel,
bottom_dock_visible,
bottom_dock_active_panel,
left_dock_zoom,
right_dock_zoom,
bottom_dock_zoom,
fullscreen,
centered_layout,
session_id,
window_id
FROM
workspaces;
DROP TABLE workspaces;
ALTER TABLE workspaces_2 RENAME TO workspaces;
CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(remote_connection_id, paths);
),
sql!(CREATE TABLE user_toolchains (
remote_connection_id INTEGER,
workspace_id INTEGER NOT NULL,
worktree_id INTEGER NOT NULL,
relative_worktree_path TEXT NOT NULL,
language_name TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
raw_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json)
) STRICT;),
sql!(
DROP TABLE ssh_connections;
),
];
// Allow recovering from bad migration that was initially shipped to nightly
// when introducing the ssh_connections table.
fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool {
old.starts_with("CREATE TABLE ssh_connections")
&& new.starts_with("CREATE TABLE ssh_connections")
}
}
db::static_connection!(DB, WorkspaceDb, []);
impl WorkspaceDb {
/// Returns a serialized workspace for the given worktree_roots. If the passed array
/// is empty, the most recent workspace is returned instead. If no workspace for the
/// passed roots is stored, returns none.
pub(crate) fn workspace_for_roots<P: AsRef<Path>>(
&self,
worktree_roots: &[P],
) -> Option<SerializedWorkspace> {
self.workspace_for_roots_internal(worktree_roots, None)
}
pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
&self,
worktree_roots: &[P],
ssh_project_id: RemoteConnectionId,
) -> Option<SerializedWorkspace> {
self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id))
}
pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
&self,
worktree_roots: &[P],
remote_connection_id: Option<RemoteConnectionId>,
) -> Option<SerializedWorkspace> {
// paths are sorted before db interactions to ensure that the order of the paths
// doesn't affect the workspace selection for existing workspaces
let root_paths = PathList::new(worktree_roots);
// Note that we re-assign the workspace_id here in case it's empty
// and we've grabbed the most recent workspace
let (
workspace_id,
paths,
paths_order,
window_bounds,
display,
centered_layout,
docks,
window_id,
): (
WorkspaceId,
String,
String,
Option<SerializedWindowBounds>,
Option<Uuid>,
Option<bool>,
DockStructure,
Option<u64>,
) = self
.select_row_bound(sql! {
SELECT
workspace_id,
paths,
paths_order,
window_state,
window_x,
window_y,
window_width,
window_height,
display,
centered_layout,
left_dock_visible,
left_dock_active_panel,
left_dock_zoom,
right_dock_visible,
right_dock_active_panel,
right_dock_zoom,
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
window_id
FROM workspaces
WHERE
paths IS ? AND
remote_connection_id IS ?
LIMIT 1
})
.map(|mut prepared_statement| {
(prepared_statement)((
root_paths.serialize().paths,
remote_connection_id.map(|id| id.0 as i32),
))
.unwrap()
})
.context("No workspaces found")
.warn_on_err()
.flatten()?;
let paths = PathList::deserialize(&SerializedPathList {
paths,
order: paths_order,
});
Some(SerializedWorkspace {
id: workspace_id,
location: SerializedWorkspaceLocation::Local,
paths,
center_group: self
.get_center_pane_group(workspace_id)
.context("Getting center group")
.log_err()?,
window_bounds,
centered_layout: centered_layout.unwrap_or(false),
display,
docks,
session_id: None,
breakpoints: self.breakpoints(workspace_id),
window_id,
user_toolchains: self.user_toolchains(workspace_id, remote_connection_id),
})
}
fn breakpoints(&self, workspace_id: WorkspaceId) -> BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> {
let breakpoints: Result<Vec<(PathBuf, Breakpoint)>> = self
.select_bound(sql! {
SELECT path, breakpoint_location, log_message, condition, hit_condition, state
FROM breakpoints
WHERE workspace_id = ?
})
.and_then(|mut prepared_statement| (prepared_statement)(workspace_id));
match breakpoints {
Ok(bp) => {
if bp.is_empty() {
log::debug!("Breakpoints are empty after querying database for them");
}
let mut map: BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> = Default::default();
for (path, breakpoint) in bp {
let path: Arc<Path> = path.into();
map.entry(path.clone()).or_default().push(SourceBreakpoint {
row: breakpoint.position,
path,
message: breakpoint.message,
condition: breakpoint.condition,
hit_condition: breakpoint.hit_condition,
state: breakpoint.state,
});
}
for (path, bps) in map.iter() {
log::info!(
"Got {} breakpoints from database at path: {}",
bps.len(),
path.to_string_lossy()
);
}
map
}
Err(msg) => {
log::error!("Breakpoints query failed with msg: {msg}");
Default::default()
}
}
}
fn user_toolchains(
&self,
workspace_id: WorkspaceId,
remote_connection_id: Option<RemoteConnectionId>,
) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
type RowKind = (WorkspaceId, u64, String, String, String, String, String);
let toolchains: Vec<RowKind> = self
.select_bound(sql! {
SELECT workspace_id, worktree_id, relative_worktree_path,
language_name, name, path, raw_json
FROM user_toolchains WHERE remote_connection_id IS ?1 AND (
workspace_id IN (0, ?2)
)
})
.and_then(|mut statement| {
(statement)((remote_connection_id.map(|id| id.0), workspace_id))
})
.unwrap_or_default();
let mut ret = BTreeMap::<_, IndexSet<_>>::default();
for (
_workspace_id,
worktree_id,
relative_worktree_path,
language_name,
name,
path,
raw_json,
) in toolchains
{
// INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to
let scope = if _workspace_id == WorkspaceId(0) {
debug_assert_eq!(worktree_id, u64::MAX);
debug_assert_eq!(relative_worktree_path, String::default());
ToolchainScope::Global
} else {
debug_assert_eq!(workspace_id, _workspace_id);
debug_assert_eq!(
worktree_id == u64::MAX,
relative_worktree_path == String::default()
);
let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else {
continue;
};
if worktree_id != u64::MAX && relative_worktree_path != String::default() {
ToolchainScope::Subproject(
WorktreeId::from_usize(worktree_id as usize),
relative_path.into(),
)
} else {
ToolchainScope::Project
}
};
let Ok(as_json) = serde_json::from_str(&raw_json) else {
continue;
};
let toolchain = Toolchain {
name: SharedString::from(name),
path: SharedString::from(path),
language_name: LanguageName::from_proto(language_name),
as_json,
};
ret.entry(scope).or_default().insert(toolchain);
}
ret
}
/// Saves a workspace using the worktree roots. Will garbage collect any workspaces
/// that used this workspace previously
pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
let paths = workspace.paths.serialize();
log::debug!("Saving workspace at location: {:?}", workspace.location);
self.write(move |conn| {
conn.with_savepoint("update_worktrees", || {
let remote_connection_id = match workspace.location.clone() {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(connection_options) => {
Some(Self::get_or_create_remote_connection_internal(
conn,
connection_options
)?.0)
}
};
// Clear out panes and pane_groups
conn.exec_bound(sql!(
DELETE FROM pane_groups WHERE workspace_id = ?1;
DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
.context("Clearing old panes")?;
conn.exec_bound(
sql!(
DELETE FROM breakpoints WHERE workspace_id = ?1;
)
)?(workspace.id).context("Clearing old breakpoints")?;
for (path, breakpoints) in workspace.breakpoints {
for bp in breakpoints {
let state = BreakpointStateWrapper::from(bp.state);
match conn.exec_bound(sql!(
INSERT INTO breakpoints (workspace_id, path, breakpoint_location, log_message, condition, hit_condition, state)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);))?
((
workspace.id,
path.as_ref(),
bp.row,
bp.message,
bp.condition,
bp.hit_condition,
state,
)) {
Ok(_) => {
log::debug!("Stored breakpoint at row: {} in path: {}", bp.row, path.to_string_lossy())
}
Err(err) => {
log::error!("{err}");
continue;
}
}
}
}
conn.exec_bound(
sql!(
DELETE FROM user_toolchains WHERE workspace_id = ?1;
)
)?(workspace.id).context("Clearing old user toolchains")?;
for (scope, toolchains) in workspace.user_toolchains {
for toolchain in toolchains {
let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
let (workspace_id, worktree_id, relative_worktree_path) = match scope {
ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.as_unix_str().to_owned())),
ToolchainScope::Project => (Some(workspace.id), None, None),
ToolchainScope::Global => (None, None, None),
};
let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(),
toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string());
if let Err(err) = conn.exec_bound(query)?(args) {
log::error!("{err}");
continue;
}
}
}
conn.exec_bound(sql!(
DELETE
FROM workspaces
WHERE
workspace_id != ?1 AND
paths IS ?2 AND
remote_connection_id IS ?3
))?((
workspace.id,
paths.paths.clone(),
remote_connection_id,
))
.context("clearing out old locations")?;
// Upsert
let query = sql!(
INSERT INTO workspaces(
workspace_id,
paths,
paths_order,
remote_connection_id,
left_dock_visible,
left_dock_active_panel,
left_dock_zoom,
right_dock_visible,
right_dock_active_panel,
right_dock_zoom,
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
session_id,
window_id,
timestamp
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, CURRENT_TIMESTAMP)
ON CONFLICT DO
UPDATE SET
paths = ?2,
paths_order = ?3,
remote_connection_id = ?4,
left_dock_visible = ?5,
left_dock_active_panel = ?6,
left_dock_zoom = ?7,
right_dock_visible = ?8,
right_dock_active_panel = ?9,
right_dock_zoom = ?10,
bottom_dock_visible = ?11,
bottom_dock_active_panel = ?12,
bottom_dock_zoom = ?13,
session_id = ?14,
window_id = ?15,
timestamp = CURRENT_TIMESTAMP
);
let mut prepared_query = conn.exec_bound(query)?;
let args = (
workspace.id,
paths.paths.clone(),
paths.order.clone(),
remote_connection_id,
workspace.docks,
workspace.session_id,
workspace.window_id,
);
prepared_query(args).context("Updating workspace")?;
// Save center pane group
Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
.context("save pane group in save workspace")?;
Ok(())
})
.log_err();
})
.await;
}
pub(crate) async fn get_or_create_remote_connection(
&self,
options: RemoteConnectionOptions,
) -> Result<RemoteConnectionId> {
self.write(move |conn| Self::get_or_create_remote_connection_internal(conn, options))
.await
}
fn get_or_create_remote_connection_internal(
this: &Connection,
options: RemoteConnectionOptions,
) -> Result<RemoteConnectionId> {
let kind;
let user;
let mut host = None;
let mut port = None;
let mut distro = None;
match options {
RemoteConnectionOptions::Ssh(options) => {
kind = RemoteConnectionKind::Ssh;
host = Some(options.host);
port = options.port;
user = options.username;
}
RemoteConnectionOptions::Wsl(options) => {
kind = RemoteConnectionKind::Wsl;
distro = Some(options.distro_name);
user = options.user;
}
}
Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro)
}
fn get_or_create_remote_connection_query(
this: &Connection,
kind: RemoteConnectionKind,
host: Option<String>,
port: Option<u16>,
user: Option<String>,
distro: Option<String>,
) -> Result<RemoteConnectionId> {
if let Some(id) = this.select_row_bound(sql!(
SELECT id
FROM remote_connections
WHERE
kind IS ? AND
host IS ? AND
port IS ? AND
user IS ? AND
distro IS ?
LIMIT 1
))?((
kind.serialize(),
host.clone(),
port,
user.clone(),
distro.clone(),
))? {
Ok(RemoteConnectionId(id))
} else {
let id = this.select_row_bound(sql!(
INSERT INTO remote_connections (
kind,
host,
port,
user,
distro
) VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING id
))?((kind.serialize(), host, port, user, distro))?
.context("failed to insert remote project")?;
Ok(RemoteConnectionId(id))
}
}
query! {
pub async fn next_id() -> Result<WorkspaceId> {
INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
}
}
fn recent_workspaces(
&self,
) -> Result<Vec<(WorkspaceId, PathList, Option<RemoteConnectionId>)>> {
Ok(self
.recent_workspaces_query()?
.into_iter()
.map(|(id, paths, order, remote_connection_id)| {
(
id,
PathList::deserialize(&SerializedPathList { paths, order }),
remote_connection_id.map(RemoteConnectionId),
)
})
.collect())
}
query! {
fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>)>> {
SELECT workspace_id, paths, paths_order, remote_connection_id
FROM workspaces
WHERE
paths IS NOT NULL OR
remote_connection_id IS NOT NULL
ORDER BY timestamp DESC
}
}
fn session_workspaces(
&self,
session_id: String,
) -> Result<Vec<(PathList, Option<u64>, Option<RemoteConnectionId>)>> {
Ok(self
.session_workspaces_query(session_id)?
.into_iter()
.map(|(paths, order, window_id, remote_connection_id)| {
(
PathList::deserialize(&SerializedPathList { paths, order }),
window_id,
remote_connection_id.map(RemoteConnectionId),
)
})
.collect())
}
query! {
fn session_workspaces_query(session_id: String) -> Result<Vec<(String, String, Option<u64>, Option<u64>)>> {
SELECT paths, paths_order, window_id, remote_connection_id
FROM workspaces
WHERE session_id = ?1
ORDER BY timestamp DESC
}
}
query! {
pub fn breakpoints_for_file(workspace_id: WorkspaceId, file_path: &Path) -> Result<Vec<Breakpoint>> {
SELECT breakpoint_location
FROM breakpoints
WHERE workspace_id= ?1 AND path = ?2
}
}
query! {
pub fn clear_breakpoints(file_path: &Path) -> Result<()> {
DELETE FROM breakpoints
WHERE file_path = ?2
}
}
fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
Ok(self.select(sql!(
SELECT
id, kind, host, port, user, distro
FROM
remote_connections
))?()?
.into_iter()
.filter_map(|(id, kind, host, port, user, distro)| {
Some((
RemoteConnectionId(id),
Self::remote_connection_from_row(kind, host, port, user, distro)?,
))
})
.collect())
}
pub(crate) fn remote_connection(
&self,
id: RemoteConnectionId,
) -> Result<RemoteConnectionOptions> {
let (kind, host, port, user, distro) = self.select_row_bound(sql!(
SELECT kind, host, port, user, distro
FROM remote_connections
WHERE id = ?
))?(id.0)?
.context("no such remote connection")?;
Self::remote_connection_from_row(kind, host, port, user, distro)
.context("invalid remote_connection row")
}
fn remote_connection_from_row(
kind: String,
host: Option<String>,
port: Option<u16>,
user: Option<String>,
distro: Option<String>,
) -> Option<RemoteConnectionOptions> {
match RemoteConnectionKind::deserialize(&kind)? {
RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
distro_name: distro?,
user: user,
})),
RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host?,
port,
username: user,
..Default::default()
})),
}
}
pub(crate) fn last_window(
&self,
) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
let mut prepared_query =
self.select::<(Option<Uuid>, Option<SerializedWindowBounds>)>(sql!(
SELECT
display,
window_state, window_x, window_y, window_width, window_height
FROM workspaces
WHERE paths
IS NOT NULL
ORDER BY timestamp DESC
LIMIT 1
))?;
let result = prepared_query()?;
Ok(result.into_iter().next().unwrap_or((None, None)))
}
query! {
pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
DELETE FROM workspaces
WHERE workspace_id IS ?
}
}
// Returns the recent locations which are still valid on disk and deletes ones which no longer
// exist.
pub async fn recent_workspaces_on_disk(
&self,
) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
let mut result = Vec::new();
let mut delete_tasks = Vec::new();
let remote_connections = self.remote_connections()?;
for (id, paths, remote_connection_id) in self.recent_workspaces()? {
if let Some(remote_connection_id) = remote_connection_id {
if let Some(connection_options) = remote_connections.get(&remote_connection_id) {
result.push((
id,
SerializedWorkspaceLocation::Remote(connection_options.clone()),
paths,
));
} else {
delete_tasks.push(self.delete_workspace_by_id(id));
}
continue;
}
let has_wsl_path = if cfg!(windows) {
fn is_wsl_path(path: &PathBuf) -> bool {
use std::path::{Component, Prefix};
path.components()
.next()
.and_then(|component| match component {
Component::Prefix(prefix) => Some(prefix),
_ => None,
})
.and_then(|prefix| match prefix.kind() {
Prefix::UNC(server, _) => Some(server),
Prefix::VerbatimUNC(server, _) => Some(server),
_ => None,
})
.map(|server| {
let server_str = server.to_string_lossy();
server_str == "wsl.localhost" || server_str == "wsl$"
})
.unwrap_or(false)
}
paths.paths().iter().any(|path| is_wsl_path(path))
} else {
false
};
// Delete the workspace if any of the paths are WSL paths.
// If a local workspace points to WSL, this check will cause us to wait for the
// WSL VM and file server to boot up. This can block for many seconds.
// Supported scenarios use remote workspaces.
if !has_wsl_path
&& paths.paths().iter().all(|path| path.exists())
&& paths.paths().iter().any(|path| path.is_dir())
{
result.push((id, SerializedWorkspaceLocation::Local, paths));
} else {
delete_tasks.push(self.delete_workspace_by_id(id));
}
}
futures::future::join_all(delete_tasks).await;
Ok(result)
}
pub async fn last_workspace(&self) -> Result<Option<(SerializedWorkspaceLocation, PathList)>> {
Ok(self
.recent_workspaces_on_disk()
.await?
.into_iter()
.next()
.map(|(_, location, paths)| (location, paths)))
}
// Returns the locations of the workspaces that were still opened when the last
// session was closed (i.e. when Zed was quit).
// If `last_session_window_order` is provided, the returned locations are ordered
// according to that.
pub fn last_session_workspace_locations(
&self,
last_session_id: &str,
last_session_window_stack: Option<Vec<WindowId>>,
) -> Result<Vec<(SerializedWorkspaceLocation, PathList)>> {
let mut workspaces = Vec::new();
for (paths, window_id, remote_connection_id) in
self.session_workspaces(last_session_id.to_owned())?
{
if let Some(remote_connection_id) = remote_connection_id {
workspaces.push((
SerializedWorkspaceLocation::Remote(
self.remote_connection(remote_connection_id)?,
),
paths,
window_id.map(WindowId::from),
));
} else if paths.paths().iter().all(|path| path.exists())
&& paths.paths().iter().any(|path| path.is_dir())
{
workspaces.push((
SerializedWorkspaceLocation::Local,
paths,
window_id.map(WindowId::from),
));
}
}
if let Some(stack) = last_session_window_stack {
workspaces.sort_by_key(|(_, _, window_id)| {
window_id
.and_then(|id| stack.iter().position(|&order_id| order_id == id))
.unwrap_or(usize::MAX)
});
}
Ok(workspaces
.into_iter()
.map(|(location, paths, _)| (location, paths))
.collect::<Vec<_>>())
}
fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
Ok(self
.get_pane_group(workspace_id, None)?
.into_iter()
.next()
.unwrap_or_else(|| {
SerializedPaneGroup::Pane(SerializedPane {
active: true,
children: vec![],
pinned_count: 0,
})
}))
}
fn get_pane_group(
&self,
workspace_id: WorkspaceId,
group_id: Option<GroupId>,
) -> Result<Vec<SerializedPaneGroup>> {
type GroupKey = (Option<GroupId>, WorkspaceId);
type GroupOrPane = (
Option<GroupId>,
Option<SerializedAxis>,
Option<PaneId>,
Option<bool>,
Option<usize>,
Option<String>,
);
self.select_bound::<GroupKey, GroupOrPane>(sql!(
SELECT group_id, axis, pane_id, active, pinned_count, flexes
FROM (SELECT
group_id,
axis,
NULL as pane_id,
NULL as active,
NULL as pinned_count,
position,
parent_group_id,
workspace_id,
flexes
FROM pane_groups
UNION
SELECT
NULL,
NULL,
center_panes.pane_id,
panes.active as active,
pinned_count,
position,
parent_group_id,
panes.workspace_id as workspace_id,
NULL
FROM center_panes
JOIN panes ON center_panes.pane_id = panes.pane_id)
WHERE parent_group_id IS ? AND workspace_id = ?
ORDER BY position
))?((group_id, workspace_id))?
.into_iter()
.map(|(group_id, axis, pane_id, active, pinned_count, flexes)| {
let maybe_pane = maybe!({ Some((pane_id?, active?, pinned_count?)) });
if let Some((group_id, axis)) = group_id.zip(axis) {
let flexes = flexes
.map(|flexes: String| serde_json::from_str::<Vec<f32>>(&flexes))
.transpose()?;
Ok(SerializedPaneGroup::Group {
axis,
children: self.get_pane_group(workspace_id, Some(group_id))?,
flexes,
})
} else if let Some((pane_id, active, pinned_count)) = maybe_pane {
Ok(SerializedPaneGroup::Pane(SerializedPane::new(
self.get_items(pane_id)?,
active,
pinned_count,
)))
} else {
bail!("Pane Group Child was neither a pane group or a pane");
}
})
// Filter out panes and pane groups which don't have any children or items
.filter(|pane_group| match pane_group {
Ok(SerializedPaneGroup::Group { children, .. }) => !children.is_empty(),
Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
_ => true,
})
.collect::<Result<_>>()
}
fn save_pane_group(
conn: &Connection,
workspace_id: WorkspaceId,
pane_group: &SerializedPaneGroup,
parent: Option<(GroupId, usize)>,
) -> Result<()> {
if parent.is_none() {
log::debug!("Saving a pane group for workspace {workspace_id:?}");
}
match pane_group {
SerializedPaneGroup::Group {
axis,
children,
flexes,
} => {
let (parent_id, position) = parent.unzip();
let flex_string = flexes
.as_ref()
.map(|flexes| serde_json::json!(flexes).to_string());
let group_id = conn.select_row_bound::<_, i64>(sql!(
INSERT INTO pane_groups(
workspace_id,
parent_group_id,
position,
axis,
flexes
)
VALUES (?, ?, ?, ?, ?)
RETURNING group_id
))?((
workspace_id,
parent_id,
position,
*axis,
flex_string,
))?
.context("Couldn't retrieve group_id from inserted pane_group")?;
for (position, group) in children.iter().enumerate() {
Self::save_pane_group(conn, workspace_id, group, Some((group_id, position)))?
}
Ok(())
}
SerializedPaneGroup::Pane(pane) => {
Self::save_pane(conn, workspace_id, pane, parent)?;
Ok(())
}
}
}
fn save_pane(
conn: &Connection,
workspace_id: WorkspaceId,
pane: &SerializedPane,
parent: Option<(GroupId, usize)>,
) -> Result<PaneId> {
let pane_id = conn.select_row_bound::<_, i64>(sql!(
INSERT INTO panes(workspace_id, active, pinned_count)
VALUES (?, ?, ?)
RETURNING pane_id
))?((workspace_id, pane.active, pane.pinned_count))?
.context("Could not retrieve inserted pane_id")?;
let (parent_id, order) = parent.unzip();
conn.exec_bound(sql!(
INSERT INTO center_panes(pane_id, parent_group_id, position)
VALUES (?, ?, ?)
))?((pane_id, parent_id, order))?;
Self::save_items(conn, workspace_id, pane_id, &pane.children).context("Saving items")?;
Ok(pane_id)
}
fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
self.select_bound(sql!(
SELECT kind, item_id, active, preview FROM items
WHERE pane_id = ?
ORDER BY position
))?(pane_id)
}
fn save_items(
conn: &Connection,
workspace_id: WorkspaceId,
pane_id: PaneId,
items: &[SerializedItem],
) -> Result<()> {
let mut insert = conn.exec_bound(sql!(
INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active, preview) VALUES (?, ?, ?, ?, ?, ?, ?)
)).context("Preparing insertion")?;
for (position, item) in items.iter().enumerate() {
insert((workspace_id, pane_id, position, item))?;
}
Ok(())
}
query! {
pub async fn update_timestamp(workspace_id: WorkspaceId) -> Result<()> {
UPDATE workspaces
SET timestamp = CURRENT_TIMESTAMP
WHERE workspace_id = ?
}
}
query! {
pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> {
UPDATE workspaces
SET window_state = ?2,
window_x = ?3,
window_y = ?4,
window_width = ?5,
window_height = ?6,
display = ?7
WHERE workspace_id = ?1
}
}
query! {
pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> {
UPDATE workspaces
SET centered_layout = ?2
WHERE workspace_id = ?1
}
}
query! {
pub(crate) async fn set_session_id(workspace_id: WorkspaceId, session_id: Option<String>) -> Result<()> {
UPDATE workspaces
SET session_id = ?2
WHERE workspace_id = ?1
}
}
pub async fn toolchain(
&self,
workspace_id: WorkspaceId,
worktree_id: WorktreeId,
relative_worktree_path: Arc<RelPath>,
language_name: LanguageName,
) -> Result<Option<Toolchain>> {
self.write(move |this| {
let mut select = this
.select_bound(sql!(
SELECT
name, path, raw_json
FROM toolchains
WHERE
workspace_id = ? AND
language_name = ? AND
worktree_id = ? AND
relative_worktree_path = ?
))
.context("select toolchain")?;
let toolchain: Vec<(String, String, String)> = select((
workspace_id,
language_name.as_ref().to_string(),
worktree_id.to_usize(),
relative_worktree_path.as_unix_str().to_string(),
))?;
Ok(toolchain
.into_iter()
.next()
.and_then(|(name, path, raw_json)| {
Some(Toolchain {
name: name.into(),
path: path.into(),
language_name,
as_json: serde_json::Value::from_str(&raw_json).ok()?,
})
}))
})
.await
}
pub(crate) async fn toolchains(
&self,
workspace_id: WorkspaceId,
) -> Result<Vec<(Toolchain, WorktreeId, Arc<RelPath>)>> {
self.write(move |this| {
let mut select = this
.select_bound(sql!(
SELECT
name, path, worktree_id, relative_worktree_path, language_name, raw_json
FROM toolchains
WHERE workspace_id = ?
))
.context("select toolchains")?;
let toolchain: Vec<(String, String, u64, String, String, String)> =
select(workspace_id)?;
Ok(toolchain
.into_iter()
.filter_map(
|(name, path, worktree_id, relative_worktree_path, language, json)| {
Some((
Toolchain {
name: name.into(),
path: path.into(),
language_name: LanguageName::new(&language),
as_json: serde_json::Value::from_str(&json).ok()?,
},
WorktreeId::from_proto(worktree_id),
RelPath::from_proto(&relative_worktree_path).log_err()?,
))
},
)
.collect())
})
.await
}
pub async fn set_toolchain(
&self,
workspace_id: WorkspaceId,
worktree_id: WorktreeId,
relative_worktree_path: Arc<RelPath>,
toolchain: Toolchain,
) -> Result<()> {
log::debug!(
"Setting toolchain for workspace, worktree: {worktree_id:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
toolchain.name
);
self.write(move |conn| {
let mut insert = conn
.exec_bound(sql!(
INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO
UPDATE SET
name = ?5,
path = ?6,
raw_json = ?7
))
.context("Preparing insertion")?;
insert((
workspace_id,
worktree_id.to_usize(),
relative_worktree_path.as_unix_str(),
toolchain.language_name.as_ref(),
toolchain.name.as_ref(),
toolchain.path.as_ref(),
toolchain.as_json.to_string(),
))?;
Ok(())
}).await
}
}
pub fn delete_unloaded_items(
alive_items: Vec<ItemId>,
workspace_id: WorkspaceId,
table: &'static str,
db: &ThreadSafeConnection,
cx: &mut App,
) -> Task<Result<()>> {
let db = db.clone();
cx.spawn(async move |_| {
let placeholders = alive_items
.iter()
.map(|_| "?")
.collect::<Vec<&str>>()
.join(", ");
let query = format!(
"DELETE FROM {table} WHERE workspace_id = ? AND item_id NOT IN ({placeholders})"
);
db.write(move |conn| {
let mut statement = Statement::prepare(conn, query)?;
let mut next_index = statement.bind(&workspace_id, 1)?;
for id in alive_items {
next_index = statement.bind(&id, next_index)?;
}
statement.exec()
})
.await
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::persistence::model::{
SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
};
use gpui;
use pretty_assertions::assert_eq;
use remote::SshConnectionOptions;
use std::{thread, time::Duration};
#[gpui::test]
async fn test_breakpoints() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_breakpoints").await;
let id = db.next_id().await.unwrap();
let path = Path::new("/tmp/test.rs");
let breakpoint = Breakpoint {
position: 123,
message: None,
state: BreakpointState::Enabled,
condition: None,
hit_condition: None,
};
let log_breakpoint = Breakpoint {
position: 456,
message: Some("Test log message".into()),
state: BreakpointState::Enabled,
condition: None,
hit_condition: None,
};
let disable_breakpoint = Breakpoint {
position: 578,
message: None,
state: BreakpointState::Disabled,
condition: None,
hit_condition: None,
};
let condition_breakpoint = Breakpoint {
position: 789,
message: None,
state: BreakpointState::Enabled,
condition: Some("x > 5".into()),
hit_condition: None,
};
let hit_condition_breakpoint = Breakpoint {
position: 999,
message: None,
state: BreakpointState::Enabled,
condition: None,
hit_condition: Some(">= 3".into()),
};
let workspace = SerializedWorkspace {
id,
paths: PathList::new(&["/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: {
let mut map = collections::BTreeMap::default();
map.insert(
Arc::from(path),
vec![
SourceBreakpoint {
row: breakpoint.position,
path: Arc::from(path),
message: breakpoint.message.clone(),
state: breakpoint.state,
condition: breakpoint.condition.clone(),
hit_condition: breakpoint.hit_condition.clone(),
},
SourceBreakpoint {
row: log_breakpoint.position,
path: Arc::from(path),
message: log_breakpoint.message.clone(),
state: log_breakpoint.state,
condition: log_breakpoint.condition.clone(),
hit_condition: log_breakpoint.hit_condition.clone(),
},
SourceBreakpoint {
row: disable_breakpoint.position,
path: Arc::from(path),
message: disable_breakpoint.message.clone(),
state: disable_breakpoint.state,
condition: disable_breakpoint.condition.clone(),
hit_condition: disable_breakpoint.hit_condition.clone(),
},
SourceBreakpoint {
row: condition_breakpoint.position,
path: Arc::from(path),
message: condition_breakpoint.message.clone(),
state: condition_breakpoint.state,
condition: condition_breakpoint.condition.clone(),
hit_condition: condition_breakpoint.hit_condition.clone(),
},
SourceBreakpoint {
row: hit_condition_breakpoint.position,
path: Arc::from(path),
message: hit_condition_breakpoint.message.clone(),
state: hit_condition_breakpoint.state,
condition: hit_condition_breakpoint.condition.clone(),
hit_condition: hit_condition_breakpoint.hit_condition.clone(),
},
],
);
map
},
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
db.save_workspace(workspace.clone()).await;
let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap();
assert_eq!(loaded_breakpoints.len(), 5);
// normal breakpoint
assert_eq!(loaded_breakpoints[0].row, breakpoint.position);
assert_eq!(loaded_breakpoints[0].message, breakpoint.message);
assert_eq!(loaded_breakpoints[0].condition, breakpoint.condition);
assert_eq!(
loaded_breakpoints[0].hit_condition,
breakpoint.hit_condition
);
assert_eq!(loaded_breakpoints[0].state, breakpoint.state);
assert_eq!(loaded_breakpoints[0].path, Arc::from(path));
// enabled breakpoint
assert_eq!(loaded_breakpoints[1].row, log_breakpoint.position);
assert_eq!(loaded_breakpoints[1].message, log_breakpoint.message);
assert_eq!(loaded_breakpoints[1].condition, log_breakpoint.condition);
assert_eq!(
loaded_breakpoints[1].hit_condition,
log_breakpoint.hit_condition
);
assert_eq!(loaded_breakpoints[1].state, log_breakpoint.state);
assert_eq!(loaded_breakpoints[1].path, Arc::from(path));
// disable breakpoint
assert_eq!(loaded_breakpoints[2].row, disable_breakpoint.position);
assert_eq!(loaded_breakpoints[2].message, disable_breakpoint.message);
assert_eq!(
loaded_breakpoints[2].condition,
disable_breakpoint.condition
);
assert_eq!(
loaded_breakpoints[2].hit_condition,
disable_breakpoint.hit_condition
);
assert_eq!(loaded_breakpoints[2].state, disable_breakpoint.state);
assert_eq!(loaded_breakpoints[2].path, Arc::from(path));
// condition breakpoint
assert_eq!(loaded_breakpoints[3].row, condition_breakpoint.position);
assert_eq!(loaded_breakpoints[3].message, condition_breakpoint.message);
assert_eq!(
loaded_breakpoints[3].condition,
condition_breakpoint.condition
);
assert_eq!(
loaded_breakpoints[3].hit_condition,
condition_breakpoint.hit_condition
);
assert_eq!(loaded_breakpoints[3].state, condition_breakpoint.state);
assert_eq!(loaded_breakpoints[3].path, Arc::from(path));
// hit condition breakpoint
assert_eq!(loaded_breakpoints[4].row, hit_condition_breakpoint.position);
assert_eq!(
loaded_breakpoints[4].message,
hit_condition_breakpoint.message
);
assert_eq!(
loaded_breakpoints[4].condition,
hit_condition_breakpoint.condition
);
assert_eq!(
loaded_breakpoints[4].hit_condition,
hit_condition_breakpoint.hit_condition
);
assert_eq!(loaded_breakpoints[4].state, hit_condition_breakpoint.state);
assert_eq!(loaded_breakpoints[4].path, Arc::from(path));
}
#[gpui::test]
async fn test_remove_last_breakpoint() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_remove_last_breakpoint").await;
let id = db.next_id().await.unwrap();
let singular_path = Path::new("/tmp/test_remove_last_breakpoint.rs");
let breakpoint_to_remove = Breakpoint {
position: 100,
message: None,
state: BreakpointState::Enabled,
condition: None,
hit_condition: None,
};
let workspace = SerializedWorkspace {
id,
paths: PathList::new(&["/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: {
let mut map = collections::BTreeMap::default();
map.insert(
Arc::from(singular_path),
vec![SourceBreakpoint {
row: breakpoint_to_remove.position,
path: Arc::from(singular_path),
message: None,
state: BreakpointState::Enabled,
condition: None,
hit_condition: None,
}],
);
map
},
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
db.save_workspace(workspace.clone()).await;
let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(singular_path)).unwrap();
assert_eq!(loaded_breakpoints.len(), 1);
assert_eq!(loaded_breakpoints[0].row, breakpoint_to_remove.position);
assert_eq!(loaded_breakpoints[0].message, breakpoint_to_remove.message);
assert_eq!(
loaded_breakpoints[0].condition,
breakpoint_to_remove.condition
);
assert_eq!(
loaded_breakpoints[0].hit_condition,
breakpoint_to_remove.hit_condition
);
assert_eq!(loaded_breakpoints[0].state, breakpoint_to_remove.state);
assert_eq!(loaded_breakpoints[0].path, Arc::from(singular_path));
let workspace_without_breakpoint = SerializedWorkspace {
id,
paths: PathList::new(&["/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: collections::BTreeMap::default(),
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
db.save_workspace(workspace_without_breakpoint.clone())
.await;
let loaded_after_remove = db.workspace_for_roots(&["/tmp"]).unwrap();
let empty_breakpoints = loaded_after_remove
.breakpoints
.get(&Arc::from(singular_path));
assert!(empty_breakpoints.is_none());
}
#[gpui::test]
async fn test_next_id_stability() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_next_id_stability").await;
db.write(|conn| {
conn.migrate(
"test_table",
&[sql!(
CREATE TABLE test_table(
text TEXT,
workspace_id INTEGER,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)],
|_, _, _| false,
)
.unwrap();
})
.await;
let id = db.next_id().await.unwrap();
// Assert the empty row got inserted
assert_eq!(
Some(id),
db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
SELECT workspace_id FROM workspaces WHERE workspace_id = ?
))
.unwrap()(id)
.unwrap()
);
db.write(move |conn| {
conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
.unwrap()(("test-text-1", id))
.unwrap()
})
.await;
let test_text_1 = db
.select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
.unwrap()(1)
.unwrap()
.unwrap();
assert_eq!(test_text_1, "test-text-1");
}
#[gpui::test]
async fn test_workspace_id_stability() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_workspace_id_stability").await;
db.write(|conn| {
conn.migrate(
"test_table",
&[sql!(
CREATE TABLE test_table(
text TEXT,
workspace_id INTEGER,
FOREIGN KEY(workspace_id)
REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;)],
|_, _, _| false,
)
})
.await
.unwrap();
let mut workspace_1 = SerializedWorkspace {
id: WorkspaceId(1),
paths: PathList::new(&["/tmp", "/tmp2"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
let workspace_2 = SerializedWorkspace {
id: WorkspaceId(2),
paths: PathList::new(&["/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
db.save_workspace(workspace_1.clone()).await;
db.write(|conn| {
conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
.unwrap()(("test-text-1", 1))
.unwrap();
})
.await;
db.save_workspace(workspace_2.clone()).await;
db.write(|conn| {
conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
.unwrap()(("test-text-2", 2))
.unwrap();
})
.await;
workspace_1.paths = PathList::new(&["/tmp", "/tmp3"]);
db.save_workspace(workspace_1.clone()).await;
db.save_workspace(workspace_1).await;
db.save_workspace(workspace_2).await;
let test_text_2 = db
.select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
.unwrap()(2)
.unwrap()
.unwrap();
assert_eq!(test_text_2, "test-text-2");
let test_text_1 = db
.select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
.unwrap()(1)
.unwrap()
.unwrap();
assert_eq!(test_text_1, "test-text-1");
}
fn group(axis: Axis, children: Vec<SerializedPaneGroup>) -> SerializedPaneGroup {
SerializedPaneGroup::Group {
axis: SerializedAxis(axis),
flexes: None,
children,
}
}
#[gpui::test]
async fn test_full_workspace_serialization() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_full_workspace_serialization").await;
// -----------------
// | 1,2 | 5,6 |
// | - - - | |
// | 3,4 | |
// -----------------
let center_group = group(
Axis::Horizontal,
vec![
group(
Axis::Vertical,
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, false, false),
SerializedItem::new("Terminal", 6, true, false),
],
false,
0,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 7, true, false),
SerializedItem::new("Terminal", 8, false, false),
],
false,
0,
)),
],
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 9, false, false),
SerializedItem::new("Terminal", 10, true, false),
],
false,
0,
)),
],
);
let workspace = SerializedWorkspace {
id: WorkspaceId(5),
paths: PathList::new(&["/tmp", "/tmp2"]),
location: SerializedWorkspaceLocation::Local,
center_group,
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: None,
window_id: Some(999),
user_toolchains: Default::default(),
};
db.save_workspace(workspace.clone()).await;
let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
assert_eq!(workspace, round_trip_workspace.unwrap());
// Test guaranteed duplicate IDs
db.save_workspace(workspace.clone()).await;
db.save_workspace(workspace.clone()).await;
let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
assert_eq!(workspace, round_trip_workspace.unwrap());
}
#[gpui::test]
async fn test_workspace_assignment() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_basic_functionality").await;
let workspace_1 = SerializedWorkspace {
id: WorkspaceId(1),
paths: PathList::new(&["/tmp", "/tmp2"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: None,
window_id: Some(1),
user_toolchains: Default::default(),
};
let mut workspace_2 = SerializedWorkspace {
id: WorkspaceId(2),
paths: PathList::new(&["/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: Some(2),
user_toolchains: Default::default(),
};
db.save_workspace(workspace_1.clone()).await;
db.save_workspace(workspace_2.clone()).await;
// Test that paths are treated as a set
assert_eq!(
db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
workspace_1
);
assert_eq!(
db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
workspace_1
);
// Make sure that other keys work
assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
// Test 'mutate' case of updating a pre-existing id
workspace_2.paths = PathList::new(&["/tmp", "/tmp2"]);
db.save_workspace(workspace_2.clone()).await;
assert_eq!(
db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
workspace_2
);
// Test other mechanism for mutating
let mut workspace_3 = SerializedWorkspace {
id: WorkspaceId(3),
paths: PathList::new(&["/tmp2", "/tmp"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: None,
window_id: Some(3),
user_toolchains: Default::default(),
};
db.save_workspace(workspace_3.clone()).await;
assert_eq!(
db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
workspace_3
);
// Make sure that updating paths differently also works
workspace_3.paths = PathList::new(&["/tmp3", "/tmp4", "/tmp2"]);
db.save_workspace(workspace_3.clone()).await;
assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
assert_eq!(
db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
.unwrap(),
workspace_3
);
}
#[gpui::test]
async fn test_session_workspaces() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_serializing_workspaces_session_id").await;
let workspace_1 = SerializedWorkspace {
id: WorkspaceId(1),
paths: PathList::new(&["/tmp1"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-1".to_owned()),
window_id: Some(10),
user_toolchains: Default::default(),
};
let workspace_2 = SerializedWorkspace {
id: WorkspaceId(2),
paths: PathList::new(&["/tmp2"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-1".to_owned()),
window_id: Some(20),
user_toolchains: Default::default(),
};
let workspace_3 = SerializedWorkspace {
id: WorkspaceId(3),
paths: PathList::new(&["/tmp3"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-2".to_owned()),
window_id: Some(30),
user_toolchains: Default::default(),
};
let workspace_4 = SerializedWorkspace {
id: WorkspaceId(4),
paths: PathList::new(&["/tmp4"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
let connection_id = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: "my-host".to_string(),
port: Some(1234),
..Default::default()
}))
.await
.unwrap();
let workspace_5 = SerializedWorkspace {
id: WorkspaceId(5),
paths: PathList::default(),
location: SerializedWorkspaceLocation::Remote(
db.remote_connection(connection_id).unwrap(),
),
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-2".to_owned()),
window_id: Some(50),
user_toolchains: Default::default(),
};
let workspace_6 = SerializedWorkspace {
id: WorkspaceId(6),
paths: PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: Some("session-id-3".to_owned()),
window_id: Some(60),
user_toolchains: Default::default(),
};
db.save_workspace(workspace_1.clone()).await;
thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
db.save_workspace(workspace_2.clone()).await;
db.save_workspace(workspace_3.clone()).await;
thread::sleep(Duration::from_millis(1000)); // Force timestamps to increment
db.save_workspace(workspace_4.clone()).await;
db.save_workspace(workspace_5.clone()).await;
db.save_workspace(workspace_6.clone()).await;
let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, PathList::new(&["/tmp2"]));
assert_eq!(locations[0].1, Some(20));
assert_eq!(locations[1].0, PathList::new(&["/tmp1"]));
assert_eq!(locations[1].1, Some(10));
let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, PathList::default());
assert_eq!(locations[0].1, Some(50));
assert_eq!(locations[0].2, Some(connection_id));
assert_eq!(locations[1].0, PathList::new(&["/tmp3"]));
assert_eq!(locations[1].1, Some(30));
let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(
locations[0].0,
PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
);
assert_eq!(locations[0].1, Some(60));
}
fn default_workspace<P: AsRef<Path>>(
paths: &[P],
center_group: &SerializedPaneGroup,
) -> SerializedWorkspace {
SerializedWorkspace {
id: WorkspaceId(4),
paths: PathList::new(paths),
location: SerializedWorkspaceLocation::Local,
center_group: center_group.clone(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
breakpoints: Default::default(),
centered_layout: false,
session_id: None,
window_id: None,
user_toolchains: Default::default(),
}
}
#[gpui::test]
async fn test_last_session_workspace_locations() {
let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
let db =
WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
let workspaces = [
(1, vec![dir1.path()], 9),
(2, vec![dir2.path()], 5),
(3, vec![dir3.path()], 8),
(4, vec![dir4.path()], 2),
(5, vec![dir1.path(), dir2.path(), dir3.path()], 3),
(6, vec![dir4.path(), dir3.path(), dir2.path()], 4),
]
.into_iter()
.map(|(id, paths, window_id)| SerializedWorkspace {
id: WorkspaceId(id),
paths: PathList::new(paths.as_slice()),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: Some("one-session".to_owned()),
breakpoints: Default::default(),
window_id: Some(window_id),
user_toolchains: Default::default(),
})
.collect::<Vec<_>>();
for workspace in workspaces.iter() {
db.save_workspace(workspace.clone()).await;
}
let stack = Some(Vec::from([
WindowId::from(2), // Top
WindowId::from(8),
WindowId::from(5),
WindowId::from(9),
WindowId::from(3),
WindowId::from(4), // Bottom
]));
let locations = db
.last_session_workspace_locations("one-session", stack)
.unwrap();
assert_eq!(
locations,
[
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir4.path()])
),
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir3.path()])
),
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir2.path()])
),
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir1.path()])
),
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir1.path(), dir2.path(), dir3.path()])
),
(
SerializedWorkspaceLocation::Local,
PathList::new(&[dir4.path(), dir3.path(), dir2.path()])
),
]
);
}
#[gpui::test]
async fn test_last_session_workspace_locations_remote() {
let db =
WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
.await;
let remote_connections = [
("host-1", "my-user-1"),
("host-2", "my-user-2"),
("host-3", "my-user-3"),
("host-4", "my-user-4"),
]
.into_iter()
.map(|(host, user)| async {
let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host.to_string(),
username: Some(user.to_string()),
..Default::default()
});
db.get_or_create_remote_connection(options.clone())
.await
.unwrap();
options
})
.collect::<Vec<_>>();
let remote_connections = futures::future::join_all(remote_connections).await;
let workspaces = [
(1, remote_connections[0].clone(), 9),
(2, remote_connections[1].clone(), 5),
(3, remote_connections[2].clone(), 8),
(4, remote_connections[3].clone(), 2),
]
.into_iter()
.map(|(id, remote_connection, window_id)| SerializedWorkspace {
id: WorkspaceId(id),
paths: PathList::default(),
location: SerializedWorkspaceLocation::Remote(remote_connection),
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: Some("one-session".to_owned()),
breakpoints: Default::default(),
window_id: Some(window_id),
user_toolchains: Default::default(),
})
.collect::<Vec<_>>();
for workspace in workspaces.iter() {
db.save_workspace(workspace.clone()).await;
}
let stack = Some(Vec::from([
WindowId::from(2), // Top
WindowId::from(8),
WindowId::from(5),
WindowId::from(9), // Bottom
]));
let have = db
.last_session_workspace_locations("one-session", stack)
.unwrap();
assert_eq!(have.len(), 4);
assert_eq!(
have[0],
(
SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
PathList::default()
)
);
assert_eq!(
have[1],
(
SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
PathList::default()
)
);
assert_eq!(
have[2],
(
SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
PathList::default()
)
);
assert_eq!(
have[3],
(
SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
PathList::default()
)
);
}
#[gpui::test]
async fn test_get_or_create_ssh_project() {
let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project").await;
let host = "example.com".to_string();
let port = Some(22_u16);
let user = Some("user".to_string());
let connection_id = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host.clone(),
port,
username: user.clone(),
..Default::default()
}))
.await
.unwrap();
// Test that calling the function again with the same parameters returns the same project
let same_connection = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host.clone(),
port,
username: user.clone(),
..Default::default()
}))
.await
.unwrap();
assert_eq!(connection_id, same_connection);
// Test with different parameters
let host2 = "otherexample.com".to_string();
let port2 = None;
let user2 = Some("otheruser".to_string());
let different_connection = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host2.clone(),
port: port2,
username: user2.clone(),
..Default::default()
}))
.await
.unwrap();
assert_ne!(connection_id, different_connection);
}
#[gpui::test]
async fn test_get_or_create_ssh_project_with_null_user() {
let db = WorkspaceDb::open_test_db("test_get_or_create_ssh_project_with_null_user").await;
let (host, port, user) = ("example.com".to_string(), None, None);
let connection_id = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host.clone(),
port,
username: None,
..Default::default()
}))
.await
.unwrap();
let same_connection_id = db
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: host.clone(),
port,
username: user.clone(),
..Default::default()
}))
.await
.unwrap();
assert_eq!(connection_id, same_connection_id);
}
#[gpui::test]
async fn test_get_remote_connections() {
let db = WorkspaceDb::open_test_db("test_get_remote_connections").await;
let connections = [
("example.com".to_string(), None, None),
(
"anotherexample.com".to_string(),
Some(123_u16),
Some("user2".to_string()),
),
("yetanother.com".to_string(), Some(345_u16), None),
];
let mut ids = Vec::new();
for (host, port, user) in connections.iter() {
ids.push(
db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
SshConnectionOptions {
host: host.clone(),
port: *port,
username: user.clone(),
..Default::default()
},
))
.await
.unwrap(),
);
}
let stored_connections = db.remote_connections().unwrap();
assert_eq!(
stored_connections,
[
(
ids[0],
RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: "example.com".into(),
port: None,
username: None,
..Default::default()
}),
),
(
ids[1],
RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: "anotherexample.com".into(),
port: Some(123),
username: Some("user2".into()),
..Default::default()
}),
),
(
ids[2],
RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: "yetanother.com".into(),
port: Some(345),
username: None,
..Default::default()
}),
),
]
.into_iter()
.collect::<HashMap<_, _>>(),
);
}
#[gpui::test]
async fn test_simple_split() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("simple_split").await;
// -----------------
// | 1,2 | 5,6 |
// | - - - | |
// | 3,4 | |
// -----------------
let center_pane = group(
Axis::Horizontal,
vec![
group(
Axis::Vertical,
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
0,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, false, false),
SerializedItem::new("Terminal", 3, true, false),
],
true,
0,
)),
],
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, true, false),
SerializedItem::new("Terminal", 6, false, false),
],
false,
0,
)),
],
);
let workspace = default_workspace(&["/tmp"], &center_pane);
db.save_workspace(workspace.clone()).await;
let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
assert_eq!(workspace.center_group, new_workspace.center_group);
}
#[gpui::test]
async fn test_cleanup_panes() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_cleanup_panes").await;
let center_pane = group(
Axis::Horizontal,
vec![
group(
Axis::Vertical,
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
0,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, false, false),
SerializedItem::new("Terminal", 3, true, false),
],
true,
0,
)),
],
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, false, false),
SerializedItem::new("Terminal", 6, true, false),
],
false,
0,
)),
],
);
let id = &["/tmp"];
let mut workspace = default_workspace(id, &center_pane);
db.save_workspace(workspace.clone()).await;
workspace.center_group = group(
Axis::Vertical,
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
0,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, true, false),
SerializedItem::new("Terminal", 3, false, false),
],
true,
0,
)),
],
);
db.save_workspace(workspace.clone()).await;
let new_workspace = db.workspace_for_roots(id).unwrap();
assert_eq!(workspace.center_group, new_workspace.center_group);
}
}