Closes https://github.com/zed-industries/zed/issues/53262 - Remove the ability to pick a branch from the agent panel; delegate this to the title bar picker - Make the worktree creation earger, just as you selected whether you want to create it from main or current branch - Remove flicker when creating a new worktree and switching to a previously existing one - Improve some UI stuff: how we display that a worktree is creating/loading, the branch and worktree icons, etc. - Fixed a bug where worktrees in a detached HEAD state wouldn't show up in the worktree pickers A big part of the diff of this PR is the removal of everything involved with the `StartThreadIn` enum/the set up involved in only creating the worktree by the time of the first prompt send. Release Notes: - Agent: Improved and simplified the UX of creating threads in Git worktrees. - Git: Fixed a bug where worktrees in a detached HEAD state wouldn't show up in the worktree picker. --------- Co-authored-by: Nathan Sobo <nathan@zed.dev>
5097 lines
184 KiB
Rust
5097 lines
184 KiB
Rust
pub mod model;
|
|
|
|
use std::{
|
|
borrow::Cow,
|
|
collections::BTreeMap,
|
|
path::{Path, PathBuf},
|
|
str::FromStr,
|
|
sync::Arc,
|
|
};
|
|
|
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
use fs::Fs;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use collections::{HashMap, HashSet, IndexSet};
|
|
use db::{
|
|
kvp::KeyValueStore,
|
|
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},
|
|
trusted_worktrees::{DbTrustedPaths, RemoteHostLocation},
|
|
};
|
|
|
|
use language::{LanguageName, Toolchain, ToolchainScope};
|
|
use remote::{
|
|
DockerConnectionOptions, RemoteConnectionIdentity, RemoteConnectionOptions,
|
|
SshConnectionOptions, WslConnectionOptions, remote_connection_identity,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
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, SessionWorkspace};
|
|
|
|
// https://www.sqlite.org/limits.html
|
|
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
|
|
// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
|
|
const MAX_QUERY_PLACEHOLDERS: usize = 32000;
|
|
|
|
fn parse_timestamp(text: &str) -> DateTime<Utc> {
|
|
NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
|
|
.map(|naive| naive.and_utc())
|
|
.unwrap_or_else(|_| Utc::now())
|
|
}
|
|
|
|
#[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))
|
|
}
|
|
}
|
|
|
|
const DEFAULT_WINDOW_BOUNDS_KEY: &str = "default_window_bounds";
|
|
|
|
pub fn read_default_window_bounds(kvp: &KeyValueStore) -> Option<(Uuid, WindowBounds)> {
|
|
let json_str = kvp
|
|
.read_kvp(DEFAULT_WINDOW_BOUNDS_KEY)
|
|
.log_err()
|
|
.flatten()?;
|
|
|
|
let (display_uuid, persisted) =
|
|
serde_json::from_str::<(Uuid, WindowBoundsJson)>(&json_str).ok()?;
|
|
Some((display_uuid, persisted.into()))
|
|
}
|
|
|
|
pub async fn write_default_window_bounds(
|
|
kvp: &KeyValueStore,
|
|
bounds: WindowBounds,
|
|
display_uuid: Uuid,
|
|
) -> anyhow::Result<()> {
|
|
let persisted = WindowBoundsJson::from(bounds);
|
|
let json_str = serde_json::to_string(&(display_uuid, persisted))?;
|
|
kvp.write_kvp(DEFAULT_WINDOW_BOUNDS_KEY.to_string(), json_str)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub enum WindowBoundsJson {
|
|
Windowed {
|
|
x: i32,
|
|
y: i32,
|
|
width: i32,
|
|
height: i32,
|
|
},
|
|
Maximized {
|
|
x: i32,
|
|
y: i32,
|
|
width: i32,
|
|
height: i32,
|
|
},
|
|
Fullscreen {
|
|
x: i32,
|
|
y: i32,
|
|
width: i32,
|
|
height: i32,
|
|
},
|
|
}
|
|
|
|
impl From<WindowBounds> for WindowBoundsJson {
|
|
fn from(b: WindowBounds) -> Self {
|
|
match b {
|
|
WindowBounds::Windowed(bounds) => {
|
|
let origin = bounds.origin;
|
|
let size = bounds.size;
|
|
WindowBoundsJson::Windowed {
|
|
x: f32::from(origin.x).round() as i32,
|
|
y: f32::from(origin.y).round() as i32,
|
|
width: f32::from(size.width).round() as i32,
|
|
height: f32::from(size.height).round() as i32,
|
|
}
|
|
}
|
|
WindowBounds::Maximized(bounds) => {
|
|
let origin = bounds.origin;
|
|
let size = bounds.size;
|
|
WindowBoundsJson::Maximized {
|
|
x: f32::from(origin.x).round() as i32,
|
|
y: f32::from(origin.y).round() as i32,
|
|
width: f32::from(size.width).round() as i32,
|
|
height: f32::from(size.height).round() as i32,
|
|
}
|
|
}
|
|
WindowBounds::Fullscreen(bounds) => {
|
|
let origin = bounds.origin;
|
|
let size = bounds.size;
|
|
WindowBoundsJson::Fullscreen {
|
|
x: f32::from(origin.x).round() as i32,
|
|
y: f32::from(origin.y).round() as i32,
|
|
width: f32::from(size.width).round() as i32,
|
|
height: f32::from(size.height).round() as i32,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<WindowBoundsJson> for WindowBounds {
|
|
fn from(n: WindowBoundsJson) -> Self {
|
|
match n {
|
|
WindowBoundsJson::Windowed {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
} => WindowBounds::Windowed(Bounds {
|
|
origin: point(px(x as f32), px(y as f32)),
|
|
size: size(px(width as f32), px(height as f32)),
|
|
}),
|
|
WindowBoundsJson::Maximized {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
} => WindowBounds::Maximized(Bounds {
|
|
origin: point(px(x as f32), px(y as f32)),
|
|
size: size(px(width as f32), px(height as f32)),
|
|
}),
|
|
WindowBoundsJson::Fullscreen {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
} => WindowBounds::Fullscreen(Bounds {
|
|
origin: point(px(x as f32), px(y as f32)),
|
|
size: size(px(width as f32), px(height as f32)),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_multi_workspace_state(window_id: WindowId, cx: &App) -> model::MultiWorkspaceState {
|
|
let kvp = KeyValueStore::global(cx);
|
|
kvp.scoped("multi_workspace_state")
|
|
.read(&window_id.as_u64().to_string())
|
|
.log_err()
|
|
.flatten()
|
|
.and_then(|json| serde_json::from_str(&json).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn write_multi_workspace_state(
|
|
kvp: &KeyValueStore,
|
|
window_id: WindowId,
|
|
state: model::MultiWorkspaceState,
|
|
) {
|
|
if let Ok(json_str) = serde_json::to_string(&state) {
|
|
kvp.scoped("multi_workspace_state")
|
|
.write(window_id.as_u64().to_string(), json_str)
|
|
.await
|
|
.log_err();
|
|
}
|
|
}
|
|
|
|
pub fn read_serialized_multi_workspaces(
|
|
session_workspaces: Vec<model::SessionWorkspace>,
|
|
cx: &App,
|
|
) -> Vec<model::SerializedMultiWorkspace> {
|
|
let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
|
|
let mut window_id_to_group: HashMap<WindowId, usize> = HashMap::default();
|
|
|
|
for session_workspace in session_workspaces {
|
|
match session_workspace.window_id {
|
|
Some(window_id) => {
|
|
let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| {
|
|
window_groups.push(Vec::new());
|
|
window_groups.len() - 1
|
|
});
|
|
window_groups[group_index].push(session_workspace);
|
|
}
|
|
None => {
|
|
window_groups.push(vec![session_workspace]);
|
|
}
|
|
}
|
|
}
|
|
|
|
window_groups
|
|
.into_iter()
|
|
.filter_map(|group| {
|
|
let window_id = group.first().and_then(|sw| sw.window_id);
|
|
let state = window_id
|
|
.map(|wid| read_multi_workspace_state(wid, cx))
|
|
.unwrap_or_default();
|
|
let active_workspace = state
|
|
.active_workspace_id
|
|
.and_then(|id| group.iter().position(|ws| ws.workspace_id == id))
|
|
.or(Some(0))
|
|
.and_then(|index| group.into_iter().nth(index))?;
|
|
Some(model::SerializedMultiWorkspace {
|
|
active_workspace,
|
|
state,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state";
|
|
|
|
pub fn read_default_dock_state(kvp: &KeyValueStore) -> Option<DockStructure> {
|
|
let json_str = kvp.read_kvp(DEFAULT_DOCK_STATE_KEY).log_err().flatten()?;
|
|
|
|
serde_json::from_str::<DockStructure>(&json_str).ok()
|
|
}
|
|
|
|
pub async fn write_default_dock_state(
|
|
kvp: &KeyValueStore,
|
|
docks: DockStructure,
|
|
) -> anyhow::Result<()> {
|
|
let json_str = serde_json::to_string(&docks)?;
|
|
kvp.write_kvp(DEFAULT_DOCK_STATE_KEY.to_string(), json_str)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[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;
|
|
),
|
|
sql!(
|
|
ALTER TABLE remote_connections ADD COLUMN name TEXT;
|
|
ALTER TABLE remote_connections ADD COLUMN container_id TEXT;
|
|
),
|
|
sql!(
|
|
CREATE TABLE IF NOT EXISTS trusted_worktrees (
|
|
trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
absolute_path TEXT,
|
|
user_name TEXT,
|
|
host_name TEXT
|
|
) STRICT;
|
|
),
|
|
sql!(CREATE TABLE toolchains2 (
|
|
workspace_id INTEGER,
|
|
worktree_root_path TEXT NOT NULL,
|
|
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_root_path, language_name, relative_worktree_path)) STRICT;
|
|
INSERT OR REPLACE INTO toolchains2
|
|
// The `instr(paths, '\n') = 0` part allows us to find all
|
|
// workspaces that have a single worktree, as `\n` is used as a
|
|
// separator when serializing the workspace paths, so if no `\n` is
|
|
// found, we know we have a single worktree.
|
|
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;
|
|
DROP TABLE toolchains;
|
|
ALTER TABLE toolchains2 RENAME TO toolchains;
|
|
),
|
|
sql!(CREATE TABLE user_toolchains2 (
|
|
remote_connection_id INTEGER,
|
|
workspace_id INTEGER NOT NULL,
|
|
worktree_root_path TEXT 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_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT;
|
|
INSERT OR REPLACE INTO user_toolchains2
|
|
// The `instr(paths, '\n') = 0` part allows us to find all
|
|
// workspaces that have a single worktree, as `\n` is used as a
|
|
// separator when serializing the workspace paths, so if no `\n` is
|
|
// found, we know we have a single worktree.
|
|
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;
|
|
DROP TABLE user_toolchains;
|
|
ALTER TABLE user_toolchains2 RENAME TO user_toolchains;
|
|
),
|
|
sql!(
|
|
ALTER TABLE remote_connections ADD COLUMN use_podman BOOLEAN;
|
|
),
|
|
sql!(
|
|
ALTER TABLE remote_connections ADD COLUMN remote_env TEXT;
|
|
),
|
|
];
|
|
|
|
// 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!(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],
|
|
remote_project_id: RemoteConnectionId,
|
|
) -> Option<SerializedWorkspace> {
|
|
self.workspace_for_roots_internal(worktree_roots, Some(remote_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);
|
|
|
|
// Empty workspaces cannot be matched by paths (all empty workspaces have paths = "").
|
|
// They should only be restored via workspace_for_id during session restoration.
|
|
if root_paths.is_empty() && remote_connection_id.is_none() {
|
|
return None;
|
|
}
|
|
|
|
// 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
|
|
})
|
|
.and_then(|mut prepared_statement| {
|
|
(prepared_statement)((
|
|
root_paths.serialize().paths,
|
|
remote_connection_id.map(|id| id.0 as i32),
|
|
))
|
|
})
|
|
.context("No workspaces found")
|
|
.warn_on_err()
|
|
.flatten()?;
|
|
|
|
let paths = PathList::deserialize(&SerializedPathList {
|
|
paths,
|
|
order: paths_order,
|
|
});
|
|
|
|
let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
|
|
self.remote_connection(remote_connection_id)
|
|
.context("Get remote connection")
|
|
.log_err()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(SerializedWorkspace {
|
|
id: workspace_id,
|
|
location: match remote_connection_options {
|
|
Some(options) => SerializedWorkspaceLocation::Remote(options),
|
|
None => 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),
|
|
})
|
|
}
|
|
|
|
/// Returns the workspace with the given ID, loading all associated data.
|
|
pub(crate) fn workspace_for_id(
|
|
&self,
|
|
workspace_id: WorkspaceId,
|
|
) -> Option<SerializedWorkspace> {
|
|
let (
|
|
paths,
|
|
paths_order,
|
|
window_bounds,
|
|
display,
|
|
centered_layout,
|
|
docks,
|
|
window_id,
|
|
remote_connection_id,
|
|
): (
|
|
String,
|
|
String,
|
|
Option<SerializedWindowBounds>,
|
|
Option<Uuid>,
|
|
Option<bool>,
|
|
DockStructure,
|
|
Option<u64>,
|
|
Option<i32>,
|
|
) = self
|
|
.select_row_bound(sql! {
|
|
SELECT
|
|
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,
|
|
remote_connection_id
|
|
FROM workspaces
|
|
WHERE workspace_id = ?
|
|
})
|
|
.and_then(|mut prepared_statement| (prepared_statement)(workspace_id))
|
|
.context("No workspace found for id")
|
|
.warn_on_err()
|
|
.flatten()?;
|
|
|
|
let paths = PathList::deserialize(&SerializedPathList {
|
|
paths,
|
|
order: paths_order,
|
|
});
|
|
|
|
let remote_connection_id = remote_connection_id.map(|id| RemoteConnectionId(id as u64));
|
|
let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
|
|
self.remote_connection(remote_connection_id)
|
|
.context("Get remote connection")
|
|
.log_err()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(SerializedWorkspace {
|
|
id: workspace_id,
|
|
location: match remote_connection_options {
|
|
Some(options) => SerializedWorkspaceLocation::Remote(options),
|
|
None => 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, String, String, String, String, String, String);
|
|
|
|
let toolchains: Vec<RowKind> = self
|
|
.select_bound(sql! {
|
|
SELECT workspace_id, worktree_root_path, 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_root_path,
|
|
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_root_path, String::default());
|
|
debug_assert_eq!(relative_worktree_path, String::default());
|
|
ToolchainScope::Global
|
|
} else {
|
|
debug_assert_eq!(workspace_id, _workspace_id);
|
|
debug_assert_eq!(
|
|
worktree_root_path == String::default(),
|
|
relative_worktree_path == String::default()
|
|
);
|
|
|
|
let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else {
|
|
continue;
|
|
};
|
|
if worktree_root_path != String::default()
|
|
&& relative_worktree_path != String::default()
|
|
{
|
|
ToolchainScope::Subproject(
|
|
Arc::from(worktree_root_path.as_ref()),
|
|
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_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
|
|
let (workspace_id, worktree_root_path, relative_worktree_path) = match scope {
|
|
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())),
|
|
ToolchainScope::Project => (Some(workspace.id), None, None),
|
|
ToolchainScope::Global => (None, None, None),
|
|
};
|
|
let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear out old workspaces with the same paths.
|
|
// Skip this for empty workspaces - they are identified by workspace_id, not paths.
|
|
// Multiple empty workspaces with different content should coexist.
|
|
if !paths.paths.is_empty() {
|
|
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 identity = remote_connection_identity(&options);
|
|
let kind;
|
|
let user: Option<String>;
|
|
let mut host = None;
|
|
let mut port = None;
|
|
let mut distro = None;
|
|
let mut name = None;
|
|
let mut container_id = None;
|
|
let mut use_podman = None;
|
|
let mut remote_env = None;
|
|
|
|
match identity {
|
|
RemoteConnectionIdentity::Ssh {
|
|
host: identity_host,
|
|
username,
|
|
port: identity_port,
|
|
} => {
|
|
kind = RemoteConnectionKind::Ssh;
|
|
host = Some(identity_host);
|
|
port = identity_port;
|
|
user = username;
|
|
}
|
|
RemoteConnectionIdentity::Wsl {
|
|
distro_name,
|
|
user: identity_user,
|
|
} => {
|
|
kind = RemoteConnectionKind::Wsl;
|
|
distro = Some(distro_name);
|
|
user = identity_user;
|
|
}
|
|
RemoteConnectionIdentity::Docker {
|
|
container_id: identity_container_id,
|
|
name: identity_name,
|
|
remote_user,
|
|
} => {
|
|
kind = RemoteConnectionKind::Docker;
|
|
container_id = Some(identity_container_id);
|
|
name = Some(identity_name);
|
|
user = Some(remote_user);
|
|
}
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionIdentity::Mock { id } => {
|
|
kind = RemoteConnectionKind::Ssh;
|
|
host = Some(format!("mock-{}", id));
|
|
user = Some(format!("mock-user-{}", id));
|
|
}
|
|
}
|
|
|
|
if let RemoteConnectionOptions::Docker(options) = options {
|
|
use_podman = Some(options.use_podman);
|
|
remote_env = serde_json::to_string(&options.remote_env).ok();
|
|
}
|
|
|
|
Self::get_or_create_remote_connection_query(
|
|
this,
|
|
kind,
|
|
host,
|
|
port,
|
|
user,
|
|
distro,
|
|
name,
|
|
container_id,
|
|
use_podman,
|
|
remote_env,
|
|
)
|
|
}
|
|
|
|
fn get_or_create_remote_connection_query(
|
|
this: &Connection,
|
|
kind: RemoteConnectionKind,
|
|
host: Option<String>,
|
|
port: Option<u16>,
|
|
user: Option<String>,
|
|
distro: Option<String>,
|
|
name: Option<String>,
|
|
container_id: Option<String>,
|
|
use_podman: Option<bool>,
|
|
remote_env: 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 ? AND
|
|
name IS ? AND
|
|
container_id IS ?
|
|
LIMIT 1
|
|
))?((
|
|
kind.serialize(),
|
|
host.clone(),
|
|
port,
|
|
user.clone(),
|
|
distro.clone(),
|
|
name.clone(),
|
|
container_id.clone(),
|
|
))? {
|
|
Ok(RemoteConnectionId(id))
|
|
} else {
|
|
let id = this.select_row_bound(sql!(
|
|
INSERT INTO remote_connections (
|
|
kind,
|
|
host,
|
|
port,
|
|
user,
|
|
distro,
|
|
name,
|
|
container_id,
|
|
use_podman,
|
|
remote_env
|
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
RETURNING id
|
|
))?((
|
|
kind.serialize(),
|
|
host,
|
|
port,
|
|
user,
|
|
distro,
|
|
name,
|
|
container_id,
|
|
use_podman,
|
|
remote_env,
|
|
))?
|
|
.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>,
|
|
DateTime<Utc>,
|
|
)>,
|
|
> {
|
|
Ok(self
|
|
.recent_workspaces_query()?
|
|
.into_iter()
|
|
.map(|(id, paths, order, remote_connection_id, timestamp)| {
|
|
(
|
|
id,
|
|
PathList::deserialize(&SerializedPathList { paths, order }),
|
|
remote_connection_id.map(RemoteConnectionId),
|
|
parse_timestamp(×tamp),
|
|
)
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
query! {
|
|
fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, String)>> {
|
|
SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp
|
|
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<(
|
|
WorkspaceId,
|
|
PathList,
|
|
Option<u64>,
|
|
Option<RemoteConnectionId>,
|
|
)>,
|
|
> {
|
|
Ok(self
|
|
.session_workspaces_query(session_id)?
|
|
.into_iter()
|
|
.map(
|
|
|(workspace_id, paths, order, window_id, remote_connection_id)| {
|
|
(
|
|
WorkspaceId(workspace_id),
|
|
PathList::deserialize(&SerializedPathList { paths, order }),
|
|
window_id,
|
|
remote_connection_id.map(RemoteConnectionId),
|
|
)
|
|
},
|
|
)
|
|
.collect())
|
|
}
|
|
|
|
query! {
|
|
fn session_workspaces_query(session_id: String) -> Result<Vec<(i64, String, String, Option<u64>, Option<u64>)>> {
|
|
SELECT workspace_id, 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, container_id, name, use_podman, remote_env
|
|
FROM
|
|
remote_connections
|
|
))?()?
|
|
.into_iter()
|
|
.filter_map(
|
|
|(id, kind, host, port, user, distro, container_id, name, use_podman, remote_env)| {
|
|
Some((
|
|
RemoteConnectionId(id),
|
|
Self::remote_connection_from_row(
|
|
kind,
|
|
host,
|
|
port,
|
|
user,
|
|
distro,
|
|
container_id,
|
|
name,
|
|
use_podman,
|
|
remote_env,
|
|
)?,
|
|
))
|
|
},
|
|
)
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn remote_connection(
|
|
&self,
|
|
id: RemoteConnectionId,
|
|
) -> Result<RemoteConnectionOptions> {
|
|
let (kind, host, port, user, distro, container_id, name, use_podman, remote_env) =
|
|
self.select_row_bound(sql!(
|
|
SELECT kind, host, port, user, distro, container_id, name, use_podman, remote_env
|
|
FROM remote_connections
|
|
WHERE id = ?
|
|
))?(id.0)?
|
|
.context("no such remote connection")?;
|
|
Self::remote_connection_from_row(
|
|
kind,
|
|
host,
|
|
port,
|
|
user,
|
|
distro,
|
|
container_id,
|
|
name,
|
|
use_podman,
|
|
remote_env,
|
|
)
|
|
.context("invalid remote_connection row")
|
|
}
|
|
|
|
fn remote_connection_from_row(
|
|
kind: String,
|
|
host: Option<String>,
|
|
port: Option<u16>,
|
|
user: Option<String>,
|
|
distro: Option<String>,
|
|
container_id: Option<String>,
|
|
name: Option<String>,
|
|
use_podman: Option<bool>,
|
|
remote_env: 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?.into(),
|
|
port,
|
|
username: user,
|
|
..Default::default()
|
|
})),
|
|
RemoteConnectionKind::Docker => {
|
|
let remote_env: BTreeMap<String, String> =
|
|
serde_json::from_str(&remote_env?).ok()?;
|
|
Some(RemoteConnectionOptions::Docker(DockerConnectionOptions {
|
|
container_id: container_id?,
|
|
name: name?,
|
|
remote_user: user?,
|
|
upload_binary_over_docker_exec: false,
|
|
use_podman: use_podman?,
|
|
remote_env,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
|
|
DELETE FROM workspaces
|
|
WHERE workspace_id IS ?
|
|
}
|
|
}
|
|
|
|
async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
|
|
let mut any_dir = false;
|
|
for path in paths {
|
|
match fs.metadata(path).await.ok().flatten() {
|
|
None => {
|
|
return false;
|
|
}
|
|
Some(meta) => {
|
|
if meta.is_dir {
|
|
any_dir = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
any_dir
|
|
}
|
|
|
|
// 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,
|
|
fs: &dyn Fs,
|
|
) -> Result<
|
|
Vec<(
|
|
WorkspaceId,
|
|
SerializedWorkspaceLocation,
|
|
PathList,
|
|
DateTime<Utc>,
|
|
)>,
|
|
> {
|
|
let mut result = Vec::new();
|
|
let mut workspaces_to_delete = Vec::new();
|
|
let remote_connections = self.remote_connections()?;
|
|
let now = Utc::now();
|
|
for (id, paths, remote_connection_id, timestamp) 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,
|
|
timestamp,
|
|
));
|
|
} else {
|
|
workspaces_to_delete.push(id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Delete the workspace if any of the paths are WSL paths. If a
|
|
// local workspace points to WSL, attempting to read its metadata
|
|
// will wait for the WSL VM and file server to boot up. This can
|
|
// block for many seconds. Supported scenarios use remote
|
|
// workspaces.
|
|
if cfg!(windows) {
|
|
let has_wsl_path = paths
|
|
.paths()
|
|
.iter()
|
|
.any(|path| util::paths::WslPath::from_path(path).is_some());
|
|
if has_wsl_path {
|
|
workspaces_to_delete.push(id);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
|
|
result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
|
|
} else if now - timestamp >= chrono::Duration::days(7) {
|
|
workspaces_to_delete.push(id);
|
|
}
|
|
}
|
|
|
|
futures::future::join_all(
|
|
workspaces_to_delete
|
|
.into_iter()
|
|
.map(|id| self.delete_workspace_by_id(id)),
|
|
)
|
|
.await;
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn last_workspace(
|
|
&self,
|
|
fs: &dyn Fs,
|
|
) -> Result<
|
|
Option<(
|
|
WorkspaceId,
|
|
SerializedWorkspaceLocation,
|
|
PathList,
|
|
DateTime<Utc>,
|
|
)>,
|
|
> {
|
|
Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
|
|
}
|
|
|
|
// 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 async fn last_session_workspace_locations(
|
|
&self,
|
|
last_session_id: &str,
|
|
last_session_window_stack: Option<Vec<WindowId>>,
|
|
fs: &dyn Fs,
|
|
) -> Result<Vec<SessionWorkspace>> {
|
|
let mut workspaces = Vec::new();
|
|
|
|
for (workspace_id, paths, window_id, remote_connection_id) in
|
|
self.session_workspaces(last_session_id.to_owned())?
|
|
{
|
|
let window_id = window_id.map(WindowId::from);
|
|
|
|
if let Some(remote_connection_id) = remote_connection_id {
|
|
workspaces.push(SessionWorkspace {
|
|
workspace_id,
|
|
location: SerializedWorkspaceLocation::Remote(
|
|
self.remote_connection(remote_connection_id)?,
|
|
),
|
|
paths,
|
|
window_id,
|
|
});
|
|
} else if paths.is_empty() {
|
|
// Empty workspace with items (drafts, files) - include for restoration
|
|
workspaces.push(SessionWorkspace {
|
|
workspace_id,
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths,
|
|
window_id,
|
|
});
|
|
} else {
|
|
if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
|
|
workspaces.push(SessionWorkspace {
|
|
workspace_id,
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths,
|
|
window_id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(stack) = last_session_window_stack {
|
|
workspaces.sort_by_key(|workspace| {
|
|
workspace
|
|
.window_id
|
|
.and_then(|id| stack.iter().position(|&order_id| order_id == id))
|
|
.unwrap_or(usize::MAX)
|
|
});
|
|
}
|
|
|
|
Ok(workspaces)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub(crate) async fn set_session_binding(workspace_id: WorkspaceId, session_id: Option<String>, window_id: Option<u64>) -> Result<()> {
|
|
UPDATE workspaces
|
|
SET session_id = ?2, window_id = ?3
|
|
WHERE workspace_id = ?1
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn toolchains(
|
|
&self,
|
|
workspace_id: WorkspaceId,
|
|
) -> Result<Vec<(Toolchain, Arc<Path>, Arc<RelPath>)>> {
|
|
self.write(move |this| {
|
|
let mut select = this
|
|
.select_bound(sql!(
|
|
SELECT
|
|
name, path, worktree_root_path, relative_worktree_path, language_name, raw_json
|
|
FROM toolchains
|
|
WHERE workspace_id = ?
|
|
))
|
|
.context("select toolchains")?;
|
|
|
|
let toolchain: Vec<(String, String, String, String, String, String)> =
|
|
select(workspace_id)?;
|
|
|
|
Ok(toolchain
|
|
.into_iter()
|
|
.filter_map(
|
|
|(name, path, worktree_root_path, 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()?,
|
|
},
|
|
Arc::from(worktree_root_path.as_ref()),
|
|
RelPath::from_proto(&relative_worktree_path).log_err()?,
|
|
))
|
|
},
|
|
)
|
|
.collect())
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn set_toolchain(
|
|
&self,
|
|
workspace_id: WorkspaceId,
|
|
worktree_root_path: Arc<Path>,
|
|
relative_worktree_path: Arc<RelPath>,
|
|
toolchain: Toolchain,
|
|
) -> Result<()> {
|
|
log::debug!(
|
|
"Setting toolchain for workspace, worktree: {worktree_root_path:?}, 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_root_path, 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_root_path.to_string_lossy().into_owned(),
|
|
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(crate) async fn save_trusted_worktrees(
|
|
&self,
|
|
trusted_worktrees: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>>,
|
|
) -> anyhow::Result<()> {
|
|
use anyhow::Context as _;
|
|
use db::sqlez::statement::Statement;
|
|
use itertools::Itertools as _;
|
|
|
|
self.clear_trusted_worktrees()
|
|
.await
|
|
.context("clearing previous trust state")?;
|
|
|
|
let trusted_worktrees = trusted_worktrees
|
|
.into_iter()
|
|
.flat_map(|(host, abs_paths)| {
|
|
abs_paths
|
|
.into_iter()
|
|
.map(move |abs_path| (Some(abs_path), host.clone()))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut first_worktree;
|
|
let mut last_worktree = 0_usize;
|
|
for (count, placeholders) in std::iter::once("(?, ?, ?)")
|
|
.cycle()
|
|
.take(trusted_worktrees.len())
|
|
.chunks(MAX_QUERY_PLACEHOLDERS / 3)
|
|
.into_iter()
|
|
.map(|chunk| {
|
|
let mut count = 0;
|
|
let placeholders = chunk
|
|
.inspect(|_| {
|
|
count += 1;
|
|
})
|
|
.join(", ");
|
|
(count, placeholders)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
{
|
|
first_worktree = last_worktree;
|
|
last_worktree = last_worktree + count;
|
|
let query = format!(
|
|
r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name)
|
|
VALUES {placeholders};"#
|
|
);
|
|
|
|
let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec();
|
|
self.write(move |conn| {
|
|
let mut statement = Statement::prepare(conn, query)?;
|
|
let mut next_index = 1;
|
|
for (abs_path, host) in trusted_worktrees {
|
|
let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy());
|
|
next_index = statement.bind(
|
|
&abs_path.as_ref().map(|abs_path| abs_path.as_ref()),
|
|
next_index,
|
|
)?;
|
|
next_index = statement.bind(
|
|
&host
|
|
.as_ref()
|
|
.and_then(|host| Some(host.user_name.as_ref()?.as_str())),
|
|
next_index,
|
|
)?;
|
|
next_index = statement.bind(
|
|
&host.as_ref().map(|host| host.host_identifier.as_str()),
|
|
next_index,
|
|
)?;
|
|
}
|
|
statement.exec()
|
|
})
|
|
.await
|
|
.context("inserting new trusted state")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn fetch_trusted_worktrees(&self) -> Result<DbTrustedPaths> {
|
|
let trusted_worktrees = self.trusted_worktrees()?;
|
|
Ok(trusted_worktrees
|
|
.into_iter()
|
|
.filter_map(|(abs_path, user_name, host_name)| {
|
|
let db_host = match (user_name, host_name) {
|
|
(None, Some(host_name)) => Some(RemoteHostLocation {
|
|
user_name: None,
|
|
host_identifier: SharedString::new(host_name),
|
|
}),
|
|
(Some(user_name), Some(host_name)) => Some(RemoteHostLocation {
|
|
user_name: Some(SharedString::new(user_name)),
|
|
host_identifier: SharedString::new(host_name),
|
|
}),
|
|
_ => None,
|
|
};
|
|
Some((db_host, abs_path?))
|
|
})
|
|
.fold(HashMap::default(), |mut acc, (remote_host, abs_path)| {
|
|
acc.entry(remote_host)
|
|
.or_insert_with(HashSet::default)
|
|
.insert(abs_path);
|
|
acc
|
|
}))
|
|
}
|
|
|
|
query! {
|
|
fn trusted_worktrees() -> Result<Vec<(Option<PathBuf>, Option<String>, Option<String>)>> {
|
|
SELECT absolute_path, user_name, host_name
|
|
FROM trusted_worktrees
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub async fn clear_trusted_worktrees() -> Result<()> {
|
|
DELETE FROM trusted_worktrees
|
|
}
|
|
}
|
|
}
|
|
|
|
type WorkspaceEntry = (
|
|
WorkspaceId,
|
|
SerializedWorkspaceLocation,
|
|
PathList,
|
|
DateTime<Utc>,
|
|
);
|
|
|
|
/// Resolves workspace entries whose paths are git linked worktree checkouts
|
|
/// to their main repository paths.
|
|
///
|
|
/// For each workspace entry:
|
|
/// - If any path is a linked worktree checkout, all worktree paths in that
|
|
/// entry are resolved to their main repository paths, producing a new
|
|
/// `PathList`.
|
|
/// - The resolved entry is then deduplicated against existing entries: if a
|
|
/// workspace with the same paths already exists, the entry with the most
|
|
/// recent timestamp is kept.
|
|
pub async fn resolve_worktree_workspaces(
|
|
workspaces: impl IntoIterator<Item = WorkspaceEntry>,
|
|
fs: &dyn Fs,
|
|
) -> Vec<WorkspaceEntry> {
|
|
// First pass: resolve worktree paths to main repo paths concurrently.
|
|
let resolved = futures::future::join_all(workspaces.into_iter().map(|entry| async move {
|
|
let paths = entry.2.paths();
|
|
if paths.is_empty() {
|
|
return entry;
|
|
}
|
|
|
|
// Resolve each path concurrently
|
|
let resolved_paths = futures::future::join_all(
|
|
paths
|
|
.iter()
|
|
.map(|path| project::git_store::resolve_git_worktree_to_main_repo(fs, path)),
|
|
)
|
|
.await;
|
|
|
|
// If no paths were resolved, this entry is not a worktree — keep as-is
|
|
if resolved_paths.iter().all(|r| r.is_none()) {
|
|
return entry;
|
|
}
|
|
|
|
// Build new path list, substituting resolved paths
|
|
let new_paths: Vec<PathBuf> = paths
|
|
.iter()
|
|
.zip(resolved_paths.iter())
|
|
.map(|(original, resolved)| {
|
|
resolved
|
|
.as_ref()
|
|
.cloned()
|
|
.unwrap_or_else(|| original.clone())
|
|
})
|
|
.collect();
|
|
|
|
let new_path_refs: Vec<&Path> = new_paths.iter().map(|p| p.as_path()).collect();
|
|
(entry.0, entry.1, PathList::new(&new_path_refs), entry.3)
|
|
}))
|
|
.await;
|
|
|
|
// Second pass: deduplicate by PathList.
|
|
// When two entries resolve to the same paths, keep the one with the
|
|
// more recent timestamp.
|
|
let mut seen: collections::HashMap<Vec<PathBuf>, usize> = collections::HashMap::default();
|
|
let mut result: Vec<WorkspaceEntry> = Vec::new();
|
|
|
|
for entry in resolved {
|
|
let key: Vec<PathBuf> = entry.2.paths().to_vec();
|
|
if let Some(&existing_idx) = seen.get(&key) {
|
|
// Keep the entry with the more recent timestamp
|
|
if entry.3 > result[existing_idx].3 {
|
|
result[existing_idx] = entry;
|
|
}
|
|
} else {
|
|
seen.insert(key, result.len());
|
|
result.push(entry);
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
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::OpenMode;
|
|
use crate::PathList;
|
|
use crate::ProjectGroupKey;
|
|
use crate::{
|
|
multi_workspace::MultiWorkspace,
|
|
persistence::{
|
|
model::{
|
|
SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
|
|
SessionWorkspace,
|
|
},
|
|
read_multi_workspace_state,
|
|
},
|
|
};
|
|
|
|
use gpui::AppContext as _;
|
|
use pretty_assertions::assert_eq;
|
|
use project::Project;
|
|
use remote::SshConnectionOptions;
|
|
use serde_json::json;
|
|
use std::{thread, time::Duration};
|
|
|
|
/// Creates a unique directory in a FakeFs, returning the path.
|
|
/// Uses a UUID suffix to avoid collisions with other tests sharing the global DB.
|
|
async fn unique_test_dir(fs: &fs::FakeFs, prefix: &str) -> PathBuf {
|
|
let dir = PathBuf::from(format!("/test-dirs/{}-{}", prefix, uuid::Uuid::new_v4()));
|
|
fs.insert_tree(&dir, json!({})).await;
|
|
dir
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_multi_workspace_serializes_on_add_and_remove(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let project1 = Project::test(fs.clone(), [], cx).await;
|
|
let project2 = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| {
|
|
mw.open_sidebar(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.set_random_database_id(cx);
|
|
});
|
|
|
|
let window_id =
|
|
multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
|
|
|
|
// --- Add a second workspace ---
|
|
let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
|
|
workspace.update(cx, |ws, _cx| ws.set_random_database_id());
|
|
mw.activate(workspace.clone(), window, cx);
|
|
workspace
|
|
});
|
|
|
|
// Run background tasks so serialize has a chance to flush.
|
|
cx.run_until_parked();
|
|
|
|
// Read back the persisted state and check that the active workspace ID was written.
|
|
let state_after_add = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
|
|
let active_workspace2_db_id = workspace2.read_with(cx, |ws, _| ws.database_id());
|
|
assert_eq!(
|
|
state_after_add.active_workspace_id, active_workspace2_db_id,
|
|
"After adding a second workspace, the serialized active_workspace_id should match \
|
|
the newly activated workspace's database id"
|
|
);
|
|
|
|
// --- Remove the non-active workspace ---
|
|
multi_workspace.update_in(cx, |mw, _window, cx| {
|
|
let active = mw.workspace().clone();
|
|
let ws = mw
|
|
.workspaces()
|
|
.find(|ws| *ws != &active)
|
|
.expect("should have a non-active workspace");
|
|
mw.remove([ws.clone()], |_, _, _| unreachable!(), _window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
let state_after_remove = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
|
|
let remaining_db_id =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
|
|
assert_eq!(
|
|
state_after_remove.active_workspace_id, remaining_db_id,
|
|
"After removing a workspace, the serialized active_workspace_id should match \
|
|
the remaining active workspace's database id"
|
|
);
|
|
}
|
|
|
|
#[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;
|
|
)],
|
|
&mut |_, _, _| 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;)],
|
|
&mut |_, _, _| 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".into(),
|
|
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, WorkspaceId(2));
|
|
assert_eq!(locations[0].1, PathList::new(&["/tmp2"]));
|
|
assert_eq!(locations[0].2, Some(20));
|
|
assert_eq!(locations[1].0, WorkspaceId(1));
|
|
assert_eq!(locations[1].1, PathList::new(&["/tmp1"]));
|
|
assert_eq!(locations[1].2, Some(10));
|
|
|
|
let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
|
|
assert_eq!(locations.len(), 2);
|
|
assert_eq!(locations[0].0, WorkspaceId(5));
|
|
assert_eq!(locations[0].1, PathList::default());
|
|
assert_eq!(locations[0].2, Some(50));
|
|
assert_eq!(locations[0].3, Some(connection_id));
|
|
assert_eq!(locations[1].0, WorkspaceId(3));
|
|
assert_eq!(locations[1].1, PathList::new(&["/tmp3"]));
|
|
assert_eq!(locations[1].2, Some(30));
|
|
|
|
let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
|
|
assert_eq!(locations.len(), 1);
|
|
assert_eq!(locations[0].0, WorkspaceId(6));
|
|
assert_eq!(
|
|
locations[0].1,
|
|
PathList::new(&["/tmp6c", "/tmp6b", "/tmp6a"]),
|
|
);
|
|
assert_eq!(locations[0].2, 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(cx: &mut gpui::TestAppContext) {
|
|
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 fs = fs::FakeFs::new(cx.executor());
|
|
fs.insert_tree(dir1.path(), json!({})).await;
|
|
fs.insert_tree(dir2.path(), json!({})).await;
|
|
fs.insert_tree(dir3.path(), json!({})).await;
|
|
fs.insert_tree(dir4.path(), json!({})).await;
|
|
|
|
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, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
locations,
|
|
[
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(4),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir4.path()]),
|
|
window_id: Some(WindowId::from(2u64)),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(3),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir3.path()]),
|
|
window_id: Some(WindowId::from(8u64)),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(2),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir2.path()]),
|
|
window_id: Some(WindowId::from(5u64)),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(1),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir1.path()]),
|
|
window_id: Some(WindowId::from(9u64)),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(5),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
|
|
window_id: Some(WindowId::from(3u64)),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(6),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
|
|
window_id: Some(WindowId::from(4u64)),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
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.into(),
|
|
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, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(have.len(), 4);
|
|
assert_eq!(
|
|
have[0],
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(4),
|
|
location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
|
|
paths: PathList::default(),
|
|
window_id: Some(WindowId::from(2u64)),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
have[1],
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(3),
|
|
location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
|
|
paths: PathList::default(),
|
|
window_id: Some(WindowId::from(8u64)),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
have[2],
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(2),
|
|
location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
|
|
paths: PathList::default(),
|
|
window_id: Some(WindowId::from(5u64)),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
have[3],
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(1),
|
|
location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
|
|
paths: PathList::default(),
|
|
window_id: Some(WindowId::from(9u64)),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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().into(),
|
|
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().into(),
|
|
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().into(),
|
|
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().into(),
|
|
port,
|
|
username: None,
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
let same_connection_id = db
|
|
.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
|
|
host: host.clone().into(),
|
|
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().into(),
|
|
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"], ¢er_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, ¢er_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);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_empty_workspace_window_bounds() {
|
|
zlog::init_test();
|
|
|
|
let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
|
|
let id = db.next_id().await.unwrap();
|
|
|
|
// Create a workspace with empty paths (empty workspace)
|
|
let empty_paths: &[&str] = &[];
|
|
let display_uuid = Uuid::new_v4();
|
|
let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
|
|
origin: point(px(100.0), px(200.0)),
|
|
size: size(px(800.0), px(600.0)),
|
|
}));
|
|
|
|
let workspace = SerializedWorkspace {
|
|
id,
|
|
paths: PathList::new(empty_paths),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: None,
|
|
display: None,
|
|
docks: Default::default(),
|
|
breakpoints: Default::default(),
|
|
centered_layout: false,
|
|
session_id: None,
|
|
window_id: None,
|
|
user_toolchains: Default::default(),
|
|
};
|
|
|
|
// Save the workspace (this creates the record with empty paths)
|
|
db.save_workspace(workspace.clone()).await;
|
|
|
|
// Save window bounds separately (as the actual code does via set_window_open_status)
|
|
db.set_window_open_status(id, window_bounds, display_uuid)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Empty workspaces cannot be retrieved by paths (they'd all match).
|
|
// They must be retrieved by workspace_id.
|
|
assert!(db.workspace_for_roots(empty_paths).is_none());
|
|
|
|
// Retrieve using workspace_for_id instead
|
|
let retrieved = db.workspace_for_id(id).unwrap();
|
|
|
|
// Verify window bounds were persisted
|
|
assert_eq!(retrieved.id, id);
|
|
assert!(retrieved.window_bounds.is_some());
|
|
assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
|
|
assert!(retrieved.display.is_some());
|
|
assert_eq!(retrieved.display.unwrap(), display_uuid);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_last_session_workspace_locations_groups_by_window_id(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
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 dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
fs.insert_tree(dir1.path(), json!({})).await;
|
|
fs.insert_tree(dir2.path(), json!({})).await;
|
|
fs.insert_tree(dir3.path(), json!({})).await;
|
|
fs.insert_tree(dir4.path(), json!({})).await;
|
|
fs.insert_tree(dir5.path(), json!({})).await;
|
|
|
|
let db =
|
|
WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
|
|
.await;
|
|
|
|
// Simulate two MultiWorkspace windows each containing two workspaces,
|
|
// plus one single-workspace window:
|
|
// Window 10: workspace 1, workspace 2
|
|
// Window 20: workspace 3, workspace 4
|
|
// Window 30: workspace 5 (only one)
|
|
//
|
|
// On session restore, the caller should be able to group these by
|
|
// window_id to reconstruct the MultiWorkspace windows.
|
|
let workspaces_data: Vec<(i64, &Path, u64)> = vec![
|
|
(1, dir1.path(), 10),
|
|
(2, dir2.path(), 10),
|
|
(3, dir3.path(), 20),
|
|
(4, dir4.path(), 20),
|
|
(5, dir5.path(), 30),
|
|
];
|
|
|
|
for (id, dir, window_id) in &workspaces_data {
|
|
db.save_workspace(SerializedWorkspace {
|
|
id: WorkspaceId(*id),
|
|
paths: PathList::new(&[*dir]),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: Default::default(),
|
|
display: Default::default(),
|
|
docks: Default::default(),
|
|
centered_layout: false,
|
|
session_id: Some("test-session".to_owned()),
|
|
breakpoints: Default::default(),
|
|
window_id: Some(*window_id),
|
|
user_toolchains: Default::default(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let locations = db
|
|
.last_session_workspace_locations("test-session", None, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
|
|
// All 5 workspaces should be returned with their window_ids.
|
|
assert_eq!(locations.len(), 5);
|
|
|
|
// Every entry should have a window_id so the caller can group them.
|
|
for session_workspace in &locations {
|
|
assert!(
|
|
session_workspace.window_id.is_some(),
|
|
"workspace {:?} missing window_id",
|
|
session_workspace.workspace_id
|
|
);
|
|
}
|
|
|
|
// Group by window_id, simulating what the restoration code should do.
|
|
let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
|
|
for session_workspace in &locations {
|
|
if let Some(window_id) = session_workspace.window_id {
|
|
by_window
|
|
.entry(window_id)
|
|
.or_default()
|
|
.push(session_workspace.workspace_id);
|
|
}
|
|
}
|
|
|
|
// Should produce 3 windows, not 5.
|
|
assert_eq!(
|
|
by_window.len(),
|
|
3,
|
|
"Expected 3 window groups, got {}: {:?}",
|
|
by_window.len(),
|
|
by_window
|
|
);
|
|
|
|
// Window 10 should contain workspaces 1 and 2.
|
|
let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
|
|
assert_eq!(window_10.len(), 2);
|
|
assert!(window_10.contains(&WorkspaceId(1)));
|
|
assert!(window_10.contains(&WorkspaceId(2)));
|
|
|
|
// Window 20 should contain workspaces 3 and 4.
|
|
let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
|
|
assert_eq!(window_20.len(), 2);
|
|
assert!(window_20.contains(&WorkspaceId(3)));
|
|
assert!(window_20.contains(&WorkspaceId(4)));
|
|
|
|
// Window 30 should contain only workspace 5.
|
|
let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
|
|
assert_eq!(window_30.len(), 1);
|
|
assert!(window_30.contains(&WorkspaceId(5)));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_serialized_multi_workspaces_with_state(cx: &mut gpui::TestAppContext) {
|
|
use crate::persistence::model::MultiWorkspaceState;
|
|
|
|
// Write multi-workspace state for two windows via the scoped KVP.
|
|
let window_10 = WindowId::from(10u64);
|
|
let window_20 = WindowId::from(20u64);
|
|
|
|
let kvp = cx.update(|cx| KeyValueStore::global(cx));
|
|
|
|
write_multi_workspace_state(
|
|
&kvp,
|
|
window_10,
|
|
MultiWorkspaceState {
|
|
active_workspace_id: Some(WorkspaceId(2)),
|
|
project_groups: vec![],
|
|
sidebar_open: true,
|
|
sidebar_state: None,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
write_multi_workspace_state(
|
|
&kvp,
|
|
window_20,
|
|
MultiWorkspaceState {
|
|
active_workspace_id: Some(WorkspaceId(3)),
|
|
project_groups: vec![],
|
|
sidebar_open: false,
|
|
sidebar_state: None,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
// Build session workspaces: two in window 10, one in window 20, one with no window.
|
|
let session_workspaces = vec![
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(1),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&["/a"]),
|
|
window_id: Some(window_10),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(2),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&["/b"]),
|
|
window_id: Some(window_10),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(3),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&["/c"]),
|
|
window_id: Some(window_20),
|
|
},
|
|
SessionWorkspace {
|
|
workspace_id: WorkspaceId(4),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
paths: PathList::new(&["/d"]),
|
|
window_id: None,
|
|
},
|
|
];
|
|
|
|
let results = cx.update(|cx| read_serialized_multi_workspaces(session_workspaces, cx));
|
|
|
|
// Should produce 3 results: window 10, window 20, and the orphan.
|
|
assert_eq!(results.len(), 3);
|
|
|
|
// Window 10: active_workspace_id = 2 picks workspace 2 (paths /b), sidebar open.
|
|
let group_10 = &results[0];
|
|
assert_eq!(group_10.active_workspace.workspace_id, WorkspaceId(2));
|
|
assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
|
|
assert_eq!(group_10.state.sidebar_open, true);
|
|
|
|
// Window 20: active_workspace_id = 3 picks workspace 3 (paths /c), sidebar closed.
|
|
let group_20 = &results[1];
|
|
assert_eq!(group_20.active_workspace.workspace_id, WorkspaceId(3));
|
|
assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
|
|
assert_eq!(group_20.state.sidebar_open, false);
|
|
|
|
// Orphan: no active_workspace_id, falls back to first workspace (id 4).
|
|
let group_none = &results[2];
|
|
assert_eq!(group_none.active_workspace.workspace_id, WorkspaceId(4));
|
|
assert_eq!(group_none.state.active_workspace_id, None);
|
|
assert_eq!(group_none.state.sidebar_open, false);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_flush_serialization_completes_before_quit(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
|
|
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
|
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
|
|
// Assign a database_id so serialization will actually persist.
|
|
let workspace_id = db.next_id().await.unwrap();
|
|
workspace.update(cx, |ws, _cx| {
|
|
ws.set_database_id(workspace_id);
|
|
});
|
|
|
|
// Mutate some workspace state.
|
|
db.set_centered_layout(workspace_id, true).await.unwrap();
|
|
|
|
// Call flush_serialization and await the returned task directly
|
|
// (without run_until_parked — the point is that awaiting the task
|
|
// alone is sufficient).
|
|
let task = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.workspace()
|
|
.update(cx, |ws, cx| ws.flush_serialization(window, cx))
|
|
});
|
|
task.await;
|
|
|
|
// Read the workspace back from the DB and verify serialization happened.
|
|
let serialized = db.workspace_for_id(workspace_id);
|
|
assert!(
|
|
serialized.is_some(),
|
|
"flush_serialization should have persisted the workspace to DB"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_workspace_serialization(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
|
|
// Give the first workspace a database_id.
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.set_random_database_id(cx);
|
|
});
|
|
|
|
let window_id =
|
|
multi_workspace.update_in(cx, |_, window, _cx| window.window_handle().window_id());
|
|
|
|
// Create a new workspace via the MultiWorkspace API (triggers next_id()).
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.create_test_workspace(window, cx).detach();
|
|
});
|
|
|
|
// Let the async next_id() and re-serialization tasks complete.
|
|
cx.run_until_parked();
|
|
|
|
// The new workspace should now have a database_id.
|
|
let new_workspace_db_id =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
|
|
assert!(
|
|
new_workspace_db_id.is_some(),
|
|
"New workspace should have a database_id after run_until_parked"
|
|
);
|
|
|
|
// The multi-workspace state should record it as the active workspace.
|
|
let state = cx.update(|_, cx| read_multi_workspace_state(window_id, cx));
|
|
assert_eq!(
|
|
state.active_workspace_id, new_workspace_db_id,
|
|
"Serialized active_workspace_id should match the new workspace's database_id"
|
|
);
|
|
|
|
// The individual workspace row should exist with real data
|
|
// (not just the bare DEFAULT VALUES row from next_id).
|
|
let workspace_id = new_workspace_db_id.unwrap();
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
let serialized = db.workspace_for_id(workspace_id);
|
|
assert!(
|
|
serialized.is_some(),
|
|
"Newly created workspace should be fully serialized in the DB after database_id assignment"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remove_workspace_clears_session_binding(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir = unique_test_dir(&fs, "remove").await;
|
|
let project1 = Project::test(fs.clone(), [], cx).await;
|
|
let project2 = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| {
|
|
mw.open_sidebar(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.set_random_database_id(cx);
|
|
});
|
|
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
|
|
// Get a real DB id for workspace2 so the row actually exists.
|
|
let workspace2_db_id = db.next_id().await.unwrap();
|
|
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
|
|
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
|
ws.set_database_id(workspace2_db_id)
|
|
});
|
|
mw.add(workspace.clone(), window, cx);
|
|
});
|
|
|
|
// Save a full workspace row to the DB directly.
|
|
let session_id = format!("remove-test-session-{}", Uuid::new_v4());
|
|
db.save_workspace(SerializedWorkspace {
|
|
id: workspace2_db_id,
|
|
paths: PathList::new(&[&dir]),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: Default::default(),
|
|
display: Default::default(),
|
|
docks: Default::default(),
|
|
centered_layout: false,
|
|
session_id: Some(session_id.clone()),
|
|
breakpoints: Default::default(),
|
|
window_id: Some(99),
|
|
user_toolchains: Default::default(),
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
db.workspace_for_id(workspace2_db_id).is_some(),
|
|
"Workspace2 should exist in DB before removal"
|
|
);
|
|
|
|
// Remove workspace at index 1 (the second workspace).
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let ws = mw.workspaces().nth(1).unwrap().clone();
|
|
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
// The row should still exist so it continues to appear in recent
|
|
// projects, but the session binding should be cleared so it is not
|
|
// restored as part of any future session.
|
|
assert!(
|
|
db.workspace_for_id(workspace2_db_id).is_some(),
|
|
"Removed workspace's DB row should be preserved for recent projects"
|
|
);
|
|
|
|
let session_workspaces = db
|
|
.last_session_workspace_locations("remove-test-session", None, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
let restored_ids: Vec<WorkspaceId> = session_workspaces
|
|
.iter()
|
|
.map(|sw| sw.workspace_id)
|
|
.collect();
|
|
assert!(
|
|
!restored_ids.contains(&workspace2_db_id),
|
|
"Removed workspace should not appear in session restoration"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remove_workspace_not_restored_as_zombie(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir1 = tempfile::TempDir::with_prefix("zombie_test1").unwrap();
|
|
let dir2 = tempfile::TempDir::with_prefix("zombie_test2").unwrap();
|
|
fs.insert_tree(dir1.path(), json!({})).await;
|
|
fs.insert_tree(dir2.path(), json!({})).await;
|
|
|
|
let project1 = Project::test(fs.clone(), [], cx).await;
|
|
let project2 = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let db = cx.update(|cx| WorkspaceDb::global(cx));
|
|
|
|
// Get real DB ids so the rows actually exist.
|
|
let ws1_id = db.next_id().await.unwrap();
|
|
let ws2_id = db.next_id().await.unwrap();
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| {
|
|
mw.open_sidebar(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.workspace().update(cx, |ws, _cx| {
|
|
ws.set_database_id(ws1_id);
|
|
});
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
|
|
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
|
ws.set_database_id(ws2_id)
|
|
});
|
|
mw.add(workspace.clone(), window, cx);
|
|
});
|
|
|
|
let session_id = "test-zombie-session";
|
|
let window_id_val: u64 = 42;
|
|
|
|
db.save_workspace(SerializedWorkspace {
|
|
id: ws1_id,
|
|
paths: PathList::new(&[dir1.path()]),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: Default::default(),
|
|
display: Default::default(),
|
|
docks: Default::default(),
|
|
centered_layout: false,
|
|
session_id: Some(session_id.to_owned()),
|
|
breakpoints: Default::default(),
|
|
window_id: Some(window_id_val),
|
|
user_toolchains: Default::default(),
|
|
})
|
|
.await;
|
|
|
|
db.save_workspace(SerializedWorkspace {
|
|
id: ws2_id,
|
|
paths: PathList::new(&[dir2.path()]),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: Default::default(),
|
|
display: Default::default(),
|
|
docks: Default::default(),
|
|
centered_layout: false,
|
|
session_id: Some(session_id.to_owned()),
|
|
breakpoints: Default::default(),
|
|
window_id: Some(window_id_val),
|
|
user_toolchains: Default::default(),
|
|
})
|
|
.await;
|
|
|
|
// Remove workspace2 (index 1).
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let ws = mw.workspaces().nth(1).unwrap().clone();
|
|
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
// The removed workspace should NOT appear in session restoration.
|
|
let locations = db
|
|
.last_session_workspace_locations(session_id, None, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
|
|
let restored_ids: Vec<WorkspaceId> = locations.iter().map(|sw| sw.workspace_id).collect();
|
|
assert!(
|
|
!restored_ids.contains(&ws2_id),
|
|
"Removed workspace should not appear in session restoration list. Found: {:?}",
|
|
restored_ids
|
|
);
|
|
assert!(
|
|
restored_ids.contains(&ws1_id),
|
|
"Remaining workspace should still appear in session restoration list"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_pending_removal_tasks_drained_on_flush(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir = unique_test_dir(&fs, "pending-removal").await;
|
|
let project1 = Project::test(fs.clone(), [], cx).await;
|
|
let project2 = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let db = cx.update(|cx| WorkspaceDb::global(cx));
|
|
|
|
// Get a real DB id for workspace2 so the row actually exists.
|
|
let workspace2_db_id = db.next_id().await.unwrap();
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| {
|
|
mw.open_sidebar(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.set_random_database_id(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
|
|
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
|
ws.set_database_id(workspace2_db_id)
|
|
});
|
|
mw.add(workspace.clone(), window, cx);
|
|
});
|
|
|
|
// Save a full workspace row to the DB directly and let it settle.
|
|
let session_id = format!("pending-removal-session-{}", Uuid::new_v4());
|
|
db.save_workspace(SerializedWorkspace {
|
|
id: workspace2_db_id,
|
|
paths: PathList::new(&[&dir]),
|
|
location: SerializedWorkspaceLocation::Local,
|
|
center_group: Default::default(),
|
|
window_bounds: Default::default(),
|
|
display: Default::default(),
|
|
docks: Default::default(),
|
|
centered_layout: false,
|
|
session_id: Some(session_id.clone()),
|
|
breakpoints: Default::default(),
|
|
window_id: Some(88),
|
|
user_toolchains: Default::default(),
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// Remove workspace2 — this pushes a task to pending_removal_tasks.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let ws = mw.workspaces().nth(1).unwrap().clone();
|
|
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
|
|
// Simulate the quit handler pattern: collect flush tasks + pending
|
|
// removal tasks and await them all.
|
|
let all_tasks = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
let mut tasks: Vec<Task<()>> = mw
|
|
.workspaces()
|
|
.map(|workspace| {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.flush_serialization(window, cx)
|
|
})
|
|
})
|
|
.collect();
|
|
let mut removal_tasks = mw.take_pending_removal_tasks();
|
|
// Note: removal_tasks may be empty if the background task already
|
|
// completed (take_pending_removal_tasks filters out ready tasks).
|
|
tasks.append(&mut removal_tasks);
|
|
tasks.push(mw.flush_serialization());
|
|
tasks
|
|
});
|
|
futures::future::join_all(all_tasks).await;
|
|
|
|
// The row should still exist (for recent projects), but the session
|
|
// binding should have been cleared by the pending removal task.
|
|
assert!(
|
|
db.workspace_for_id(workspace2_db_id).is_some(),
|
|
"Workspace row should be preserved for recent projects"
|
|
);
|
|
|
|
let session_workspaces = db
|
|
.last_session_workspace_locations("pending-removal-session", None, fs.as_ref())
|
|
.await
|
|
.unwrap();
|
|
let restored_ids: Vec<WorkspaceId> = session_workspaces
|
|
.iter()
|
|
.map(|sw| sw.workspace_id)
|
|
.collect();
|
|
assert!(
|
|
!restored_ids.contains(&workspace2_db_id),
|
|
"Pending removal task should have cleared the session binding"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.set_random_database_id(cx);
|
|
});
|
|
|
|
let task =
|
|
multi_workspace.update_in(cx, |mw, window, cx| mw.create_test_workspace(window, cx));
|
|
task.await;
|
|
|
|
let new_workspace_db_id =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id());
|
|
assert!(
|
|
new_workspace_db_id.is_some(),
|
|
"After run_until_parked, the workspace should have a database_id"
|
|
);
|
|
|
|
let workspace_id = new_workspace_db_id.unwrap();
|
|
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
|
|
assert!(
|
|
db.workspace_for_id(workspace_id).is_some(),
|
|
"The workspace row should exist in the DB"
|
|
);
|
|
|
|
cx.simulate_resize(gpui::size(px(1024.0), px(768.0)));
|
|
|
|
// Advance the clock past the 100ms debounce timer so the bounds
|
|
// observer task fires
|
|
cx.executor().advance_clock(Duration::from_millis(200));
|
|
cx.run_until_parked();
|
|
|
|
let serialized = db
|
|
.workspace_for_id(workspace_id)
|
|
.expect("workspace row should still exist");
|
|
assert!(
|
|
serialized.window_bounds.is_some(),
|
|
"The bounds observer should write bounds for the workspace's real DB ID, \
|
|
even when the workspace was created via create_workspace (where the ID \
|
|
is assigned asynchronously after construction)."
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir = tempfile::TempDir::with_prefix("flush_bounds_test").unwrap();
|
|
fs.insert_tree(dir.path(), json!({})).await;
|
|
|
|
let project = Project::test(fs.clone(), [dir.path()], cx).await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
let workspace_id = db.next_id().await.unwrap();
|
|
multi_workspace.update_in(cx, |mw, _, cx| {
|
|
mw.workspace().update(cx, |ws, _cx| {
|
|
ws.set_database_id(workspace_id);
|
|
});
|
|
});
|
|
|
|
let task = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.workspace()
|
|
.update(cx, |ws, cx| ws.flush_serialization(window, cx))
|
|
});
|
|
task.await;
|
|
|
|
let after = db
|
|
.workspace_for_id(workspace_id)
|
|
.expect("workspace row should exist after flush_serialization");
|
|
assert!(
|
|
!after.paths.is_empty(),
|
|
"flush_serialization should have written paths via save_workspace"
|
|
);
|
|
assert!(
|
|
after.window_bounds.is_some(),
|
|
"flush_serialization should ensure window bounds are persisted to the DB \
|
|
before the process exits."
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_worktree_workspaces(cx: &mut gpui::TestAppContext) {
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
|
|
// Main repo with a linked worktree entry
|
|
fs.insert_tree(
|
|
"/repo",
|
|
json!({
|
|
".git": {
|
|
"worktrees": {
|
|
"feature": {
|
|
"commondir": "../../",
|
|
"HEAD": "ref: refs/heads/feature"
|
|
}
|
|
}
|
|
},
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Linked worktree checkout pointing back to /repo
|
|
fs.insert_tree(
|
|
"/worktree",
|
|
json!({
|
|
".git": "gitdir: /repo/.git/worktrees/feature",
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// A plain non-git project
|
|
fs.insert_tree(
|
|
"/plain-project",
|
|
json!({
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Another normal git repo (used in mixed-path entry)
|
|
fs.insert_tree(
|
|
"/other-repo",
|
|
json!({
|
|
".git": {},
|
|
"src": { "lib.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let t0 = Utc::now() - chrono::Duration::hours(4);
|
|
let t1 = Utc::now() - chrono::Duration::hours(3);
|
|
let t2 = Utc::now() - chrono::Duration::hours(2);
|
|
let t3 = Utc::now() - chrono::Duration::hours(1);
|
|
|
|
let workspaces = vec![
|
|
// 1: Main checkout of /repo (opened earlier)
|
|
(
|
|
WorkspaceId(1),
|
|
SerializedWorkspaceLocation::Local,
|
|
PathList::new(&["/repo"]),
|
|
t0,
|
|
),
|
|
// 2: Linked worktree of /repo (opened more recently)
|
|
// Should dedup with #1; more recent timestamp wins.
|
|
(
|
|
WorkspaceId(2),
|
|
SerializedWorkspaceLocation::Local,
|
|
PathList::new(&["/worktree"]),
|
|
t1,
|
|
),
|
|
// 3: Mixed-path workspace: one root is a linked worktree,
|
|
// the other is a normal repo. The worktree path should be
|
|
// resolved; the normal path kept as-is.
|
|
(
|
|
WorkspaceId(3),
|
|
SerializedWorkspaceLocation::Local,
|
|
PathList::new(&["/other-repo", "/worktree"]),
|
|
t2,
|
|
),
|
|
// 4: Non-git project — passed through unchanged.
|
|
(
|
|
WorkspaceId(4),
|
|
SerializedWorkspaceLocation::Local,
|
|
PathList::new(&["/plain-project"]),
|
|
t3,
|
|
),
|
|
];
|
|
|
|
let result = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
|
|
|
|
// Should have 3 entries: #1 and #2 deduped into one, plus #3 and #4.
|
|
assert_eq!(result.len(), 3);
|
|
|
|
// First entry: /repo — deduplicated from #1 and #2.
|
|
// Keeps the position of #1 (first seen), but with #2's later timestamp.
|
|
assert_eq!(result[0].2.paths(), &[PathBuf::from("/repo")]);
|
|
assert_eq!(result[0].3, t1);
|
|
|
|
// Second entry: mixed-path workspace with worktree resolved.
|
|
// /worktree → /repo, so paths become [/other-repo, /repo] (sorted).
|
|
assert_eq!(
|
|
result[1].2.paths(),
|
|
&[PathBuf::from("/other-repo"), PathBuf::from("/repo")]
|
|
);
|
|
assert_eq!(result[1].0, WorkspaceId(3));
|
|
|
|
// Third entry: non-git project, unchanged.
|
|
assert_eq!(result[2].2.paths(), &[PathBuf::from("/plain-project")]);
|
|
assert_eq!(result[2].0, WorkspaceId(4));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_resolve_worktree_workspaces_bare_repo(cx: &mut gpui::TestAppContext) {
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
|
|
// Bare repo at /foo/.bare (commondir doesn't end with .git)
|
|
fs.insert_tree(
|
|
"/foo/.bare",
|
|
json!({
|
|
"worktrees": {
|
|
"my-feature": {
|
|
"commondir": "../../",
|
|
"HEAD": "ref: refs/heads/my-feature"
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Linked worktree whose commondir resolves to a bare repo (/foo/.bare)
|
|
fs.insert_tree(
|
|
"/foo/my-feature",
|
|
json!({
|
|
".git": "gitdir: /foo/.bare/worktrees/my-feature",
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let t0 = Utc::now();
|
|
|
|
let workspaces = vec![(
|
|
WorkspaceId(1),
|
|
SerializedWorkspaceLocation::Local,
|
|
PathList::new(&["/foo/my-feature"]),
|
|
t0,
|
|
)];
|
|
|
|
let result = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
|
|
|
|
// The worktree path must be preserved unchanged — /foo/.bare is a bare repo
|
|
// and cannot serve as a working-tree root, so resolution must return None.
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].2.paths(), &[PathBuf::from("/foo/my-feature")]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_restore_window_with_linked_worktree_and_multiple_project_groups(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
|
|
// Main git repo at /repo
|
|
fs.insert_tree(
|
|
"/repo",
|
|
json!({
|
|
".git": {
|
|
"HEAD": "ref: refs/heads/main",
|
|
"worktrees": {
|
|
"feature": {
|
|
"commondir": "../../",
|
|
"HEAD": "ref: refs/heads/feature"
|
|
}
|
|
}
|
|
},
|
|
"src": { "main.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Linked worktree checkout pointing back to /repo
|
|
fs.insert_tree(
|
|
"/worktree-feature",
|
|
json!({
|
|
".git": "gitdir: /repo/.git/worktrees/feature",
|
|
"src": { "lib.rs": "" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// --- Phase 1: Set up the original multi-workspace window ---
|
|
|
|
let project_1 = Project::test(fs.clone(), ["/repo".as_ref()], cx).await;
|
|
let project_1_linked_worktree =
|
|
Project::test(fs.clone(), ["/worktree-feature".as_ref()], cx).await;
|
|
|
|
// Wait for git discovery to finish.
|
|
cx.run_until_parked();
|
|
|
|
// Create a second, unrelated project so we have two distinct project groups.
|
|
fs.insert_tree(
|
|
"/other-project",
|
|
json!({
|
|
".git": { "HEAD": "ref: refs/heads/main" },
|
|
"readme.md": ""
|
|
}),
|
|
)
|
|
.await;
|
|
let project_2 = Project::test(fs.clone(), ["/other-project".as_ref()], cx).await;
|
|
cx.run_until_parked();
|
|
|
|
// Create the MultiWorkspace with project_2, then add the main repo
|
|
// and its linked worktree. The linked worktree is added last and
|
|
// becomes the active workspace.
|
|
let (multi_workspace, cx) = cx
|
|
.add_window_view(|window, cx| MultiWorkspace::test_new(project_2.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| {
|
|
mw.open_sidebar(cx);
|
|
});
|
|
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.test_add_workspace(project_1.clone(), window, cx);
|
|
});
|
|
|
|
let workspace_worktree = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.test_add_workspace(project_1_linked_worktree.clone(), window, cx)
|
|
});
|
|
|
|
let tasks =
|
|
multi_workspace.update_in(cx, |mw, window, cx| mw.flush_all_serialization(window, cx));
|
|
cx.run_until_parked();
|
|
for task in tasks {
|
|
task.await;
|
|
}
|
|
cx.run_until_parked();
|
|
|
|
let active_db_id = workspace_worktree.read_with(cx, |ws, _| ws.database_id());
|
|
assert!(
|
|
active_db_id.is_some(),
|
|
"Active workspace should have a database ID"
|
|
);
|
|
|
|
// --- Phase 2: Read back and verify the serialized state ---
|
|
|
|
let session_id = multi_workspace
|
|
.read_with(cx, |mw, cx| mw.workspace().read(cx).session_id())
|
|
.unwrap();
|
|
let db = cx.update(|_, cx| WorkspaceDb::global(cx));
|
|
let session_workspaces = db
|
|
.last_session_workspace_locations(&session_id, None, fs.as_ref())
|
|
.await
|
|
.expect("should load session workspaces");
|
|
assert!(
|
|
!session_workspaces.is_empty(),
|
|
"Should have at least one session workspace"
|
|
);
|
|
|
|
let multi_workspaces =
|
|
cx.update(|_, cx| read_serialized_multi_workspaces(session_workspaces, cx));
|
|
assert_eq!(
|
|
multi_workspaces.len(),
|
|
1,
|
|
"All workspaces share one window, so there should be exactly one multi-workspace"
|
|
);
|
|
|
|
let serialized = &multi_workspaces[0];
|
|
assert_eq!(
|
|
serialized.active_workspace.workspace_id,
|
|
active_db_id.unwrap(),
|
|
);
|
|
assert_eq!(serialized.state.project_groups.len(), 2,);
|
|
|
|
// Verify the serialized project group keys round-trip back to the
|
|
// originals.
|
|
let restored_keys: Vec<ProjectGroupKey> = serialized
|
|
.state
|
|
.project_groups
|
|
.iter()
|
|
.cloned()
|
|
.map(Into::into)
|
|
.collect();
|
|
let expected_keys = vec![
|
|
ProjectGroupKey::new(None, PathList::new(&["/repo"])),
|
|
ProjectGroupKey::new(None, PathList::new(&["/other-project"])),
|
|
];
|
|
assert_eq!(
|
|
restored_keys, expected_keys,
|
|
"Deserialized project group keys should match the originals"
|
|
);
|
|
|
|
// --- Phase 3: Restore the window and verify the result ---
|
|
|
|
let app_state =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).app_state().clone());
|
|
|
|
let serialized_mw = multi_workspaces.into_iter().next().unwrap();
|
|
let restored_handle: gpui::WindowHandle<MultiWorkspace> = cx
|
|
.update(|_, cx| {
|
|
cx.spawn(async move |mut cx| {
|
|
crate::restore_multiworkspace(serialized_mw, app_state, &mut cx).await
|
|
})
|
|
})
|
|
.await
|
|
.expect("restore_multiworkspace should succeed");
|
|
|
|
cx.run_until_parked();
|
|
|
|
// The restored window should have the same project group keys.
|
|
let restored_keys: Vec<ProjectGroupKey> = restored_handle
|
|
.read_with(cx, |mw: &MultiWorkspace, _cx| mw.project_group_keys())
|
|
.unwrap();
|
|
assert_eq!(
|
|
restored_keys, expected_keys,
|
|
"Restored window should have the same project group keys as the original"
|
|
);
|
|
|
|
// The active workspace in the restored window should have the linked
|
|
// worktree paths.
|
|
let active_paths: Vec<PathBuf> = restored_handle
|
|
.read_with(cx, |mw: &MultiWorkspace, cx| {
|
|
mw.workspace()
|
|
.read(cx)
|
|
.root_paths(cx)
|
|
.into_iter()
|
|
.map(|p: Arc<Path>| p.to_path_buf())
|
|
.collect()
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
active_paths,
|
|
vec![PathBuf::from("/worktree-feature")],
|
|
"The restored active workspace should be the linked worktree project"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remove_project_group_falls_back_to_neighbor(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir_a = unique_test_dir(&fs, "group-a").await;
|
|
let dir_b = unique_test_dir(&fs, "group-b").await;
|
|
let dir_c = unique_test_dir(&fs, "group-c").await;
|
|
|
|
let project_a = Project::test(fs.clone(), [dir_a.as_path()], cx).await;
|
|
let project_b = Project::test(fs.clone(), [dir_b.as_path()], cx).await;
|
|
let project_c = Project::test(fs.clone(), [dir_c.as_path()], cx).await;
|
|
|
|
// Create a multi-workspace with project A, then add B and C.
|
|
// project_groups stores newest first: [C, B, A].
|
|
// Sidebar displays in the same order: C (top), B (middle), A (bottom).
|
|
let (multi_workspace, cx) = cx
|
|
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
|
|
|
|
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.test_add_workspace(project_b.clone(), window, cx)
|
|
});
|
|
let _workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.test_add_workspace(project_c.clone(), window, cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
|
|
let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
|
|
let key_c = project_c.read_with(cx, |p, cx| p.project_group_key(cx));
|
|
|
|
// Activate workspace B so removing its group exercises the fallback.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.activate(workspace_b.clone(), window, cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// --- Remove group B (the middle one). ---
|
|
// In the sidebar [C, B, A], "below" B is A.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.remove_project_group(&key_b, window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let active_paths =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
|
assert_eq!(
|
|
active_paths
|
|
.iter()
|
|
.map(|p| p.to_path_buf())
|
|
.collect::<Vec<_>>(),
|
|
vec![dir_a.clone()],
|
|
"After removing the middle group, should fall back to the group below (A)"
|
|
);
|
|
|
|
// After removing B, keys = [A, C], sidebar = [C, A].
|
|
// Activate workspace A (the bottom) so removing it tests the
|
|
// "fall back upward" path.
|
|
let workspace_a =
|
|
multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.activate(workspace_a.clone(), window, cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// --- Remove group A (the bottom one in sidebar). ---
|
|
// Nothing below A, so should fall back upward to C.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.remove_project_group(&key_a, window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let active_paths =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
|
assert_eq!(
|
|
active_paths
|
|
.iter()
|
|
.map(|p| p.to_path_buf())
|
|
.collect::<Vec<_>>(),
|
|
vec![dir_c.clone()],
|
|
"After removing the bottom group, should fall back to the group above (C)"
|
|
);
|
|
|
|
// --- Remove group C (the only one remaining). ---
|
|
// Should create an empty workspace.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.remove_project_group(&key_c, window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let active_paths =
|
|
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
|
assert!(
|
|
active_paths.is_empty(),
|
|
"After removing the only remaining group, should have an empty workspace"
|
|
);
|
|
}
|
|
|
|
/// Regression test for a crash where `find_or_create_local_workspace`
|
|
/// returned a workspace that was about to be removed, hitting an assert
|
|
/// in `MultiWorkspace::remove`.
|
|
///
|
|
/// The scenario: two workspaces share the same root paths (e.g. due to
|
|
/// a provisional key mismatch). When the first is removed and the
|
|
/// fallback searches for the same paths, `workspace_for_paths` must
|
|
/// skip the doomed workspace so the assert in `remove` is satisfied.
|
|
#[gpui::test]
|
|
async fn test_remove_fallback_skips_excluded_workspaces(cx: &mut gpui::TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let fs = fs::FakeFs::new(cx.executor());
|
|
let dir = unique_test_dir(&fs, "shared").await;
|
|
|
|
// Two projects that open the same directory — this creates two
|
|
// workspaces whose root_paths are identical.
|
|
let project_a = Project::test(fs.clone(), [dir.as_path()], cx).await;
|
|
let project_b = Project::test(fs.clone(), [dir.as_path()], cx).await;
|
|
|
|
let (multi_workspace, cx) = cx
|
|
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
|
|
|
multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
|
|
|
|
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.test_add_workspace(project_b.clone(), window, cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// workspace_a is first in the workspaces vec.
|
|
let workspace_a =
|
|
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().cloned().unwrap());
|
|
assert_ne!(workspace_a, workspace_b);
|
|
|
|
// Activate workspace_a so removing it triggers the fallback path.
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.activate(workspace_a.clone(), window, cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Remove workspace_a. The fallback searches for the same paths.
|
|
// Without the `excluding` parameter, `workspace_for_paths` would
|
|
// return workspace_a (first match) and the assert in `remove`
|
|
// would fire. With the fix, workspace_a is skipped and
|
|
// workspace_b is found instead.
|
|
let path_list = PathList::new(std::slice::from_ref(&dir));
|
|
let excluded = vec![workspace_a.clone()];
|
|
multi_workspace.update_in(cx, |mw, window, cx| {
|
|
mw.remove(
|
|
vec![workspace_a.clone()],
|
|
move |this, window, cx| {
|
|
this.find_or_create_local_workspace(
|
|
path_list,
|
|
None,
|
|
&excluded,
|
|
None,
|
|
OpenMode::Activate,
|
|
window,
|
|
cx,
|
|
)
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// workspace_b should now be active — workspace_a was removed.
|
|
multi_workspace.read_with(cx, |mw, _cx| {
|
|
assert_eq!(
|
|
mw.workspace(),
|
|
&workspace_b,
|
|
"fallback should have found workspace_b, not the excluded workspace_a"
|
|
);
|
|
});
|
|
}
|
|
}
|