1use super::*;
2
3pub fn list_workspaces() -> Result<Vec<WorkspaceRecord>> {
12 list_workspaces_from(&database_path())
13}
14
15fn refuse_legacy_default_name(connection: &Connection, name_key: &str) -> Result<()> {
19 let hidden: bool = connection.query_row(
20 "SELECT EXISTS(
21 SELECT 1 FROM workspaces w
22 WHERE w.workspace_id = ?1 AND w.name_key = ?2
23 AND NOT EXISTS(
24 SELECT 1 FROM session_contexts c JOIN sessions s USING(session_id)
25 WHERE c.workspace_id = w.workspace_id))",
26 [DEFAULT_WORKSPACE_ID, name_key],
27 |row| row.get(0),
28 )?;
29 ensure!(
30 !hidden,
31 "the workspace name {name_key:?} is reserved: it holds sessions made before Mjolnir had workspaces; choose another name"
32 );
33 Ok(())
34}
35
36pub(super) struct DbPaneSize(PaneSize);
37
38impl rusqlite::types::ToSql for DbPaneSize {
39 fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
40 Ok(match self.0 {
41 PaneSize::Minimized => "minimized",
42 PaneSize::Standard => "standard",
43 PaneSize::Maximized => "maximized",
44 }
45 .into())
46 }
47}
48
49impl rusqlite::types::FromSql for DbPaneSize {
50 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
51 match value.as_str()? {
52 "minimized" => Ok(Self(PaneSize::Minimized)),
53 "standard" => Ok(Self(PaneSize::Standard)),
54 "maximized" => Ok(Self(PaneSize::Maximized)),
55 other => Err(rusqlite::types::FromSqlError::Other(
56 format!("unknown pane size {other:?}").into(),
57 )),
58 }
59 }
60}
61
62pub fn load_workspace_pane_sizes(workspace_id: &str) -> Result<PaneSizes> {
63 load_workspace_pane_sizes_from(&database_path(), workspace_id)
64}
65
66pub fn load_workspace_pane_sizes_from(path: &Path, workspace_id: &str) -> Result<PaneSizes> {
67 let connection = open_reader(path)?;
68 let sizes = connection
69 .query_row(
70 "SELECT coalesce(p.sessions, 'standard'), coalesce(p.targets, 'standard'),
71 coalesce(p.quota, 'standard')
72 FROM workspaces w LEFT JOIN workspace_pane_sizes p USING(workspace_id)
73 WHERE w.workspace_id = ?1",
74 [workspace_id],
75 |row| {
76 Ok(PaneSizes {
77 sessions: row.get::<_, DbPaneSize>(0)?.0,
78 targets: row.get::<_, DbPaneSize>(1)?.0,
79 quota: row.get::<_, DbPaneSize>(2)?.0,
80 })
81 },
82 )
83 .optional()?
84 .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
85 sizes.validate()?;
86 Ok(sizes)
87}
88
89pub fn save_workspace_pane_sizes(workspace_id: &str, sizes: PaneSizes) -> Result<()> {
90 let workspace_id = workspace_id.to_owned();
91 submit_database_write("save_workspace_pane_sizes", move |_| {
92 save_workspace_pane_sizes_to(&database_path(), &workspace_id, sizes)
93 })
94}
95
96pub fn save_workspace_pane_sizes_to(
97 path: &Path,
98 workspace_id: &str,
99 sizes: PaneSizes,
100) -> Result<()> {
101 sizes.validate()?;
102 let connection = open(path)?;
103 connection
104 .execute(
105 "INSERT INTO workspace_pane_sizes(workspace_id, sessions, targets, quota)
106 VALUES (?1, ?2, ?3, ?4)
107 ON CONFLICT(workspace_id) DO UPDATE SET
108 sessions = excluded.sessions, targets = excluded.targets, quota = excluded.quota",
109 params![
110 workspace_id,
111 DbPaneSize(sizes.sessions),
112 DbPaneSize(sizes.targets),
113 DbPaneSize(sizes.quota)
114 ],
115 )
116 .with_context(|| format!("save pane sizes for workspace {workspace_id:?}"))?;
117 Ok(())
118}
119
120pub fn load_workspace_layout(workspace_id: &str) -> Result<ConversationLayout> {
121 load_workspace_layout_from(&database_path(), workspace_id)
122}
123
124pub fn load_workspace_layout_from(path: &Path, workspace_id: &str) -> Result<ConversationLayout> {
125 let connection = open_reader(path)?;
126 let stored = connection
127 .query_row(
128 "SELECT l.layout
129 FROM workspaces w LEFT JOIN workspace_layouts l USING(workspace_id)
130 WHERE w.workspace_id = ?1",
131 [workspace_id],
132 |row| row.get::<_, Option<String>>(0),
133 )
134 .optional()?
135 .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
136 let Some(stored) = stored else {
137 return Ok(ConversationLayout::default());
138 };
139 let layout: ConversationLayout = serde_json::from_str(&stored)
140 .with_context(|| format!("decode layout for workspace {workspace_id:?}"))?;
141 layout.validate()?;
142 Ok(layout)
143}
144
145pub fn save_workspace_layout(workspace_id: &str, layout: ConversationLayout) -> Result<()> {
146 let workspace_id = workspace_id.to_owned();
147 submit_database_write("save_workspace_layout", move |_| {
148 save_workspace_layout_to(&database_path(), &workspace_id, &layout)
149 })
150}
151
152pub fn save_workspace_layout_to(
153 path: &Path,
154 workspace_id: &str,
155 layout: &ConversationLayout,
156) -> Result<()> {
157 layout.validate()?;
158 let encoded = serde_json::to_string(layout)?;
159 let connection = open(path)?;
160 connection
161 .execute(
162 "INSERT INTO workspace_layouts(workspace_id, layout)
163 VALUES (?1, ?2)
164 ON CONFLICT(workspace_id) DO UPDATE SET layout = excluded.layout",
165 params![workspace_id, encoded],
166 )
167 .with_context(|| format!("save layout for workspace {workspace_id:?}"))?;
168 Ok(())
169}
170
171pub fn list_workspaces_from(path: &Path) -> Result<Vec<WorkspaceRecord>> {
172 let connection = open_reader(path)?;
173 let mut statement = connection.prepare(
174 "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
175 count(s.session_id) FILTER (
176 WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
177 )
178 FROM workspaces w
179 LEFT JOIN session_contexts c USING(workspace_id)
180 LEFT JOIN sessions s USING(session_id)
181 GROUP BY w.workspace_id
182 HAVING w.workspace_id != 'default' OR count(s.session_id) > 0
183 ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id",
184 )?;
185 let rows = statement.query_map([], |row| {
186 Ok(WorkspaceRecord {
187 id: row.get(0)?,
188 name: row.get(1)?,
189 created_at: row.get(2)?,
190 last_opened_at: row.get(3)?,
191 session_count: row.get(4)?,
192 })
193 })?;
194 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
195}
196
197pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
198 let name = name.to_owned();
199 submit_database_write("create_workspace", move |_| {
200 create_workspace_at(&database_path(), &name)
201 })
202}
203
204pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
211 let name = name.to_owned();
212 submit_database_write("create_or_get_workspace", move |_| {
213 create_or_get_workspace_at(&database_path(), &name)
214 })
215}
216
217pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
218 let (name, name_key) = normalize_workspace_name(name)?;
219 let id = new_workspace_id()?;
220 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
221 let mut connection = open(path)?;
222 refuse_legacy_default_name(&connection, &name_key)?;
223 let transaction = connection.transaction()?;
224 transaction
225 .execute(
226 "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
227 VALUES (?1, ?2, ?3, ?4, ?4)
228 ON CONFLICT(name_key) DO NOTHING",
229 params![id, name, name_key, now],
230 )
231 .with_context(|| format!("create or find workspace {name:?}"))?;
232 let workspace = transaction.query_row(
233 "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
234 count(s.session_id) FILTER (
235 WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
236 )
237 FROM workspaces w
238 LEFT JOIN session_contexts c USING(workspace_id)
239 LEFT JOIN sessions s USING(session_id)
240 WHERE w.name_key = ?1
241 GROUP BY w.workspace_id",
242 params![name_key],
243 |row| {
244 Ok(WorkspaceRecord {
245 id: row.get(0)?,
246 name: row.get(1)?,
247 created_at: row.get(2)?,
248 last_opened_at: row.get(3)?,
249 session_count: row.get(4)?,
250 })
251 },
252 )?;
253 transaction.commit()?;
254 Ok(workspace)
255}
256
257pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
258 let (name, name_key) = normalize_workspace_name(name)?;
259 let id = new_workspace_id()?;
260 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
261 let connection = open(path)?;
262 refuse_legacy_default_name(&connection, &name_key)?;
263 connection
264 .execute(
265 "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
266 VALUES (?1, ?2, ?3, ?4, ?4)",
267 params![id, name, name_key, now],
268 )
269 .with_context(|| format!("create workspace {name:?}"))?;
270 Ok(WorkspaceRecord {
271 id,
272 name,
273 created_at: now.clone(),
274 last_opened_at: now,
275 session_count: 0,
276 })
277}
278
279pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
280 let workspace_id = workspace_id.to_owned();
281 let name = name.to_owned();
282 submit_database_write("rename_workspace", move |_| {
283 rename_workspace_at(&database_path(), &workspace_id, &name)
284 })
285}
286
287pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
288 let (name, name_key) = normalize_workspace_name(name)?;
289 let connection = open(path)?;
290 let changed = connection
291 .execute(
292 "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
293 params![workspace_id, name, name_key],
294 )
295 .with_context(|| format!("rename workspace to {name:?}"))?;
296 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
297 Ok(())
298}
299
300pub fn touch_workspace(workspace_id: &str) -> Result<()> {
301 let workspace_id = workspace_id.to_owned();
302 submit_database_write("touch_workspace", move |_| {
303 touch_workspace_at(&database_path(), &workspace_id)
304 })
305}
306
307pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
308 let connection = open(path)?;
309 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
310 let changed = connection.execute(
311 "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
312 params![workspace_id, now],
313 )?;
314 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
315 Ok(())
316}
317
318pub fn delete_workspace(workspace_id: &str) -> Result<()> {
323 let workspace_id = workspace_id.to_owned();
324 submit_database_write("delete_workspace", move |_| {
325 delete_workspace_at(&database_path(), &workspace_id)
326 })
327}
328
329pub fn delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
330 let mut connection = open(path)?;
331 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
332 let active_count = {
333 let mut statement = tx.prepare(
334 "SELECT s.state
335 FROM session_contexts c
336 JOIN sessions s USING(session_id)
337 WHERE c.workspace_id = ?1",
338 )?;
339 let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
340 states
341 .collect::<rusqlite::Result<Vec<_>>>()?
342 .into_iter()
343 .filter(|state| stored_session_state(state).is_active())
344 .count()
345 };
346 let draft_count: u64 = tx.query_row(
347 "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
348 [workspace_id],
349 |row| row.get(0),
350 )?;
351 ensure!(
352 active_count == 0 && draft_count == 0,
353 "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
354 );
355 let changed = tx.execute(
356 "DELETE FROM workspaces WHERE workspace_id = ?1",
357 [workspace_id],
358 )?;
359 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
360 tx.commit()?;
361 Ok(())
362}
363
364pub fn close_workspace(workspace_id: &str) -> Result<()> {
366 let workspace_id = workspace_id.to_owned();
367 submit_database_write("close_workspace", move |_| {
368 close_workspace_at(&database_path(), &workspace_id)
369 })
370}
371
372pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
373 remove_inactive_workspace_at(path, workspace_id, true)
374}
375
376fn remove_inactive_workspace_at(
377 path: &Path,
378 workspace_id: &str,
379 discard_session_drafts: bool,
380) -> Result<()> {
381 let mut connection = open(path)?;
382 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
383 let active_count = {
384 let mut statement = tx.prepare(
385 "SELECT s.state
386 FROM session_contexts c
387 JOIN sessions s USING(session_id)
388 WHERE c.workspace_id = ?1",
389 )?;
390 let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
391 states
392 .collect::<rusqlite::Result<Vec<_>>>()?
393 .into_iter()
394 .filter(|state| stored_session_state(state).is_active())
395 .count()
396 };
397 ensure!(
398 active_count == 0,
399 "workspace is not empty ({active_count} active sessions remain)"
400 );
401 if discard_session_drafts {
402 tx.execute(
403 "UPDATE sessions SET draft_input = '' WHERE session_id IN
404 (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
405 [workspace_id],
406 )?;
407 }
408 tx.execute(
409 "DELETE FROM detached_drafts WHERE workspace_id = ?1",
410 [workspace_id],
411 )?;
412 let changed = tx.execute(
413 "DELETE FROM workspaces WHERE workspace_id = ?1",
414 [workspace_id],
415 )?;
416 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
417 tx.commit()?;
418 Ok(())
419}
420
421pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
427 let session_id = session_id.to_owned();
428 let workspace_id = workspace_id.to_owned();
429 submit_database_write("reassign_resumable_session_workspace", move |_| {
430 reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
431 })
432}
433
434pub fn reassign_resumable_session_workspace_at(
435 path: &Path,
436 session_id: &str,
437 workspace_id: &str,
438) -> Result<()> {
439 let mut connection = open(path)?;
440 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
441 let (current_workspace, state): (String, String) = tx
442 .query_row(
443 "SELECT c.workspace_id, s.state
444 FROM session_contexts c
445 JOIN sessions s USING(session_id)
446 WHERE c.session_id = ?1",
447 [session_id],
448 |row| Ok((row.get(0)?, row.get(1)?)),
449 )
450 .with_context(|| format!("find resumable session {session_id:?}"))?;
451 ensure!(
452 matches!(
453 stored_session_state(&state),
454 SessionState::Stopped | SessionState::Lost | SessionState::Error
455 ),
456 "session {session_id} is not resumable"
457 );
458 let destination_exists: bool = tx.query_row(
459 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
460 [workspace_id],
461 |row| row.get(0),
462 )?;
463 ensure!(destination_exists, "unknown workspace {workspace_id:?}");
464 if current_workspace != workspace_id {
465 tx.execute(
466 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
467 params![session_id, workspace_id],
468 )?;
469 }
470 tx.commit()?;
471 Ok(())
472}
473
474pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
475 open_reader(path)?
476 .query_row(
477 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
478 [session_id],
479 |row| row.get(0),
480 )
481 .optional()
482 .map_err(Into::into)
483}
484
485pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
486 session_ids_for_workspace_at(&database_path(), workspace_id)
487}
488
489pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
492 let connection = open_reader(path)?;
493 let mut statement = connection.prepare(
494 "SELECT c.session_id
495 FROM session_contexts c
496 JOIN sessions s USING(session_id)
497 WHERE c.workspace_id = ?1
498 ORDER BY c.created_at, c.session_id",
499 )?;
500 let rows = statement.query_map([workspace_id], |row| row.get(0))?;
501 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
502}
503
504pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
507 let session_id = session_id.to_owned();
508 let workspace_id = workspace_id.to_owned();
509 submit_database_write("assign_new_session_workspace", move |_| {
510 assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
511 })
512}
513
514pub fn assign_new_session_workspace_at(
515 path: &Path,
516 session_id: &str,
517 workspace_id: &str,
518) -> Result<()> {
519 let connection = open(path)?;
520 let current: String = connection
521 .query_row(
522 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
523 [session_id],
524 |row| row.get(0),
525 )
526 .with_context(|| format!("find session context {session_id:?}"))?;
527 if current == workspace_id {
528 return Ok(());
529 }
530 ensure!(
531 current == DEFAULT_WORKSPACE_ID,
532 "session {session_id} already belongs to workspace {current}"
533 );
534 let exists: bool = connection.query_row(
535 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
536 [workspace_id],
537 |row| row.get(0),
538 )?;
539 ensure!(exists, "unknown workspace {workspace_id:?}");
540 connection.execute(
541 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
542 params![session_id, workspace_id],
543 )?;
544 Ok(())
545}