This PR makes Zed only have one worktree picker, as opposed to a flavor of it in the title bar and another in the agent panel. It then moves it to the title bar, making it always present, so that its trigger is separate from the branch picker (which now contains only two views: branches and stashes). For the worktree picker, I'm mostly favoring the behavior we've introduced in the agent-panel-flavored version. It also updates the title bar settings migration to use the JSON `migrate_settings` helper instead of a shallow Tree-sitter rewrite, so old `show_branch_icon = true` values are promoted to `show_branch_status_icon = true` across root, platform, release-channel, and profile settings scopes. - [x] Move worktree creation logic to the `git_ui` crate to make this more generic and less agent-specific - [x] Double-check the remote use case and ensure nothing broke there - [x] Improve the UX for the detached HEAD state; better invite people to create a branch - [x] Migrate `show_branch_icon = true` to `show_branch_status_icon = true` across nested settings scopes Suggested .rules additions When migrating renamed settings keys that can appear in platform overrides, release-channel overrides, or profiles, prefer the JSON `migrations::migrate_settings` helper over shallow Tree-sitter key rewrites unless tests explicitly cover every nested scope that can contain the key. Release Notes: - Improved migration of the title bar branch status icon setting. --------- Co-authored-by: Nathan Sobo <nathan@zed.dev> Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
5216 lines
188 KiB
Rust
5216 lines
188 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::{
|
|
bookmark_store::SerializedBookmark,
|
|
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 Bookmark {
|
|
pub row: u32,
|
|
}
|
|
|
|
impl sqlez::bindable::StaticColumnCount for Bookmark {
|
|
fn column_count() -> usize {
|
|
// row
|
|
1
|
|
}
|
|
}
|
|
|
|
impl sqlez::bindable::Bind for Bookmark {
|
|
fn bind(
|
|
&self,
|
|
statement: &sqlez::statement::Statement,
|
|
start_index: i32,
|
|
) -> anyhow::Result<i32> {
|
|
statement.bind(&self.row, start_index)
|
|
}
|
|
}
|
|
|
|
impl Column for Bookmark {
|
|
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
|
let row = statement
|
|
.column_int(start_index)
|
|
.with_context(|| format!("Failed to read bookmark at index {start_index}"))?
|
|
as u32;
|
|
|
|
Ok((Bookmark { row }, start_index + 1))
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
),
|
|
sql!(
|
|
CREATE TABLE bookmarks (
|
|
workspace_id INTEGER NOT NULL,
|
|
path TEXT NOT NULL,
|
|
row INTEGER NOT NULL,
|
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
|
|
ON DELETE CASCADE
|
|
ON UPDATE CASCADE
|
|
);
|
|
),
|
|
];
|
|
|
|
// 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,
|
|
bookmarks: self.bookmarks(workspace_id),
|
|
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,
|
|
bookmarks: self.bookmarks(workspace_id),
|
|
breakpoints: self.breakpoints(workspace_id),
|
|
window_id,
|
|
user_toolchains: self.user_toolchains(workspace_id, remote_connection_id),
|
|
})
|
|
}
|
|
|
|
fn bookmarks(&self, workspace_id: WorkspaceId) -> BTreeMap<Arc<Path>, Vec<SerializedBookmark>> {
|
|
let bookmarks: Result<Vec<(PathBuf, Bookmark)>> = self
|
|
.select_bound(sql! {
|
|
SELECT path, row
|
|
FROM bookmarks
|
|
WHERE workspace_id = ?
|
|
ORDER BY path, row
|
|
})
|
|
.and_then(|mut prepared_statement| (prepared_statement)(workspace_id));
|
|
|
|
match bookmarks {
|
|
Ok(bookmarks) => {
|
|
if bookmarks.is_empty() {
|
|
log::debug!("Bookmarks are empty after querying database for them");
|
|
}
|
|
|
|
let mut map: BTreeMap<_, Vec<_>> = BTreeMap::default();
|
|
|
|
for (path, bookmark) in bookmarks {
|
|
let path: Arc<Path> = path.into();
|
|
map.entry(path.clone())
|
|
.or_default()
|
|
.push(SerializedBookmark(bookmark.row))
|
|
}
|
|
|
|
map
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to load bookmarks: {}", e);
|
|
BTreeMap::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 bookmarks WHERE workspace_id = ?1;
|
|
)
|
|
)?(workspace.id).context("Clearing old bookmarks")?;
|
|
|
|
for (path, bookmarks) in workspace.bookmarks {
|
|
for bookmark in bookmarks {
|
|
conn.exec_bound(sql!(
|
|
INSERT INTO bookmarks (workspace_id, path, row)
|
|
VALUES (?1, ?2, ?3);
|
|
))?((workspace.id, path.as_ref(), bookmark.0)).context("Inserting bookmark")?;
|
|
}
|
|
}
|
|
|
|
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(), None, 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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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(),
|
|
bookmarks: 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(),
|
|
bookmarks: 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,
|
|
bookmarks: Default::default(),
|
|
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(),
|
|
bookmarks: 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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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,
|
|
bookmarks: Default::default(),
|
|
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(),
|
|
bookmarks: 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(),
|
|
bookmarks: 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()),
|
|
bookmarks: Default::default(),
|
|
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()),
|
|
bookmarks: Default::default(),
|
|
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(),
|
|
bookmarks: 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()),
|
|
bookmarks: Default::default(),
|
|
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()),
|
|
bookmarks: Default::default(),
|
|
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()),
|
|
bookmarks: Default::default(),
|
|
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()),
|
|
bookmarks: Default::default(),
|
|
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()),
|
|
bookmarks: Default::default(),
|
|
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(), None, 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(), None, 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(), None, 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"
|
|
);
|
|
});
|
|
}
|
|
}
|