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 force_delete_workspace(workspace_id: &str) -> Result<()> {
372 let workspace_id = workspace_id.to_owned();
373 submit_database_write("force_delete_workspace", move |_| {
374 force_delete_workspace_at(&database_path(), &workspace_id)
375 })
376}
377
378pub fn force_delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
379 remove_inactive_workspace_at(path, workspace_id, false)
380}
381
382pub fn close_workspace(workspace_id: &str) -> Result<()> {
384 let workspace_id = workspace_id.to_owned();
385 submit_database_write("close_workspace", move |_| {
386 close_workspace_at(&database_path(), &workspace_id)
387 })
388}
389
390pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
391 remove_inactive_workspace_at(path, workspace_id, true)
392}
393
394fn remove_inactive_workspace_at(
395 path: &Path,
396 workspace_id: &str,
397 discard_session_drafts: bool,
398) -> Result<()> {
399 let mut connection = open(path)?;
400 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
401 let active_count = {
402 let mut statement = tx.prepare(
403 "SELECT s.state
404 FROM session_contexts c
405 JOIN sessions s USING(session_id)
406 WHERE c.workspace_id = ?1",
407 )?;
408 let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
409 states
410 .collect::<rusqlite::Result<Vec<_>>>()?
411 .into_iter()
412 .filter(|state| stored_session_state(state).is_active())
413 .count()
414 };
415 ensure!(
416 active_count == 0,
417 "workspace is not empty ({active_count} active sessions remain)"
418 );
419 if discard_session_drafts {
420 tx.execute(
421 "UPDATE sessions SET draft_input = '' WHERE session_id IN
422 (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
423 [workspace_id],
424 )?;
425 }
426 tx.execute(
427 "DELETE FROM detached_drafts WHERE workspace_id = ?1",
428 [workspace_id],
429 )?;
430 let changed = tx.execute(
431 "DELETE FROM workspaces WHERE workspace_id = ?1",
432 [workspace_id],
433 )?;
434 ensure!(changed == 1, "unknown workspace {workspace_id:?}");
435 tx.commit()?;
436 Ok(())
437}
438
439pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
445 let session_id = session_id.to_owned();
446 let workspace_id = workspace_id.to_owned();
447 submit_database_write("reassign_resumable_session_workspace", move |_| {
448 reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
449 })
450}
451
452pub fn reassign_resumable_session_workspace_at(
453 path: &Path,
454 session_id: &str,
455 workspace_id: &str,
456) -> Result<()> {
457 let mut connection = open(path)?;
458 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
459 let (current_workspace, state): (String, String) = tx
460 .query_row(
461 "SELECT c.workspace_id, s.state
462 FROM session_contexts c
463 JOIN sessions s USING(session_id)
464 WHERE c.session_id = ?1",
465 [session_id],
466 |row| Ok((row.get(0)?, row.get(1)?)),
467 )
468 .with_context(|| format!("find resumable session {session_id:?}"))?;
469 ensure!(
470 matches!(
471 stored_session_state(&state),
472 SessionState::Stopped | SessionState::Lost | SessionState::Error
473 ),
474 "session {session_id} is not resumable"
475 );
476 let destination_exists: bool = tx.query_row(
477 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
478 [workspace_id],
479 |row| row.get(0),
480 )?;
481 ensure!(destination_exists, "unknown workspace {workspace_id:?}");
482 if current_workspace != workspace_id {
483 tx.execute(
484 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
485 params![session_id, workspace_id],
486 )?;
487 }
488 tx.commit()?;
489 Ok(())
490}
491
492pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
493 open_reader(path)?
494 .query_row(
495 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
496 [session_id],
497 |row| row.get(0),
498 )
499 .optional()
500 .map_err(Into::into)
501}
502
503pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
504 session_ids_for_workspace_at(&database_path(), workspace_id)
505}
506
507pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
510 let connection = open_reader(path)?;
511 let mut statement = connection.prepare(
512 "SELECT c.session_id
513 FROM session_contexts c
514 JOIN sessions s USING(session_id)
515 WHERE c.workspace_id = ?1
516 ORDER BY c.created_at, c.session_id",
517 )?;
518 let rows = statement.query_map([workspace_id], |row| row.get(0))?;
519 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
520}
521
522pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
525 let session_id = session_id.to_owned();
526 let workspace_id = workspace_id.to_owned();
527 submit_database_write("assign_new_session_workspace", move |_| {
528 assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
529 })
530}
531
532pub fn assign_new_session_workspace_at(
533 path: &Path,
534 session_id: &str,
535 workspace_id: &str,
536) -> Result<()> {
537 let connection = open(path)?;
538 let current: String = connection
539 .query_row(
540 "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
541 [session_id],
542 |row| row.get(0),
543 )
544 .with_context(|| format!("find session context {session_id:?}"))?;
545 if current == workspace_id {
546 return Ok(());
547 }
548 ensure!(
549 current == DEFAULT_WORKSPACE_ID,
550 "session {session_id} already belongs to workspace {current}"
551 );
552 let exists: bool = connection.query_row(
553 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
554 [workspace_id],
555 |row| row.get(0),
556 )?;
557 ensure!(exists, "unknown workspace {workspace_id:?}");
558 connection.execute(
559 "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
560 params![session_id, workspace_id],
561 )?;
562 Ok(())
563}