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 query_workspaces(path, "", &[])
173}
174
175pub fn workspace_record(workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
178 let mut records = query_workspaces(
179 &database_path(),
180 "WHERE w.workspace_id = ?1",
181 &[&workspace_id],
182 )?;
183 Ok(records.pop())
184}
185
186fn query_workspaces(
187 path: &Path,
188 filter: &str,
189 parameters: &[&dyn rusqlite::ToSql],
190) -> Result<Vec<WorkspaceRecord>> {
191 let connection = open_reader(path)?;
192 let mut statement = connection.prepare(&format!(
193 "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
194 count(s.session_id) FILTER (
195 WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
196 )
197 FROM workspaces w
198 LEFT JOIN session_contexts c USING(workspace_id)
199 LEFT JOIN sessions s USING(session_id)
200 {filter}
201 GROUP BY w.workspace_id
202 HAVING w.workspace_id != 'default' OR count(s.session_id) > 0
203 ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id"
204 ))?;
205 let rows = statement.query_map(parameters, |row| {
206 Ok(WorkspaceRecord {
207 id: row.get(0)?,
208 name: row.get(1)?,
209 created_at: row.get(2)?,
210 last_opened_at: row.get(3)?,
211 session_count: row.get(4)?,
212 })
213 })?;
214 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
215}
216
217pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
218 let name = name.to_owned();
219 submit_database_write("create_workspace", move |_| {
220 create_workspace_at(&database_path(), &name)
221 })
222}
223
224pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
231 let name = name.to_owned();
232 submit_database_write("create_or_get_workspace", move |_| {
233 create_or_get_workspace_at(&database_path(), &name)
234 })
235}
236
237pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
238 let (name, name_key) = normalize_workspace_name(name)?;
239 let id = new_workspace_id()?;
240 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
241 let mut connection = open(path)?;
242 refuse_legacy_default_name(&connection, &name_key)?;
243 let transaction = connection.transaction()?;
244 transaction
245 .execute(
246 "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
247 VALUES (?1, ?2, ?3, ?4, ?4)
248 ON CONFLICT(name_key) DO NOTHING",
249 params![id, name, name_key, now],
250 )
251 .with_context(|| format!("create or find workspace {name:?}"))?;
252 let workspace = transaction.query_row(
253 "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
254 count(s.session_id) FILTER (
255 WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
256 )
257 FROM workspaces w
258 LEFT JOIN session_contexts c USING(workspace_id)
259 LEFT JOIN sessions s USING(session_id)
260 WHERE w.name_key = ?1
261 GROUP BY w.workspace_id",
262 params![name_key],
263 |row| {
264 Ok(WorkspaceRecord {
265 id: row.get(0)?,
266 name: row.get(1)?,
267 created_at: row.get(2)?,
268 last_opened_at: row.get(3)?,
269 session_count: row.get(4)?,
270 })
271 },
272 )?;
273 transaction.commit()?;
274 Ok(workspace)
275}
276
277pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
278 let (name, name_key) = normalize_workspace_name(name)?;
279 let id = new_workspace_id()?;
280 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
281 let connection = open(path)?;
282 refuse_legacy_default_name(&connection, &name_key)?;
283 connection
284 .execute(
285 "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
286 VALUES (?1, ?2, ?3, ?4, ?4)",
287 params![id, name, name_key, now],
288 )
289 .with_context(|| format!("create workspace {name:?}"))?;
290 Ok(WorkspaceRecord {
291 id,
292 name,
293 created_at: now.clone(),
294 last_opened_at: now,
295 session_count: 0,
296 })
297}
298
299pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
300 let workspace_id = workspace_id.to_owned();
301 let name = name.to_owned();
302 submit_database_write("rename_workspace", move |_| {
303 rename_workspace_at(&database_path(), &workspace_id, &name)
304 })
305}
306
307pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
308 let (name, name_key) = normalize_workspace_name(name)?;
309 let connection = open(path)?;
310 let changed = connection
311 .execute(
312 "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
313 params![workspace_id, name, name_key],
314 )
315 .with_context(|| format!("rename workspace to {name:?}"))?;
316 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
317 Ok(())
318}
319
320pub fn touch_workspace(workspace_id: &str) -> Result<()> {
321 let workspace_id = workspace_id.to_owned();
322 submit_database_write("touch_workspace", move |_| {
323 touch_workspace_at(&database_path(), &workspace_id)
324 })
325}
326
327pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
328 let connection = open(path)?;
329 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
330 let changed = connection.execute(
331 "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
332 params![workspace_id, now],
333 )?;
334 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
335 Ok(())
336}
337
338pub fn delete_workspace(workspace_id: &str) -> Result<()> {
343 let workspace_id = workspace_id.to_owned();
344 submit_database_write("delete_workspace", move |_| {
345 delete_workspace_at(&database_path(), &workspace_id)
346 })
347}
348
349pub fn delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
350 let mut connection = open(path)?;
351 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
352 let active_count = {
353 let mut statement = tx.prepare(
354 "SELECT s.state
355 FROM session_contexts c
356 JOIN sessions s USING(session_id)
357 WHERE c.workspace_id = ?1",
358 )?;
359 let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
360 states
361 .collect::<rusqlite::Result<Vec<_>>>()?
362 .into_iter()
363 .filter(|state| stored_session_state(state).is_active())
364 .count()
365 };
366 let draft_count: u64 = tx.query_row(
367 "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
368 [workspace_id],
369 |row| row.get(0),
370 )?;
371 ensure!(
372 active_count == 0 && draft_count == 0,
373 "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
374 );
375 let changed = tx.execute(
376 "DELETE FROM workspaces WHERE workspace_id = ?1",
377 [workspace_id],
378 )?;
379 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
380 tx.commit()?;
381 Ok(())
382}
383
384pub fn close_workspace(workspace_id: &str) -> Result<()> {
386 let workspace_id = workspace_id.to_owned();
387 submit_database_write("close_workspace", move |_| {
388 close_workspace_at(&database_path(), &workspace_id)
389 })
390}
391
392pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
393 remove_inactive_workspace_at(path, workspace_id, true)
394}
395
396fn remove_inactive_workspace_at(
397 path: &Path,
398 workspace_id: &str,
399 discard_session_drafts: bool,
400) -> Result<()> {
401 let mut connection = open(path)?;
402 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
403 let active_count = {
404 let mut statement = tx.prepare(
405 "SELECT s.state
406 FROM session_contexts c
407 JOIN sessions s USING(session_id)
408 WHERE c.workspace_id = ?1",
409 )?;
410 let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
411 states
412 .collect::<rusqlite::Result<Vec<_>>>()?
413 .into_iter()
414 .filter(|state| stored_session_state(state).is_active())
415 .count()
416 };
417 ensure!(
418 active_count == 0,
419 "workspace is not empty ({active_count} active sessions remain)"
420 );
421 if discard_session_drafts {
422 tx.execute(
423 "UPDATE sessions SET draft_input = '' WHERE session_id IN
424 (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
425 [workspace_id],
426 )?;
427 }
428 tx.execute(
429 "DELETE FROM detached_drafts WHERE workspace_id = ?1",
430 [workspace_id],
431 )?;
432 let changed = tx.execute(
433 "DELETE FROM workspaces WHERE workspace_id = ?1",
434 [workspace_id],
435 )?;
436 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
437 tx.commit()?;
438 Ok(())
439}
440
441pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
447 let session_id = session_id.to_owned();
448 let workspace_id = workspace_id.to_owned();
449 submit_database_write("reassign_resumable_session_workspace", move |_| {
450 reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
451 })
452}
453
454pub fn reassign_resumable_session_workspace_at(
455 path: &Path,
456 session_id: &str,
457 workspace_id: &str,
458) -> Result<()> {
459 let mut connection = open(path)?;
460 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
461 let (current_workspace, state): (String, String) = tx
462 .query_row(
463 "SELECT c.workspace_id, s.state
464 FROM session_contexts c
465 JOIN sessions s USING(session_id)
466 WHERE c.session_id = ?1",
467 [session_id],
468 |row| Ok((row.get(0)?, row.get(1)?)),
469 )
470 .with_context(|| format!("find resumable session {session_id:?}"))?;
471 ensure!(
472 matches!(
473 stored_session_state(&state),
474 SessionState::Stopped | SessionState::Lost | SessionState::Error
475 ),
476 "session {session_id} is not resumable"
477 );
478 let destination_exists: bool = tx.query_row(
479 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
480 [workspace_id],
481 |row| row.get(0),
482 )?;
483 ensure!(destination_exists, "unknown workspace {workspace_id:?}");
484 if current_workspace != workspace_id {
485 tx.execute(
486 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
487 params![session_id, workspace_id],
488 )?;
489 }
490 tx.commit()?;
491 Ok(())
492}
493
494pub fn set_sessions_workspace(session_ids: &[String], workspace_id: &str) -> Result<()> {
500 let session_ids = session_ids.to_vec();
501 let workspace_id = workspace_id.to_owned();
502 submit_database_write("set_sessions_workspace", move |_| {
503 set_sessions_workspace_at(&database_path(), &session_ids, &workspace_id)
504 })
505}
506
507pub fn set_sessions_workspace_at(
508 path: &Path,
509 session_ids: &[String],
510 workspace_id: &str,
511) -> Result<()> {
512 let mut connection = open(path)?;
513 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
514 let destination_exists: bool = tx.query_row(
515 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
516 [workspace_id],
517 |row| row.get(0),
518 )?;
519 ensure!(destination_exists, "unknown workspace {workspace_id:?}");
520 for session_id in session_ids {
521 let changed = tx.execute(
522 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
523 params![session_id, workspace_id],
524 )?;
525 ensure!(changed == 1, "unknown session {session_id}");
526 }
527 tx.commit()?;
528 Ok(())
529}
530
531pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
532 open_reader(path)?
533 .query_row(
534 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
535 [session_id],
536 |row| row.get(0),
537 )
538 .optional()
539 .map_err(Into::into)
540}
541
542pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
543 session_ids_for_workspace_at(&database_path(), workspace_id)
544}
545
546pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
549 let connection = open_reader(path)?;
550 let mut statement = connection.prepare(
551 "SELECT c.session_id
552 FROM session_contexts c
553 JOIN sessions s USING(session_id)
554 WHERE c.workspace_id = ?1
555 ORDER BY c.created_at, c.session_id",
556 )?;
557 let rows = statement.query_map([workspace_id], |row| row.get(0))?;
558 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
559}
560
561pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
564 let session_id = session_id.to_owned();
565 let workspace_id = workspace_id.to_owned();
566 submit_database_write("assign_new_session_workspace", move |_| {
567 assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
568 })
569}
570
571pub fn assign_new_session_workspace_at(
572 path: &Path,
573 session_id: &str,
574 workspace_id: &str,
575) -> Result<()> {
576 let connection = open(path)?;
577 let current: String = connection
578 .query_row(
579 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
580 [session_id],
581 |row| row.get(0),
582 )
583 .with_context(|| format!("find session context {session_id:?}"))?;
584 if current == workspace_id {
585 return Ok(());
586 }
587 ensure!(
588 current == DEFAULT_WORKSPACE_ID,
589 "session {session_id} already belongs to workspace {current}"
590 );
591 let exists: bool = connection.query_row(
592 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
593 [workspace_id],
594 |row| row.get(0),
595 )?;
596 ensure!(exists, "unknown workspace {workspace_id:?}");
597 connection.execute(
598 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
599 params![session_id, workspace_id],
600 )?;
601 Ok(())
602}