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