1use anyhow::Result;
2use db::{
3 query,
4 sqlez::{
5 bindable::{Bind, Column, StaticColumnCount},
6 domain::Domain,
7 statement::Statement,
8 },
9 sqlez_macros::sql,
10};
11use fs::MTime;
12use itertools::Itertools as _;
13use std::{
14 path::{Path, PathBuf},
15 sync::Arc,
16};
17
18use workspace::{ItemId, WorkspaceDb, WorkspaceId};
19
20#[derive(Clone, Debug, PartialEq, Default)]
21pub(crate) struct SerializedEditor {
22 pub(crate) abs_path: Option<PathBuf>,
23 pub(crate) contents: Option<String>,
24 pub(crate) language: Option<String>,
25 pub(crate) mtime: Option<MTime>,
26}
27
28impl StaticColumnCount for SerializedEditor {
29 fn column_count() -> usize {
30 6
31 }
32}
33
34impl Bind for SerializedEditor {
35 fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
36 let start_index = statement.bind(&self.abs_path, start_index)?;
37 let start_index = statement.bind(
38 &self
39 .abs_path
40 .as_ref()
41 .map(|p| p.to_string_lossy().into_owned()),
42 start_index,
43 )?;
44 let start_index = statement.bind(&self.contents, start_index)?;
45 let start_index = statement.bind(&self.language, start_index)?;
46
47 let start_index = match self
48 .mtime
49 .and_then(|mtime| mtime.to_seconds_and_nanos_for_persistence())
50 {
51 Some((seconds, nanos)) => {
52 let start_index = statement.bind(&(seconds as i64), start_index)?;
53 statement.bind(&(nanos as i32), start_index)?
54 }
55 None => {
56 let start_index = statement.bind::<Option<i64>>(&None, start_index)?;
57 statement.bind::<Option<i32>>(&None, start_index)?
58 }
59 };
60 Ok(start_index)
61 }
62}
63
64impl Column for SerializedEditor {
65 fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
66 let (abs_path, start_index): (Option<PathBuf>, i32) =
67 Column::column(statement, start_index)?;
68 let (_abs_path, start_index): (Option<PathBuf>, i32) =
69 Column::column(statement, start_index)?;
70 let (contents, start_index): (Option<String>, i32) =
71 Column::column(statement, start_index)?;
72 let (language, start_index): (Option<String>, i32) =
73 Column::column(statement, start_index)?;
74 let (mtime_seconds, start_index): (Option<i64>, i32) =
75 Column::column(statement, start_index)?;
76 let (mtime_nanos, start_index): (Option<i32>, i32) =
77 Column::column(statement, start_index)?;
78
79 let mtime = mtime_seconds
80 .zip(mtime_nanos)
81 .map(|(seconds, nanos)| MTime::from_seconds_and_nanos(seconds as u64, nanos as u32));
82
83 let editor = Self {
84 abs_path,
85 contents,
86 language,
87 mtime,
88 };
89 Ok((editor, start_index))
90 }
91}
92
93pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
94
95impl Domain for EditorDb {
96 const NAME: &str = stringify!(EditorDb);
97
98 // Current schema shape using pseudo-rust syntax:
99 // editors(
100 // item_id: usize,
101 // workspace_id: usize,
102 // path: Option<PathBuf>,
103 // scroll_top_row: usize,
104 // scroll_vertical_offset: f32,
105 // scroll_horizontal_offset: f32,
106 // contents: Option<String>,
107 // language: Option<String>,
108 // mtime_seconds: Option<i64>,
109 // mtime_nanos: Option<i32>,
110 // )
111 //
112 // editor_selections(
113 // item_id: usize,
114 // editor_id: usize,
115 // workspace_id: usize,
116 // start: usize,
117 // end: usize,
118 // )
119 //
120 // editor_folds(
121 // item_id: usize,
122 // editor_id: usize,
123 // workspace_id: usize,
124 // start: usize,
125 // end: usize,
126 // start_fingerprint: Option<String>,
127 // end_fingerprint: Option<String>,
128 // )
129
130 const MIGRATIONS: &[&str] = &[
131 sql! (
132 CREATE TABLE editors(
133 item_id INTEGER NOT NULL,
134 workspace_id INTEGER NOT NULL,
135 path BLOB NOT NULL,
136 PRIMARY KEY(item_id, workspace_id),
137 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
138 ON DELETE CASCADE
139 ON UPDATE CASCADE
140 ) STRICT;
141 ),
142 sql! (
143 ALTER TABLE editors ADD COLUMN scroll_top_row INTEGER NOT NULL DEFAULT 0;
144 ALTER TABLE editors ADD COLUMN scroll_horizontal_offset REAL NOT NULL DEFAULT 0;
145 ALTER TABLE editors ADD COLUMN scroll_vertical_offset REAL NOT NULL DEFAULT 0;
146 ),
147 sql! (
148 // Since sqlite3 doesn't support ALTER COLUMN, we create a new
149 // table, move the data over, drop the old table, rename new table.
150 CREATE TABLE new_editors_tmp (
151 item_id INTEGER NOT NULL,
152 workspace_id INTEGER NOT NULL,
153 path BLOB, // <-- No longer "NOT NULL"
154 scroll_top_row INTEGER NOT NULL DEFAULT 0,
155 scroll_horizontal_offset REAL NOT NULL DEFAULT 0,
156 scroll_vertical_offset REAL NOT NULL DEFAULT 0,
157 contents TEXT, // New
158 language TEXT, // New
159 PRIMARY KEY(item_id, workspace_id),
160 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
161 ON DELETE CASCADE
162 ON UPDATE CASCADE
163 ) STRICT;
164
165 INSERT INTO new_editors_tmp(item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset)
166 SELECT item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
167 FROM editors;
168
169 DROP TABLE editors;
170
171 ALTER TABLE new_editors_tmp RENAME TO editors;
172 ),
173 sql! (
174 ALTER TABLE editors ADD COLUMN mtime_seconds INTEGER DEFAULT NULL;
175 ALTER TABLE editors ADD COLUMN mtime_nanos INTEGER DEFAULT NULL;
176 ),
177 sql! (
178 CREATE TABLE editor_selections (
179 item_id INTEGER NOT NULL,
180 editor_id INTEGER NOT NULL,
181 workspace_id INTEGER NOT NULL,
182 start INTEGER NOT NULL,
183 end INTEGER NOT NULL,
184 PRIMARY KEY(item_id),
185 FOREIGN KEY(editor_id, workspace_id) REFERENCES editors(item_id, workspace_id)
186 ON DELETE CASCADE
187 ) STRICT;
188 ),
189 sql! (
190 ALTER TABLE editors ADD COLUMN buffer_path TEXT;
191 UPDATE editors SET buffer_path = CAST(path AS TEXT);
192 ),
193 sql! (
194 CREATE TABLE editor_folds (
195 item_id INTEGER NOT NULL,
196 editor_id INTEGER NOT NULL,
197 workspace_id INTEGER NOT NULL,
198 start INTEGER NOT NULL,
199 end INTEGER NOT NULL,
200 PRIMARY KEY(item_id),
201 FOREIGN KEY(editor_id, workspace_id) REFERENCES editors(item_id, workspace_id)
202 ON DELETE CASCADE
203 ) STRICT;
204 ),
205 sql! (
206 ALTER TABLE editor_folds ADD COLUMN start_fingerprint TEXT;
207 ALTER TABLE editor_folds ADD COLUMN end_fingerprint TEXT;
208 ),
209 // File-level fold persistence: store folds by file path instead of editor_id.
210 // This allows folds to survive tab close and workspace cleanup.
211 // Follows the breakpoints pattern in workspace/src/persistence.rs.
212 sql! (
213 CREATE TABLE file_folds (
214 workspace_id INTEGER NOT NULL,
215 path TEXT NOT NULL,
216 start INTEGER NOT NULL,
217 end INTEGER NOT NULL,
218 start_fingerprint TEXT,
219 end_fingerprint TEXT,
220 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
221 ON DELETE CASCADE
222 ON UPDATE CASCADE,
223 PRIMARY KEY(workspace_id, path, start)
224 );
225 ),
226 ];
227}
228
229db::static_connection!(DB, EditorDb, [WorkspaceDb]);
230
231// https://www.sqlite.org/limits.html
232// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
233// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
234const MAX_QUERY_PLACEHOLDERS: usize = 32000;
235
236impl EditorDb {
237 query! {
238 pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<SerializedEditor>> {
239 SELECT path, buffer_path, contents, language, mtime_seconds, mtime_nanos FROM editors
240 WHERE item_id = ? AND workspace_id = ?
241 }
242 }
243
244 query! {
245 pub async fn save_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId, serialized_editor: SerializedEditor) -> Result<()> {
246 INSERT INTO editors
247 (item_id, workspace_id, path, buffer_path, contents, language, mtime_seconds, mtime_nanos)
248 VALUES
249 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
250 ON CONFLICT DO UPDATE SET
251 item_id = ?1,
252 workspace_id = ?2,
253 path = ?3,
254 buffer_path = ?4,
255 contents = ?5,
256 language = ?6,
257 mtime_seconds = ?7,
258 mtime_nanos = ?8
259 }
260 }
261
262 // Returns the scroll top row, and offset
263 query! {
264 pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(u32, f64, f64)>> {
265 SELECT scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
266 FROM editors
267 WHERE item_id = ? AND workspace_id = ?
268 }
269 }
270
271 query! {
272 pub async fn save_scroll_position(
273 item_id: ItemId,
274 workspace_id: WorkspaceId,
275 top_row: u32,
276 vertical_offset: f64,
277 horizontal_offset: f64
278 ) -> Result<()> {
279 UPDATE OR IGNORE editors
280 SET
281 scroll_top_row = ?3,
282 scroll_horizontal_offset = ?4,
283 scroll_vertical_offset = ?5
284 WHERE item_id = ?1 AND workspace_id = ?2
285 }
286 }
287
288 query! {
289 pub fn get_editor_selections(
290 editor_id: ItemId,
291 workspace_id: WorkspaceId
292 ) -> Result<Vec<(usize, usize)>> {
293 SELECT start, end
294 FROM editor_selections
295 WHERE editor_id = ?1 AND workspace_id = ?2
296 }
297 }
298
299 query! {
300 pub fn get_editor_folds(
301 editor_id: ItemId,
302 workspace_id: WorkspaceId
303 ) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
304 SELECT start, end, start_fingerprint, end_fingerprint
305 FROM editor_folds
306 WHERE editor_id = ?1 AND workspace_id = ?2
307 }
308 }
309
310 query! {
311 pub fn get_file_folds(
312 workspace_id: WorkspaceId,
313 path: &Path
314 ) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
315 SELECT start, end, start_fingerprint, end_fingerprint
316 FROM file_folds
317 WHERE workspace_id = ?1 AND path = ?2
318 ORDER BY start
319 }
320 }
321
322 pub async fn save_editor_selections(
323 &self,
324 editor_id: ItemId,
325 workspace_id: WorkspaceId,
326 selections: Vec<(usize, usize)>,
327 ) -> Result<()> {
328 log::debug!("Saving selections for editor {editor_id} in workspace {workspace_id:?}");
329 let mut first_selection;
330 let mut last_selection = 0_usize;
331 for (count, placeholders) in std::iter::once("(?1, ?2, ?, ?)")
332 .cycle()
333 .take(selections.len())
334 .chunks(MAX_QUERY_PLACEHOLDERS / 4)
335 .into_iter()
336 .map(|chunk| {
337 let mut count = 0;
338 let placeholders = chunk
339 .inspect(|_| {
340 count += 1;
341 })
342 .join(", ");
343 (count, placeholders)
344 })
345 .collect::<Vec<_>>()
346 {
347 first_selection = last_selection;
348 last_selection = last_selection + count;
349 let query = format!(
350 r#"
351DELETE FROM editor_selections WHERE editor_id = ?1 AND workspace_id = ?2;
352
353INSERT OR IGNORE INTO editor_selections (editor_id, workspace_id, start, end)
354VALUES {placeholders};
355"#
356 );
357
358 let selections = selections[first_selection..last_selection].to_vec();
359 self.write(move |conn| {
360 let mut statement = Statement::prepare(conn, query)?;
361 statement.bind(&editor_id, 1)?;
362 let mut next_index = statement.bind(&workspace_id, 2)?;
363 for (start, end) in selections {
364 next_index = statement.bind(&start, next_index)?;
365 next_index = statement.bind(&end, next_index)?;
366 }
367 statement.exec()
368 })
369 .await?;
370 }
371 Ok(())
372 }
373
374 pub async fn save_file_folds(
375 &self,
376 workspace_id: WorkspaceId,
377 path: Arc<Path>,
378 folds: Vec<(usize, usize, String, String)>,
379 ) -> Result<()> {
380 log::debug!("Saving folds for file {path:?} in workspace {workspace_id:?}");
381 self.write(move |conn| {
382 // Clear existing folds for this file
383 conn.exec_bound(sql!(
384 DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
385 ))?((workspace_id, path.as_ref()))?;
386
387 // Insert each fold (matches breakpoints pattern)
388 for (start, end, start_fp, end_fp) in folds {
389 conn.exec_bound(sql!(
390 INSERT INTO file_folds (workspace_id, path, start, end, start_fingerprint, end_fingerprint)
391 VALUES (?1, ?2, ?3, ?4, ?5, ?6);
392 ))?((workspace_id, path.as_ref(), start, end, start_fp, end_fp))?;
393 }
394 Ok(())
395 })
396 .await
397 }
398
399 pub async fn delete_file_folds(
400 &self,
401 workspace_id: WorkspaceId,
402 path: Arc<Path>,
403 ) -> Result<()> {
404 self.write(move |conn| {
405 conn.exec_bound(sql!(
406 DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
407 ))?((workspace_id, path.as_ref()))
408 })
409 .await
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[gpui::test]
418 async fn test_save_and_get_serialized_editor() {
419 let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
420
421 let serialized_editor = SerializedEditor {
422 abs_path: Some(PathBuf::from("testing.txt")),
423 contents: None,
424 language: None,
425 mtime: None,
426 };
427
428 DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
429 .await
430 .unwrap();
431
432 let have = DB
433 .get_serialized_editor(1234, workspace_id)
434 .unwrap()
435 .unwrap();
436 assert_eq!(have, serialized_editor);
437
438 // Now update contents and language
439 let serialized_editor = SerializedEditor {
440 abs_path: Some(PathBuf::from("testing.txt")),
441 contents: Some("Test".to_owned()),
442 language: Some("Go".to_owned()),
443 mtime: None,
444 };
445
446 DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
447 .await
448 .unwrap();
449
450 let have = DB
451 .get_serialized_editor(1234, workspace_id)
452 .unwrap()
453 .unwrap();
454 assert_eq!(have, serialized_editor);
455
456 // Now set all the fields to NULL
457 let serialized_editor = SerializedEditor {
458 abs_path: None,
459 contents: None,
460 language: None,
461 mtime: None,
462 };
463
464 DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
465 .await
466 .unwrap();
467
468 let have = DB
469 .get_serialized_editor(1234, workspace_id)
470 .unwrap()
471 .unwrap();
472 assert_eq!(have, serialized_editor);
473
474 // Storing and retrieving mtime
475 let serialized_editor = SerializedEditor {
476 abs_path: None,
477 contents: None,
478 language: None,
479 mtime: Some(MTime::from_seconds_and_nanos(100, 42)),
480 };
481
482 DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
483 .await
484 .unwrap();
485
486 let have = DB
487 .get_serialized_editor(1234, workspace_id)
488 .unwrap()
489 .unwrap();
490 assert_eq!(have, serialized_editor);
491 }
492
493 // NOTE: The fingerprint search logic (finding content at new offsets when file
494 // is modified externally) is in editor.rs:restore_from_db and requires a full
495 // Editor context to test. Manual testing procedure:
496 // 1. Open a file, fold some sections, close Zed
497 // 2. Add text at the START of the file externally (shifts all offsets)
498 // 3. Reopen Zed - folds should be restored at their NEW correct positions
499 // The search uses contains_str_at() to find fingerprints in the buffer.
500
501 #[gpui::test]
502 async fn test_save_and_get_file_folds() {
503 let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
504
505 // file_folds table uses path as key (no FK to editors table)
506 let file_path: Arc<Path> = Arc::from(Path::new("/tmp/test_file_folds.rs"));
507
508 // Save folds with fingerprints
509 let folds = vec![
510 (
511 100,
512 200,
513 "fn main() {".to_string(),
514 "} // end main".to_string(),
515 ),
516 (
517 300,
518 400,
519 "struct Foo {".to_string(),
520 "} // end Foo".to_string(),
521 ),
522 ];
523 DB.save_file_folds(workspace_id, file_path.clone(), folds.clone())
524 .await
525 .unwrap();
526
527 // Retrieve and verify fingerprints are preserved
528 let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
529 assert_eq!(retrieved.len(), 2);
530 assert_eq!(
531 retrieved[0],
532 (
533 100,
534 200,
535 Some("fn main() {".to_string()),
536 Some("} // end main".to_string())
537 )
538 );
539 assert_eq!(
540 retrieved[1],
541 (
542 300,
543 400,
544 Some("struct Foo {".to_string()),
545 Some("} // end Foo".to_string())
546 )
547 );
548
549 // Test overwrite: saving new folds replaces old ones
550 let new_folds = vec![(
551 500,
552 600,
553 "impl Bar {".to_string(),
554 "} // end impl".to_string(),
555 )];
556 DB.save_file_folds(workspace_id, file_path.clone(), new_folds)
557 .await
558 .unwrap();
559
560 let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
561 assert_eq!(retrieved.len(), 1);
562 assert_eq!(
563 retrieved[0],
564 (
565 500,
566 600,
567 Some("impl Bar {".to_string()),
568 Some("} // end impl".to_string())
569 )
570 );
571
572 // Test delete
573 DB.delete_file_folds(workspace_id, file_path.clone())
574 .await
575 .unwrap();
576 let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
577 assert!(retrieved.is_empty());
578
579 // Test multiple files don't interfere
580 let file_path_a: Arc<Path> = Arc::from(Path::new("/tmp/file_a.rs"));
581 let file_path_b: Arc<Path> = Arc::from(Path::new("/tmp/file_b.rs"));
582 let folds_a = vec![(10, 20, "a_start".to_string(), "a_end".to_string())];
583 let folds_b = vec![(30, 40, "b_start".to_string(), "b_end".to_string())];
584
585 DB.save_file_folds(workspace_id, file_path_a.clone(), folds_a)
586 .await
587 .unwrap();
588 DB.save_file_folds(workspace_id, file_path_b.clone(), folds_b)
589 .await
590 .unwrap();
591
592 let retrieved_a = DB.get_file_folds(workspace_id, &file_path_a).unwrap();
593 let retrieved_b = DB.get_file_folds(workspace_id, &file_path_b).unwrap();
594
595 assert_eq!(retrieved_a.len(), 1);
596 assert_eq!(retrieved_b.len(), 1);
597 assert_eq!(retrieved_a[0].0, 10); // file_a's fold
598 assert_eq!(retrieved_b[0].0, 30); // file_b's fold
599 }
600}