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(), ¢er_pane);
490
491 db.save_workspace(&["/tmp"], None, &workspace);
492
493 assert_eq!(workspace.center_group, center_pane);
494 }
495}