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