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