persistence.rs

  1use anyhow::Result;
  2use async_recursion::async_recursion;
  3use collections::HashSet;
  4use futures::future::join_all;
  5use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity};
  6use project::Project;
  7use serde::{Deserialize, Serialize};
  8use std::path::PathBuf;
  9use ui::{App, Context, Pixels, Window};
 10use util::ResultExt as _;
 11
 12use db::{
 13    query,
 14    sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection},
 15    sqlez_macros::sql,
 16};
 17use workspace::{
 18    ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace,
 19    WorkspaceDb, WorkspaceId,
 20};
 21
 22use crate::{
 23    TerminalView, default_working_directory,
 24    terminal_panel::{TerminalPanel, new_terminal_pane},
 25};
 26
 27pub(crate) fn serialize_pane_group(
 28    pane_group: &PaneGroup,
 29    active_pane: &Entity<Pane>,
 30    cx: &mut App,
 31) -> SerializedPaneGroup {
 32    build_serialized_pane_group(&pane_group.root, active_pane, cx)
 33}
 34
 35fn build_serialized_pane_group(
 36    pane_group: &Member,
 37    active_pane: &Entity<Pane>,
 38    cx: &mut App,
 39) -> SerializedPaneGroup {
 40    match pane_group {
 41        Member::Axis(PaneAxis {
 42            axis,
 43            members,
 44            flexes,
 45            bounding_boxes: _,
 46        }) => SerializedPaneGroup::Group {
 47            axis: SerializedAxis(*axis),
 48            children: members
 49                .iter()
 50                .map(|member| build_serialized_pane_group(member, active_pane, cx))
 51                .collect::<Vec<_>>(),
 52            flexes: Some(flexes.lock().clone()),
 53        },
 54        Member::Pane(pane_handle) => {
 55            SerializedPaneGroup::Pane(serialize_pane(pane_handle, pane_handle == active_pane, cx))
 56        }
 57    }
 58}
 59
 60fn serialize_pane(pane: &Entity<Pane>, active: bool, cx: &mut App) -> SerializedPane {
 61    let mut items_to_serialize = HashSet::default();
 62    let pane = pane.read(cx);
 63    let children = pane
 64        .items()
 65        .filter_map(|item| {
 66            let terminal_view = item.act_as::<TerminalView>(cx)?;
 67            if terminal_view.read(cx).terminal().read(cx).task().is_some() {
 68                None
 69            } else {
 70                let id = item.item_id().as_u64();
 71                items_to_serialize.insert(id);
 72                Some(id)
 73            }
 74        })
 75        .collect::<Vec<_>>();
 76    let active_item = pane
 77        .active_item()
 78        .map(|item| item.item_id().as_u64())
 79        .filter(|active_id| items_to_serialize.contains(active_id));
 80
 81    let pinned_count = pane.pinned_count();
 82    SerializedPane {
 83        active,
 84        children,
 85        active_item,
 86        pinned_count,
 87    }
 88}
 89
 90pub(crate) fn deserialize_terminal_panel(
 91    workspace: WeakEntity<Workspace>,
 92    project: Entity<Project>,
 93    database_id: WorkspaceId,
 94    serialized_panel: SerializedTerminalPanel,
 95    window: &mut Window,
 96    cx: &mut App,
 97) -> Task<anyhow::Result<Entity<TerminalPanel>>> {
 98    window.spawn(cx, async move |cx| {
 99        let terminal_panel = workspace.update_in(cx, |workspace, window, cx| {
100            cx.new(|cx| {
101                let mut panel = TerminalPanel::new(workspace, window, cx);
102                panel.height = serialized_panel.height.map(|h| h.round());
103                panel.width = serialized_panel.width.map(|w| w.round());
104                panel
105            })
106        })?;
107        match &serialized_panel.items {
108            SerializedItems::NoSplits(item_ids) => {
109                let items = deserialize_terminal_views(
110                    database_id,
111                    project,
112                    workspace,
113                    item_ids.as_slice(),
114                    cx,
115                )
116                .await;
117                let active_item = serialized_panel.active_item_id;
118                terminal_panel.update_in(cx, |terminal_panel, window, cx| {
119                    terminal_panel.active_pane.update(cx, |pane, cx| {
120                        populate_pane_items(pane, items, active_item, window, cx);
121                    });
122                })?;
123            }
124            SerializedItems::WithSplits(serialized_pane_group) => {
125                let center_pane = deserialize_pane_group(
126                    workspace,
127                    project,
128                    terminal_panel.clone(),
129                    database_id,
130                    serialized_pane_group,
131                    cx,
132                )
133                .await;
134                if let Some((center_group, active_pane)) = center_pane {
135                    terminal_panel.update(cx, |terminal_panel, _| {
136                        terminal_panel.center = PaneGroup::with_root(center_group);
137                        terminal_panel.active_pane =
138                            active_pane.unwrap_or_else(|| terminal_panel.center.first_pane());
139                    });
140                }
141            }
142        }
143
144        Ok(terminal_panel)
145    })
146}
147
148fn populate_pane_items(
149    pane: &mut Pane,
150    items: Vec<Entity<TerminalView>>,
151    active_item: Option<u64>,
152    window: &mut Window,
153    cx: &mut Context<Pane>,
154) {
155    let mut item_index = pane.items_len();
156    let mut active_item_index = None;
157    for item in items {
158        if Some(item.item_id().as_u64()) == active_item {
159            active_item_index = Some(item_index);
160        }
161        pane.add_item(Box::new(item), false, false, None, window, cx);
162        item_index += 1;
163    }
164    if let Some(index) = active_item_index {
165        pane.activate_item(index, false, false, window, cx);
166    }
167}
168
169#[async_recursion(?Send)]
170async fn deserialize_pane_group(
171    workspace: WeakEntity<Workspace>,
172    project: Entity<Project>,
173    panel: Entity<TerminalPanel>,
174    workspace_id: WorkspaceId,
175    serialized: &SerializedPaneGroup,
176    cx: &mut AsyncWindowContext,
177) -> Option<(Member, Option<Entity<Pane>>)> {
178    match serialized {
179        SerializedPaneGroup::Group {
180            axis,
181            flexes,
182            children,
183        } => {
184            let mut current_active_pane = None;
185            let mut members = Vec::new();
186            for child in children {
187                if let Some((new_member, active_pane)) = deserialize_pane_group(
188                    workspace.clone(),
189                    project.clone(),
190                    panel.clone(),
191                    workspace_id,
192                    child,
193                    cx,
194                )
195                .await
196                {
197                    members.push(new_member);
198                    current_active_pane = current_active_pane.or(active_pane);
199                }
200            }
201
202            if members.is_empty() {
203                return None;
204            }
205
206            if members.len() == 1 {
207                return Some((members.remove(0), current_active_pane));
208            }
209
210            Some((
211                Member::Axis(PaneAxis::load(axis.0, members, flexes.clone())),
212                current_active_pane,
213            ))
214        }
215        SerializedPaneGroup::Pane(serialized_pane) => {
216            let active = serialized_pane.active;
217
218            let pane = panel
219                .update_in(cx, |terminal_panel, window, cx| {
220                    new_terminal_pane(
221                        workspace.clone(),
222                        project.clone(),
223                        terminal_panel.active_pane.read(cx).is_zoomed(),
224                        window,
225                        cx,
226                    )
227                })
228                .log_err()?;
229            let active_item = serialized_pane.active_item;
230            let pinned_count = serialized_pane.pinned_count;
231            let new_items = deserialize_terminal_views(
232                workspace_id,
233                project.clone(),
234                workspace.clone(),
235                serialized_pane.children.as_slice(),
236                cx,
237            );
238            cx.spawn({
239                let pane = pane.downgrade();
240                async move |cx| {
241                    let new_items = new_items.await;
242
243                    let items = pane.update_in(cx, |pane, window, cx| {
244                        populate_pane_items(pane, new_items, active_item, window, cx);
245                        pane.set_pinned_count(pinned_count.min(pane.items_len()));
246                        pane.items_len()
247                    });
248                    // Avoid blank panes in splits
249                    if items.is_ok_and(|items| items == 0) {
250                        let working_directory = workspace
251                            .update(cx, |workspace, cx| default_working_directory(workspace, cx))
252                            .ok()
253                            .flatten();
254                        let terminal = project
255                            .update(cx, |project, cx| {
256                                project.create_terminal_shell(working_directory, cx)
257                            })
258                            .await
259                            .log_err();
260                        let Some(terminal) = terminal else {
261                            return;
262                        };
263                        pane.update_in(cx, |pane, window, cx| {
264                            let terminal_view = Box::new(cx.new(|cx| {
265                                TerminalView::new(
266                                    terminal,
267                                    workspace.clone(),
268                                    Some(workspace_id),
269                                    project.downgrade(),
270                                    window,
271                                    cx,
272                                )
273                            }));
274                            pane.add_item(terminal_view, true, false, None, window, cx);
275                        })
276                        .ok();
277                    }
278                }
279            })
280            .await;
281            Some((Member::Pane(pane.clone()), active.then_some(pane)))
282        }
283    }
284}
285
286fn deserialize_terminal_views(
287    workspace_id: WorkspaceId,
288    project: Entity<Project>,
289    workspace: WeakEntity<Workspace>,
290    item_ids: &[u64],
291    cx: &mut AsyncWindowContext,
292) -> impl Future<Output = Vec<Entity<TerminalView>>> + use<> {
293    let deserialized_items = join_all(item_ids.iter().filter_map(|item_id| {
294        cx.update(|window, cx| {
295            TerminalView::deserialize(
296                project.clone(),
297                workspace.clone(),
298                workspace_id,
299                *item_id,
300                window,
301                cx,
302            )
303        })
304        .ok()
305    }));
306    async move {
307        deserialized_items
308            .await
309            .into_iter()
310            .filter_map(|item| item.log_err())
311            .collect()
312    }
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316pub(crate) struct SerializedTerminalPanel {
317    pub items: SerializedItems,
318    // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced.
319    pub active_item_id: Option<u64>,
320    pub width: Option<Pixels>,
321    pub height: Option<Pixels>,
322}
323
324#[derive(Debug, Serialize, Deserialize)]
325#[serde(untagged)]
326pub(crate) enum SerializedItems {
327    // The data stored before terminal splits were introduced.
328    NoSplits(Vec<u64>),
329    WithSplits(SerializedPaneGroup),
330}
331
332#[derive(Debug, Serialize, Deserialize)]
333pub(crate) enum SerializedPaneGroup {
334    Pane(SerializedPane),
335    Group {
336        axis: SerializedAxis,
337        flexes: Option<Vec<f32>>,
338        children: Vec<SerializedPaneGroup>,
339    },
340}
341
342#[derive(Debug, Serialize, Deserialize)]
343pub(crate) struct SerializedPane {
344    pub active: bool,
345    pub children: Vec<u64>,
346    pub active_item: Option<u64>,
347    #[serde(default)]
348    pub pinned_count: usize,
349}
350
351#[derive(Debug)]
352pub(crate) struct SerializedAxis(pub Axis);
353
354impl Serialize for SerializedAxis {
355    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
356    where
357        S: serde::Serializer,
358    {
359        match self.0 {
360            Axis::Horizontal => serializer.serialize_str("horizontal"),
361            Axis::Vertical => serializer.serialize_str("vertical"),
362        }
363    }
364}
365
366impl<'de> Deserialize<'de> for SerializedAxis {
367    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
368    where
369        D: serde::Deserializer<'de>,
370    {
371        let s = String::deserialize(deserializer)?;
372        match s.as_str() {
373            "horizontal" => Ok(SerializedAxis(Axis::Horizontal)),
374            "vertical" => Ok(SerializedAxis(Axis::Vertical)),
375            invalid => Err(serde::de::Error::custom(format!(
376                "Invalid axis value: '{invalid}'"
377            ))),
378        }
379    }
380}
381
382pub struct TerminalDb(ThreadSafeConnection);
383
384impl Domain for TerminalDb {
385    const NAME: &str = stringify!(TerminalDb);
386
387    const MIGRATIONS: &[&str] = &[
388        sql!(
389            CREATE TABLE terminals (
390                workspace_id INTEGER,
391                item_id INTEGER UNIQUE,
392                working_directory BLOB,
393                PRIMARY KEY(workspace_id, item_id),
394                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
395                ON DELETE CASCADE
396            ) STRICT;
397        ),
398        // Remove the unique constraint on the item_id table
399        // SQLite doesn't have a way of doing this automatically, so
400        // we have to do this silly copying.
401        sql!(
402            CREATE TABLE terminals2 (
403                workspace_id INTEGER,
404                item_id INTEGER,
405                working_directory BLOB,
406                PRIMARY KEY(workspace_id, item_id),
407                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
408                ON DELETE CASCADE
409            ) STRICT;
410
411            INSERT INTO terminals2 (workspace_id, item_id, working_directory)
412            SELECT workspace_id, item_id, working_directory FROM terminals;
413
414            DROP TABLE terminals;
415
416            ALTER TABLE terminals2 RENAME TO terminals;
417        ),
418        sql! (
419            ALTER TABLE terminals ADD COLUMN working_directory_path TEXT;
420            UPDATE terminals SET working_directory_path = CAST(working_directory AS TEXT);
421        ),
422        sql! (
423            ALTER TABLE terminals ADD COLUMN custom_title TEXT;
424        ),
425    ];
426}
427
428db::static_connection!(TERMINAL_DB, TerminalDb, [WorkspaceDb]);
429
430impl TerminalDb {
431    query! {
432       pub async fn update_workspace_id(
433            new_id: WorkspaceId,
434            old_id: WorkspaceId,
435            item_id: ItemId
436        ) -> Result<()> {
437            UPDATE terminals
438            SET workspace_id = ?
439            WHERE workspace_id = ? AND item_id = ?
440        }
441    }
442
443    pub async fn save_working_directory(
444        &self,
445        item_id: ItemId,
446        workspace_id: WorkspaceId,
447        working_directory: PathBuf,
448    ) -> Result<()> {
449        log::debug!(
450            "Saving working directory {working_directory:?} for item {item_id} in workspace {workspace_id:?}"
451        );
452        let query =
453            "INSERT INTO terminals(item_id, workspace_id, working_directory, working_directory_path)
454            VALUES (?1, ?2, ?3, ?4)
455            ON CONFLICT DO UPDATE SET
456                item_id = ?1,
457                workspace_id = ?2,
458                working_directory = ?3,
459                working_directory_path = ?4"
460        ;
461        self.write(move |conn| {
462            let mut statement = Statement::prepare(conn, query)?;
463            let mut next_index = statement.bind(&item_id, 1)?;
464            next_index = statement.bind(&workspace_id, next_index)?;
465            next_index = statement.bind(&working_directory, next_index)?;
466            statement.bind(
467                &working_directory.to_string_lossy().into_owned(),
468                next_index,
469            )?;
470            statement.exec()
471        })
472        .await
473    }
474
475    query! {
476        pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
477            SELECT working_directory
478            FROM terminals
479            WHERE item_id = ? AND workspace_id = ?
480        }
481    }
482
483    pub async fn save_custom_title(
484        &self,
485        item_id: ItemId,
486        workspace_id: WorkspaceId,
487        custom_title: Option<String>,
488    ) -> Result<()> {
489        log::debug!(
490            "Saving custom title {:?} for item {} in workspace {:?}",
491            custom_title,
492            item_id,
493            workspace_id
494        );
495        self.write(move |conn| {
496            let query = "INSERT INTO terminals (item_id, workspace_id, custom_title)
497                VALUES (?1, ?2, ?3)
498                ON CONFLICT (workspace_id, item_id) DO UPDATE SET
499                    custom_title = excluded.custom_title";
500            let mut statement = Statement::prepare(conn, query)?;
501            let mut next_index = statement.bind(&item_id, 1)?;
502            next_index = statement.bind(&workspace_id, next_index)?;
503            statement.bind(&custom_title, next_index)?;
504            statement.exec()
505        })
506        .await
507    }
508
509    query! {
510        pub fn get_custom_title(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<String>> {
511            SELECT custom_title
512            FROM terminals
513            WHERE item_id = ? AND workspace_id = ?
514        }
515    }
516}