workspace.rs

  1pub mod model;
  2
  3use anyhow::{bail, Context, Result};
  4use util::{iife, unzip_option, ResultExt};
  5
  6use std::path::{Path, PathBuf};
  7
  8use indoc::indoc;
  9use sqlez::{domain::Domain, migrations::Migration};
 10
 11use self::model::{
 12    Axis, GroupId, PaneId, SerializedItem, SerializedItemKind, SerializedPane, SerializedPaneGroup,
 13    SerializedWorkspace, WorkspaceId,
 14};
 15
 16use super::Db;
 17
 18// 1) Move all of this into Workspace crate
 19// 2) Deserialize items fully
 20// 3) Typed prepares (including how you expect to pull data out)
 21// 4) Investigate Tree column impls
 22
 23pub(crate) const WORKSPACES_MIGRATION: Migration = Migration::new(
 24    "workspace",
 25    &[indoc! {"
 26        CREATE TABLE workspaces(
 27            workspace_id BLOB PRIMARY KEY,
 28            dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded'
 29            dock_visible INTEGER, -- Boolean
 30            timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
 31        ) STRICT;
 32    "}],
 33);
 34
 35pub(crate) const PANE_MIGRATIONS: Migration = Migration::new(
 36    "pane",
 37    &[indoc! {"
 38        CREATE TABLE pane_groups(
 39            group_id INTEGER PRIMARY KEY,
 40            workspace_id BLOB NOT NULL,
 41            parent_group_id INTEGER, -- NULL indicates that this is a root node
 42            position INTEGER, -- NULL indicates that this is a root node
 43            axis TEXT NOT NULL, -- Enum:  'Vertical' / 'Horizontal'
 44            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
 45            FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
 46        ) STRICT;
 47        
 48        CREATE TABLE panes(
 49            pane_id INTEGER PRIMARY KEY,
 50            workspace_id BLOB NOT NULL,
 51            parent_group_id INTEGER, -- NULL, this is a dock pane
 52            position INTEGER, -- NULL, this is a dock pane
 53            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
 54            FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
 55        ) STRICT;
 56    "}],
 57);
 58
 59pub(crate) const ITEM_MIGRATIONS: Migration = Migration::new(
 60    "item",
 61    &[indoc! {"
 62        CREATE TABLE items(
 63            item_id INTEGER NOT NULL, -- This is the item's view id, so this is not unique
 64            workspace_id BLOB NOT NULL,
 65            pane_id INTEGER NOT NULL,
 66            kind TEXT NOT NULL,
 67            position INTEGER NOT NULL,
 68            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
 69            FOREIGN KEY(pane_id) REFERENCES panes(pane_id) ON DELETE CASCADE
 70            PRIMARY KEY(item_id, workspace_id)
 71        ) STRICT;
 72    "}],
 73);
 74
 75#[derive(Clone)]
 76pub enum Workspace {}
 77
 78impl Domain for Workspace {
 79    fn migrate(conn: &sqlez::connection::Connection) -> anyhow::Result<()> {
 80        WORKSPACES_MIGRATION.run(&conn)?;
 81        PANE_MIGRATIONS.run(&conn)?;
 82        ITEM_MIGRATIONS.run(&conn)
 83    }
 84}
 85
 86impl Db<Workspace> {
 87    /// Returns a serialized workspace for the given worktree_roots. If the passed array
 88    /// is empty, the most recent workspace is returned instead. If no workspace for the
 89    /// passed roots is stored, returns none.
 90    pub fn workspace_for_roots<P: AsRef<Path>>(
 91        &self,
 92        worktree_roots: &[P],
 93    ) -> Option<SerializedWorkspace> {
 94        let workspace_id: WorkspaceId = worktree_roots.into();
 95
 96        // Note that we re-assign the workspace_id here in case it's empty
 97        // and we've grabbed the most recent workspace
 98        let (workspace_id, dock_anchor, dock_visible) = iife!({
 99            if worktree_roots.len() == 0 {
100                self.select_row(indoc! {"
101                        SELECT workspace_id, dock_anchor, dock_visible 
102                        FROM workspaces 
103                        ORDER BY timestamp DESC LIMIT 1"})?()?
104            } else {
105                self.select_row_bound(indoc! {"
106                        SELECT workspace_id, dock_anchor, dock_visible 
107                        FROM workspaces 
108                        WHERE workspace_id = ?"})?(&workspace_id)?
109            }
110            .context("No workspaces found")
111        })
112        .warn_on_err()
113        .flatten()?;
114
115        Some(SerializedWorkspace {
116            dock_pane: self
117                .get_dock_pane(&workspace_id)
118                .context("Getting dock pane")
119                .log_err()?,
120            center_group: self
121                .get_center_pane_group(&workspace_id)
122                .context("Getting center group")
123                .log_err()?,
124            dock_anchor,
125            dock_visible,
126        })
127    }
128
129    /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
130    /// that used this workspace previously
131    pub fn save_workspace<P: AsRef<Path>>(
132        &self,
133        worktree_roots: &[P],
134        old_roots: Option<&[P]>,
135        workspace: &SerializedWorkspace,
136    ) {
137        let workspace_id: WorkspaceId = worktree_roots.into();
138
139        self.with_savepoint("update_worktrees", || {
140            if let Some(old_roots) = old_roots {
141                let old_id: WorkspaceId = old_roots.into();
142
143                self.exec_bound("DELETE FROM WORKSPACES WHERE workspace_id = ?")?(&old_id)?;
144            }
145
146            // Delete any previous workspaces with the same roots. This cascades to all
147            // other tables that are based on the same roots set.
148            // Insert new workspace into workspaces table if none were found
149            self.exec_bound("DELETE FROM workspaces WHERE workspace_id = ?;")?(&workspace_id)?;
150
151            self.exec_bound(
152                "INSERT INTO workspaces(workspace_id, dock_anchor, dock_visible) VALUES (?, ?, ?)",
153            )?((&workspace_id, workspace.dock_anchor, workspace.dock_visible))?;
154
155            // Save center pane group and dock pane
156            self.save_pane_group(&workspace_id, &workspace.center_group, None)?;
157            self.save_pane(&workspace_id, &workspace.dock_pane, None)?;
158
159            Ok(())
160        })
161        .with_context(|| {
162            format!(
163                "Update workspace with roots {:?}",
164                worktree_roots
165                    .iter()
166                    .map(|p| p.as_ref())
167                    .collect::<Vec<_>>()
168            )
169        })
170        .log_err();
171    }
172
173    /// Returns the previous workspace ids sorted by last modified along with their opened worktree roots
174    pub fn recent_workspaces(&self, limit: usize) -> Vec<Vec<PathBuf>> {
175        iife!({
176            // TODO, upgrade anyhow: https://docs.rs/anyhow/1.0.66/anyhow/fn.Ok.html
177            Ok::<_, anyhow::Error>(
178                self.select_bound::<usize, WorkspaceId>(
179                    "SELECT workspace_id FROM workspaces ORDER BY timestamp DESC LIMIT ?",
180                )?(limit)?
181                .into_iter()
182                .map(|id| id.paths())
183                .collect::<Vec<Vec<PathBuf>>>(),
184            )
185        })
186        .log_err()
187        .unwrap_or_default()
188    }
189
190    pub(crate) fn get_center_pane_group(
191        &self,
192        workspace_id: &WorkspaceId,
193    ) -> Result<SerializedPaneGroup> {
194        self.get_pane_group_children(workspace_id, None)?
195            .into_iter()
196            .next()
197            .context("No center pane group")
198    }
199
200    fn get_pane_group_children<'a>(
201        &self,
202        workspace_id: &WorkspaceId,
203        group_id: Option<GroupId>,
204    ) -> Result<Vec<SerializedPaneGroup>> {
205        self.select_bound::<(Option<GroupId>, &WorkspaceId), (Option<GroupId>, Option<Axis>, Option<PaneId>)>(indoc! {"
206            SELECT group_id, axis, pane_id
207                FROM (SELECT group_id, axis, NULL as pane_id, position,  parent_group_id, workspace_id
208                FROM pane_groups
209                  UNION
210                SELECT NULL, NULL,  pane_id,  position,  parent_group_id, workspace_id
211                FROM panes
212                -- Remove the dock panes from the union
213                WHERE parent_group_id IS NOT NULL and position IS NOT NULL) 
214            WHERE parent_group_id IS ? AND workspace_id = ?
215            ORDER BY position
216            "})?((group_id, workspace_id))?
217            .into_iter()
218            .map(|(group_id, axis, pane_id)| {
219                if let Some((group_id, axis)) = group_id.zip(axis) {
220                    Ok(SerializedPaneGroup::Group {
221                        axis,
222                        children: self.get_pane_group_children(
223                            workspace_id,
224                            Some(group_id),
225                        )?,
226                    })
227                } else if let Some(pane_id) = pane_id {
228                    Ok(SerializedPaneGroup::Pane(SerializedPane {
229                        children: self.get_items(pane_id)?,
230                    }))
231                } else {
232                    bail!("Pane Group Child was neither a pane group or a pane");
233                }
234            })
235            .collect::<Result<_>>()
236    }
237
238    pub(crate) fn save_pane_group(
239        &self,
240        workspace_id: &WorkspaceId,
241        pane_group: &SerializedPaneGroup,
242        parent: Option<(GroupId, usize)>,
243    ) -> Result<()> {
244        if parent.is_none() && !matches!(pane_group, SerializedPaneGroup::Group { .. }) {
245            bail!("Pane groups must have a SerializedPaneGroup::Group at the root")
246        }
247
248        let (parent_id, position) = unzip_option(parent);
249
250        match pane_group {
251            SerializedPaneGroup::Group { axis, children } => {
252                let parent_id = self.insert_bound("INSERT INTO pane_groups(workspace_id, parent_group_id, position, axis) VALUES (?, ?, ?, ?)")?
253                    ((workspace_id, parent_id, position, *axis))?;
254
255                for (position, group) in children.iter().enumerate() {
256                    self.save_pane_group(workspace_id, group, Some((parent_id, position)))?
257                }
258                Ok(())
259            }
260            SerializedPaneGroup::Pane(pane) => self.save_pane(workspace_id, pane, parent),
261        }
262    }
263
264    pub(crate) fn get_dock_pane(&self, workspace_id: &WorkspaceId) -> Result<SerializedPane> {
265        let pane_id = self.select_row_bound(indoc! {"
266                SELECT pane_id FROM panes 
267                WHERE workspace_id = ? AND parent_group_id IS NULL AND position IS NULL"})?(
268            workspace_id,
269        )?
270        .context("No dock pane for workspace")?;
271
272        Ok(SerializedPane::new(
273            self.get_items(pane_id).context("Reading items")?,
274        ))
275    }
276
277    pub(crate) fn save_pane(
278        &self,
279        workspace_id: &WorkspaceId,
280        pane: &SerializedPane,
281        parent: Option<(GroupId, usize)>,
282    ) -> Result<()> {
283        let (parent_id, order) = unzip_option(parent);
284
285        let pane_id = self.insert_bound(
286            "INSERT INTO panes(workspace_id, parent_group_id, position) VALUES (?, ?, ?)",
287        )?((workspace_id, parent_id, order))?;
288
289        self.save_items(workspace_id, pane_id, &pane.children)
290            .context("Saving items")
291    }
292
293    pub(crate) fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
294        Ok(self.select_bound(indoc! {"
295                SELECT item_id, kind FROM items
296                WHERE pane_id = ?
297                ORDER BY position"})?(pane_id)?
298        .into_iter()
299        .map(|(item_id, kind)| match kind {
300            SerializedItemKind::Terminal => SerializedItem::Terminal { item_id },
301            _ => unimplemented!(),
302        })
303        .collect())
304    }
305
306    pub(crate) fn save_items(
307        &self,
308        workspace_id: &WorkspaceId,
309        pane_id: PaneId,
310        items: &[SerializedItem],
311    ) -> Result<()> {
312        let mut delete_old = self
313            .exec_bound("DELETE FROM items WHERE workspace_id = ? AND pane_id = ? AND item_id = ?")
314            .context("Preparing deletion")?;
315        let mut insert_new = self.exec_bound(
316            "INSERT INTO items(item_id, workspace_id, pane_id, kind, position) VALUES (?, ?, ?, ?, ?)",
317        ).context("Preparing insertion")?;
318        for (position, item) in items.iter().enumerate() {
319            delete_old((workspace_id, pane_id, item.item_id()))?;
320            insert_new((item.item_id(), workspace_id, pane_id, item.kind(), position))?;
321        }
322
323        Ok(())
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use crate::{
330        model::{
331            DockAnchor::{Bottom, Expanded, Right},
332            SerializedWorkspace,
333        },
334        Db,
335    };
336
337    #[test]
338    fn test_workspace_assignment() {
339        env_logger::try_init().ok();
340
341        let db = Db::open_in_memory("test_basic_functionality");
342
343        let workspace_1 = SerializedWorkspace {
344            dock_anchor: Bottom,
345            dock_visible: true,
346            center_group: Default::default(),
347            dock_pane: Default::default(),
348        };
349
350        let workspace_2 = SerializedWorkspace {
351            dock_anchor: Expanded,
352            dock_visible: false,
353            center_group: Default::default(),
354            dock_pane: Default::default(),
355        };
356
357        let workspace_3 = SerializedWorkspace {
358            dock_anchor: Right,
359            dock_visible: true,
360            center_group: Default::default(),
361            dock_pane: Default::default(),
362        };
363
364        db.save_workspace(&["/tmp", "/tmp2"], None, &workspace_1);
365        db.save_workspace(&["/tmp"], None, &workspace_2);
366
367        db.write_to("test.db").unwrap();
368
369        // Test that paths are treated as a set
370        assert_eq!(
371            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
372            workspace_1
373        );
374        assert_eq!(
375            db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
376            workspace_1
377        );
378
379        // Make sure that other keys work
380        assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
381        assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
382
383        // Test 'mutate' case of updating a pre-existing id
384        db.save_workspace(&["/tmp", "/tmp2"], Some(&["/tmp", "/tmp2"]), &workspace_2);
385        assert_eq!(
386            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
387            workspace_2
388        );
389
390        // Test other mechanism for mutating
391        db.save_workspace(&["/tmp", "/tmp2"], None, &workspace_3);
392        assert_eq!(
393            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
394            workspace_3
395        );
396
397        // Make sure that updating paths differently also works
398        db.save_workspace(
399            &["/tmp3", "/tmp4", "/tmp2"],
400            Some(&["/tmp", "/tmp2"]),
401            &workspace_3,
402        );
403        assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
404        assert_eq!(
405            db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
406                .unwrap(),
407            workspace_3
408        );
409    }
410
411    use crate::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
412
413    fn default_workspace(
414        dock_pane: SerializedPane,
415        center_group: &SerializedPaneGroup,
416    ) -> SerializedWorkspace {
417        SerializedWorkspace {
418            dock_anchor: crate::model::DockAnchor::Right,
419            dock_visible: false,
420            center_group: center_group.clone(),
421            dock_pane,
422        }
423    }
424
425    #[test]
426    fn test_basic_dock_pane() {
427        env_logger::try_init().ok();
428
429        let db = Db::open_in_memory("basic_dock_pane");
430
431        let dock_pane = crate::model::SerializedPane {
432            children: vec![
433                SerializedItem::Terminal { item_id: 1 },
434                SerializedItem::Terminal { item_id: 4 },
435                SerializedItem::Terminal { item_id: 2 },
436                SerializedItem::Terminal { item_id: 3 },
437            ],
438        };
439
440        let workspace = default_workspace(dock_pane, &Default::default());
441
442        db.save_workspace(&["/tmp"], None, &workspace);
443
444        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
445
446        assert_eq!(workspace.dock_pane, new_workspace.dock_pane);
447    }
448
449    #[test]
450    fn test_simple_split() {
451        env_logger::try_init().ok();
452
453        let db = Db::open_in_memory("simple_split");
454
455        //  -----------------
456        //  | 1,2   | 5,6   |
457        //  | - - - |       |
458        //  | 3,4   |       |
459        //  -----------------
460        let center_pane = SerializedPaneGroup::Group {
461            axis: crate::model::Axis::Horizontal,
462            children: vec![
463                SerializedPaneGroup::Group {
464                    axis: crate::model::Axis::Vertical,
465                    children: vec![
466                        SerializedPaneGroup::Pane(SerializedPane {
467                            children: vec![
468                                SerializedItem::Terminal { item_id: 1 },
469                                SerializedItem::Terminal { item_id: 2 },
470                            ],
471                        }),
472                        SerializedPaneGroup::Pane(SerializedPane {
473                            children: vec![
474                                SerializedItem::Terminal { item_id: 4 },
475                                SerializedItem::Terminal { item_id: 3 },
476                            ],
477                        }),
478                    ],
479                },
480                SerializedPaneGroup::Pane(SerializedPane {
481                    children: vec![
482                        SerializedItem::Terminal { item_id: 5 },
483                        SerializedItem::Terminal { item_id: 6 },
484                    ],
485                }),
486            ],
487        };
488
489        let workspace = default_workspace(Default::default(), &center_pane);
490
491        db.save_workspace(&["/tmp"], None, &workspace);
492
493        assert_eq!(workspace.center_group, center_pane);
494    }
495}