diff --git a/apps/staged/package.json b/apps/staged/package.json index 4a5811845..0d4851483 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -47,6 +47,8 @@ }, "dependencies": { "@builderbot/diff-viewer": "workspace:*", + "@milkdown/crepe": "^7.22.1", + "@milkdown/kit": "^7.22.1", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 44968f501..3a48186cf 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -181,6 +181,9 @@ pub struct NoteTimelineItem { pub completed_at: Option, pub suggested_next_commit_step: Option, pub suggested_next_note_step: Option, + /// `None` for session-produced notes, `"written"` for user-authored ones — + /// the frontend routes the latter to the editor instead of the viewer. + pub subtype: Option, } /// Review with session status resolved. @@ -2315,6 +2318,7 @@ pub fn run() { timeline::reset_branch_to_remote, // Notes note_commands::create_note, + note_commands::update_note, note_commands::delete_note, note_commands::get_note, note_commands::list_child_notes, diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 90c750955..6731bced0 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -24,35 +24,66 @@ pub(crate) fn note_to_timeline_item(store: &Store, note: Note) -> NoteTimelineIt completed_at: note.completed_at, suggested_next_commit_step: note.suggested_next_commit_step, suggested_next_note_step: note.suggested_next_note_step, + subtype: note.subtype, + } +} + +/// Build the timeline item for a note that has no session by construction, so +/// there is no session status to resolve. Shared with the web-server dispatch. +pub(crate) fn standalone_note_to_timeline_item(note: Note) -> NoteTimelineItem { + NoteTimelineItem { + id: note.id, + title: note.title, + content: note.content, + session_id: None, + session_status: None, + completion_reason: None, + created_at: note.created_at, + updated_at: note.updated_at, + completed_at: note.completed_at, + suggested_next_commit_step: None, + suggested_next_note_step: None, + subtype: note.subtype, } } /// Create a standalone note (no session) for a branch. +/// +/// `subtype` is `"written"` when the user authored the note in the editor +/// dialog; the drag-drop and save-action-output paths leave it unset. #[tauri::command(rename_all = "camelCase")] pub fn create_note( store: tauri::State<'_, Mutex>>>, branch_id: String, title: String, content: String, + subtype: Option, ) -> Result { let store = crate::get_store(&store)?; let mut note = crate::store::models::Note::new(&branch_id, &title, &content); + note.subtype = subtype; store .create_note_with_unique_title(&mut note) .map_err(|e| e.to_string())?; - Ok(NoteTimelineItem { - id: note.id, - title: note.title, - content: note.content, - session_id: None, - session_status: None, - completion_reason: None, - created_at: note.created_at, - updated_at: note.updated_at, - completed_at: note.completed_at, - suggested_next_commit_step: None, - suggested_next_note_step: None, - }) + Ok(standalone_note_to_timeline_item(note)) +} + +/// Save an edit to a user-authored ("written") note. +/// +/// Rejects notes an agent session produced — their content is owned by that +/// session and would be overwritten on its next turn. +#[tauri::command(rename_all = "camelCase")] +pub fn update_note( + store: tauri::State<'_, Mutex>>>, + note_id: String, + title: String, + content: String, +) -> Result { + let store = crate::get_store(&store)?; + let note = store + .update_written_note(¬e_id, &title, &content) + .map_err(|e| e.to_string())?; + Ok(standalone_note_to_timeline_item(note)) } /// Delete a note and optionally its linked session. diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 57a61730c..5378229b4 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); @@ -207,7 +207,7 @@ fn test_store_repairs_github_comment_tracking_user_version() { created_at INTEGER NOT NULL, image_ids TEXT DEFAULT NULL ); - CREATE TABLE notes (id TEXT PRIMARY KEY); + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); -- Only the table/column the 0026 auto-review cleanup targets. CREATE TABLE reviews ( id TEXT PRIMARY KEY, @@ -245,7 +245,7 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert!(column_exists(&conn, "sessions", "pipeline")); assert!(column_exists(&conn, "sessions", "acp_config_selection")); assert!(column_exists(&conn, "sessions", "acp_title")); @@ -287,7 +287,7 @@ fn test_store_repairs_pipeline_user_version() { created_at INTEGER NOT NULL, image_ids TEXT DEFAULT NULL ); - CREATE TABLE notes (id TEXT PRIMARY KEY); + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); -- Only the table/column the 0026 auto-review cleanup targets. CREATE TABLE reviews ( id TEXT PRIMARY KEY, @@ -320,7 +320,7 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); @@ -366,8 +366,8 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { id TEXT PRIMARY KEY, detecting_actions INTEGER NOT NULL DEFAULT 0 ); - -- Only the table the 0025 column add targets. - CREATE TABLE notes (id TEXT PRIMARY KEY); + -- Only the table the 0025/0027 note column adds target. + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); -- Only the table/column the 0026 auto-review cleanup targets. CREATE TABLE reviews ( id TEXT PRIMARY KEY, @@ -385,7 +385,7 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert!(column_exists(&conn, "sessions", "completion_effects_at")); let marker = |id: &str| -> Option { @@ -427,6 +427,8 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() { INSERT INTO reviews (id, is_auto) VALUES ('user-review', 0), ('auto-review', 1); + -- Only the table the 0027 note column add targets. + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); ", ) .unwrap(); @@ -439,7 +441,7 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert!(!column_exists(&conn, "reviews", "is_auto")); // Reviews the removed auto-review feature created in the background are @@ -475,8 +477,8 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { INSERT INTO action_contexts (id, detecting_actions) VALUES ('wedged', 1), ('idle', 0); - -- Only the table the 0025 column add targets. - CREATE TABLE notes (id TEXT PRIMARY KEY); + -- Only the table the 0025/0027 note column adds target. + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); -- Only the table/column the 0026 auto-review cleanup targets. CREATE TABLE reviews ( id TEXT PRIMARY KEY, @@ -494,7 +496,7 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 26); + assert_eq!(version, 27); assert!(column_exists(&conn, "action_contexts", "detecting_pid")); // No shipped build ever cleared the flag from outside the process that set @@ -511,3 +513,60 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { cleanup_db(&path); } + +#[test] +fn test_note_subtype_migration_backfills_session_less_notes() { + let path = temp_db_path("note-subtype-backfill"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + " + PRAGMA user_version = 25; + CREATE TABLE app_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + app_version TEXT NOT NULL + ); + INSERT INTO app_metadata (id, app_version) VALUES (1, '0.2.9'); + CREATE TABLE notes ( + id TEXT PRIMARY KEY, + session_id TEXT + ); + INSERT INTO notes (id, session_id) VALUES + ('dropped', NULL), + ('agent', 'session-1'); + -- Only the table/column the 0026 auto-review cleanup targets. + CREATE TABLE reviews ( + id TEXT PRIMARY KEY, + is_auto INTEGER NOT NULL DEFAULT 0 + ); + ", + ) + .unwrap(); + drop(conn); + + let store = Store::new(&path).unwrap(); + drop(store); + + let conn = Connection::open(&path).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 27); + assert!(column_exists(&conn, "notes", "subtype")); + + let subtype = |id: &str| -> Option { + conn.query_row( + "SELECT subtype FROM notes WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .unwrap() + }; + + // Drag-dropped files and saved action output are already user-authored + // notes with no owning session, so they become editable alongside newly + // written ones. Notes an agent produced stay untagged. + assert_eq!(subtype("dropped").as_deref(), Some("written")); + assert_eq!(subtype("agent"), None); + + cleanup_db(&path); +} diff --git a/apps/staged/src-tauri/src/store/migrations/0027-add-note-subtype/up.sql b/apps/staged/src-tauri/src/store/migrations/0027-add-note-subtype/up.sql new file mode 100644 index 000000000..156189d9b --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0027-add-note-subtype/up.sql @@ -0,0 +1,8 @@ +-- Distinguishes user-authored notes (written directly in the editor dialog) +-- from agent/session notes. NULL = produced by a session; 'written' = authored +-- by the user and therefore editable in place. +ALTER TABLE notes ADD COLUMN subtype TEXT; + +-- Existing session-less notes (drag-dropped files, saved action output) are +-- exactly the user-authored class, so they become editable too. +UPDATE notes SET subtype = 'written' WHERE session_id IS NULL; diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 2500371cf..6b49a7d34 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -891,9 +891,17 @@ pub struct Note { /// rather than a standalone branch note. Children are hidden from the /// branch timeline and fetched via the parent project-note view. pub parent_project_note_id: Option, + /// How the note's content came to be. `None` means a session produced it; + /// [`Note::SUBTYPE_WRITTEN`] means the user authored it directly and it can + /// be edited in place. + pub subtype: Option, } impl Note { + /// Subtype marking a note the user wrote themselves rather than one an + /// agent session produced. Only these are editable via `update_note`. + pub const SUBTYPE_WRITTEN: &'static str = "written"; + pub fn new(branch_id: &str, title: &str, content: &str) -> Self { let now = now_timestamp(); let has_content = !content.is_empty(); @@ -909,6 +917,7 @@ impl Note { suggested_next_commit_step: None, suggested_next_note_step: None, parent_project_note_id: None, + subtype: None, } } @@ -921,6 +930,15 @@ impl Note { self.parent_project_note_id = Some(id.to_string()); self } + + pub fn with_subtype(mut self, subtype: &str) -> Self { + self.subtype = Some(subtype.to_string()); + self + } + + pub fn is_written(&self) -> bool { + self.subtype.as_deref() == Some(Self::SUBTYPE_WRITTEN) + } } // ============================================================================= diff --git a/apps/staged/src-tauri/src/store/notes.rs b/apps/staged/src-tauri/src/store/notes.rs index 61421029b..173771035 100644 --- a/apps/staged/src-tauri/src/store/notes.rs +++ b/apps/staged/src-tauri/src/store/notes.rs @@ -27,7 +27,8 @@ impl Store { pub fn create_note_with_unique_title(&self, note: &mut Note) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); if !note.title.is_empty() { - note.title = Self::resolve_unique_note_title(&conn, ¬e.branch_id, ¬e.title)?; + note.title = + Self::resolve_unique_note_title(&conn, ¬e.branch_id, ¬e.title, None)?; } Self::insert_note(&conn, note)?; self.publish(StoreChange::Notes { @@ -39,8 +40,8 @@ impl Store { fn insert_note(conn: &rusqlite::Connection, note: &Note) -> Result<(), StoreError> { conn.execute( - "INSERT INTO notes (id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO notes (id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ note.id, note.branch_id, @@ -53,19 +54,25 @@ impl Store { note.suggested_next_commit_step, note.suggested_next_note_step, note.parent_project_note_id, + note.subtype, ], )?; Ok(()) } + /// Pick a title that is unique among the branch's notes. `exclude_id` is the + /// note being renamed, so re-saving a written note under its own title keeps + /// that title instead of collecting a ` (2)` suffix on every save. fn resolve_unique_note_title( conn: &rusqlite::Connection, branch_id: &str, base: &str, + exclude_id: Option<&str>, ) -> Result { - let mut stmt = conn.prepare("SELECT title FROM notes WHERE branch_id = ?1")?; + let mut stmt = + conn.prepare("SELECT title FROM notes WHERE branch_id = ?1 AND id IS NOT ?2")?; let titles: Vec = stmt - .query_map(params![branch_id], |row| row.get(0))? + .query_map(params![branch_id, exclude_id], |row| row.get(0))? .collect::>()?; if !titles.iter().any(|t| t == base) { @@ -90,7 +97,7 @@ impl Store { pub fn get_note(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE id = ?1", params![id], Self::row_to_note, @@ -106,7 +113,7 @@ impl Store { pub fn list_notes_for_branch(&self, branch_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE branch_id = ?1 AND parent_project_note_id IS NULL ORDER BY COALESCE(completed_at, created_at) DESC, created_at DESC", )?; @@ -120,7 +127,7 @@ impl Store { pub fn list_all_notes_for_branch(&self, branch_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE branch_id = ?1 ORDER BY COALESCE(completed_at, created_at) DESC, created_at DESC", )?; @@ -135,7 +142,7 @@ impl Store { pub fn list_child_notes(&self, parent_project_note_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE parent_project_note_id = ?1 ORDER BY COALESCE(completed_at, created_at) DESC, created_at DESC", )?; @@ -147,7 +154,7 @@ impl Store { pub fn get_note_by_session(&self, session_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE session_id = ?1", params![session_id], Self::row_to_note, @@ -160,7 +167,7 @@ impl Store { pub fn get_empty_note_by_session(&self, session_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype FROM notes WHERE session_id = ?1 AND content = ''", params![session_id], Self::row_to_note, @@ -226,6 +233,64 @@ impl Store { Ok(()) } + /// Rewrite a user-authored note's title and content, returning the saved row. + /// + /// Unlike [`Store::update_note_title_and_content`] (the session runner's path) + /// this always advances `updated_at` — a user save is an explicit edit, not a + /// re-extraction — and leaves the suggested next steps alone, since nothing + /// regenerates them for a written note. Notes an agent produced are rejected: + /// their content belongs to the session that wrote it. + pub fn update_written_note( + &self, + id: &str, + title: &str, + content: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let existing = conn + .query_row( + "SELECT id, branch_id, session_id, title, content, created_at, updated_at, completed_at, suggested_next_commit_step, suggested_next_note_step, parent_project_note_id, subtype + FROM notes WHERE id = ?1", + params![id], + Self::row_to_note, + ) + .optional()? + .ok_or_else(|| StoreError(format!("Note not found: {id}")))?; + + if !existing.is_written() { + return Err(StoreError(format!( + "Note {id} was produced by a session and cannot be edited" + ))); + } + + let title = if title.is_empty() { + title.to_string() + } else { + Self::resolve_unique_note_title(&conn, &existing.branch_id, title, Some(id))? + }; + let now = now_timestamp(); + let completed_at = + existing + .completed_at + .or(if content.is_empty() { None } else { Some(now) }); + conn.execute( + "UPDATE notes SET title = ?1, content = ?2, updated_at = ?3, completed_at = ?4 WHERE id = ?5", + params![title, content, now, completed_at, id], + )?; + self.publish(StoreChange::Notes { + branch_id: Some(existing.branch_id.clone()), + project_id: None, + }); + + Ok(Note { + title, + content: content.to_string(), + updated_at: now, + completed_at, + ..existing + }) + } + pub fn mark_note_completed(&self, id: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); let now = now_timestamp(); @@ -267,6 +332,7 @@ impl Store { suggested_next_commit_step: row.get(8)?, suggested_next_note_step: row.get(9)?, parent_project_note_id: row.get(10)?, + subtype: row.get(11)?, }) } } diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index c3cb59b77..623cb3367 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -2337,6 +2337,100 @@ fn test_create_note_with_unique_title_skips_empty_titles() { assert_eq!(second.title, ""); } +#[test] +fn test_written_note_round_trips_its_subtype() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + let mut written = Note::new(&branch.id, "Design sketch", "# Design sketch") + .with_subtype(Note::SUBTYPE_WRITTEN); + store.create_note_with_unique_title(&mut written).unwrap(); + let agent = Note::new(&branch.id, "Agent note", "body").with_session("session-1"); + store.create_note(&agent).unwrap(); + + assert!(store.get_note(&written.id).unwrap().unwrap().is_written()); + assert!(!store.get_note(&agent.id).unwrap().unwrap().is_written()); +} + +#[test] +fn test_update_written_note_rewrites_title_and_content() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + let mut note = Note::new(&branch.id, "Draft", "# Draft").with_subtype(Note::SUBTYPE_WRITTEN); + store.create_note_with_unique_title(&mut note).unwrap(); + let created_completed_at = note.completed_at; + + let updated = store + .update_written_note(¬e.id, "Final", "# Final\n\nbody") + .unwrap(); + assert_eq!(updated.title, "Final"); + assert_eq!(updated.content, "# Final\n\nbody"); + // Completion is write-once: the note has been readable since it was saved. + assert_eq!(updated.completed_at, created_completed_at); + + let stored = store.get_note(¬e.id).unwrap().unwrap(); + assert_eq!(stored.title, "Final"); + assert_eq!(stored.content, "# Final\n\nbody"); + assert!(stored.is_written()); +} + +#[test] +fn test_update_written_note_keeps_its_own_title_but_avoids_others() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + let mut note = Note::new(&branch.id, "Notes", "body").with_subtype(Note::SUBTYPE_WRITTEN); + store.create_note_with_unique_title(&mut note).unwrap(); + let other = Note::new(&branch.id, "Other", "body"); + store.create_note(&other).unwrap(); + + // Re-saving under the same title must not accumulate " (2)" suffixes. + let resaved = store + .update_written_note(¬e.id, "Notes", "body v2") + .unwrap(); + assert_eq!(resaved.title, "Notes"); + + // Renaming onto another note's title still disambiguates. + let renamed = store + .update_written_note(¬e.id, "Other", "body v3") + .unwrap(); + assert_eq!(renamed.title, "Other (2)"); +} + +#[test] +fn test_update_written_note_rejects_session_produced_notes() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + // An agent owns this note's content — its session would overwrite any edit + // on the next turn, so the store refuses rather than silently losing it. + let agent = Note::new(&branch.id, "Agent note", "body").with_session("session-1"); + store.create_note(&agent).unwrap(); + + assert!(store + .update_written_note(&agent.id, "Hijacked", "mine now") + .is_err()); + let unchanged = store.get_note(&agent.id).unwrap().unwrap(); + assert_eq!(unchanged.content, "body"); + + assert!(store + .update_written_note("missing", "Title", "body") + .is_err()); +} + #[test] fn test_list_child_notes_returns_children_and_excludes_them_from_branch_timeline() { let store = Store::in_memory().unwrap(); @@ -3045,6 +3139,42 @@ fn change_feed_publishes_domain_changes_for_mutations() { ); } +#[test] +fn change_feed_written_note_edits_publish_like_other_note_mutations() { + let (tx, mut rx) = tokio::sync::broadcast::channel(64); + let store = Store::in_memory().unwrap().with_change_sender(tx); + + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + let mut note = Note::new(&branch.id, "Draft", "# Draft").with_subtype(Note::SUBTYPE_WRITTEN); + store.create_note_with_unique_title(&mut note).unwrap(); + while rx.try_recv().is_ok() {} + + // Without this, a note edited in one window leaves every other view showing + // the old title and content until some unrelated event refreshes it. + store + .update_written_note(¬e.id, "Final", "# Final") + .unwrap(); + assert_eq!( + rx.try_recv().unwrap(), + super::StoreChange::Notes { + branch_id: Some(branch.id.clone()), + project_id: None + } + ); + + // A rejected edit writes nothing, so it announces nothing. + let agent = Note::new(&branch.id, "Agent note", "body").with_session("session-1"); + store.create_note(&agent).unwrap(); + while rx.try_recv().is_ok() {} + assert!(store + .update_written_note(&agent.id, "Hijacked", "mine now") + .is_err()); + assert!(rx.try_recv().is_err()); +} + #[test] fn change_feed_ensure_review_publishes_only_on_create() { let (tx, mut rx) = tokio::sync::broadcast::channel(64); diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 67266d4c9..809e75cac 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -529,6 +529,7 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result Result = opt_arg(&args, "subtype")?; let mut note = crate::store::models::Note::new(&branch_id, &title, &content); + note.subtype = subtype; store .create_note_with_unique_title(&mut note) .map_err(|e| e.to_string())?; - let item = crate::NoteTimelineItem { - id: note.id, - title: note.title, - content: note.content, - session_id: None, - session_status: None, - completion_reason: None, - created_at: note.created_at, - updated_at: note.updated_at, - completed_at: note.completed_at, - suggested_next_commit_step: None, - suggested_next_note_step: None, - }; + let item = crate::note_commands::standalone_note_to_timeline_item(note); + Ok(serde_json::to_value(item).unwrap()) + } + "update_note" => { + let store = get_store(store_mutex)?; + let note_id: String = arg(&args, "noteId")?; + let title: String = arg(&args, "title")?; + let content: String = arg(&args, "content")?; + let note = store + .update_written_note(¬e_id, &title, &content) + .map_err(|e| e.to_string())?; + let item = crate::note_commands::standalone_note_to_timeline_item(note); Ok(serde_json::to_value(item).unwrap()) } "delete_note" => { diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 2dedb910a..59e71d3eb 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1100,13 +1100,35 @@ export function drainQueuedSessions(branchId: string): Promise { // Timeline item deletion // ============================================================================= -/** Create a standalone note (no session) for a branch. */ +/** + * Create a standalone note (no session) for a branch. + * + * `subtype` is `'written'` when the user authored the note in the editor + * dialog; the drag-drop and save-action-output paths leave it unset. + */ export function createNote( branchId: string, title: string, + content: string, + subtype: import('./types').NoteSubtype = null +): Promise { + return invokeCommand('create_note', { branchId, title, content, subtype }); +} + +/** + * Save an edit to a user-authored ("written") note. Rejects notes an agent + * session produced — their content belongs to that session. + * + * Like the other note mutations this publishes `notes-changed`, so other views + * refresh on their own; the calling card still invalidates locally so its own + * timeline updates without waiting for the round trip. + */ +export function updateNote( + noteId: string, + title: string, content: string -): Promise<{ id: string; title: string; content: string; createdAt: number; updatedAt: number }> { - return invokeCommand('create_note', { branchId, title, content }); +): Promise { + return invokeCommand('update_note', { noteId, title, content }); } /** Delete a note and optionally its linked session. */ diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 3eec5a165..9e6caeaca 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -51,6 +51,7 @@ ProjectRepo, WorkspaceStatus, } from '../../types'; + import { WRITTEN_NOTE_SUBTYPE } from '../../types'; import * as commands from '../../api/commands'; import BranchTimeline from '../timeline/BranchTimeline.svelte'; import ImageViewerModal from '../timeline/ImageViewerModal.svelte'; @@ -58,6 +59,8 @@ import SessionModal from '../sessions/SessionModal.svelte'; import NewSessionModal from '../sessions/NewSessionModal.svelte'; import NoteModal from '../notes/NoteModal.svelte'; + import WriteNoteModal from '../notes/WriteNoteModal.svelte'; + import { splitNoteMarkdown } from '../notes/noteMarkdown'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { @@ -529,6 +532,12 @@ // Note modal (opened by clicking a note in the timeline) let openNote = $state(null); + // Write-note editor. `null` while closed; `{ note: null }` for a new note, + // `{ note }` when editing an existing user-written one. + let openWriteNote = $state<{ + note: { id: string; title: string; content: string } | null; + } | null>(null); + // Image viewer modal (opened by clicking an image in the timeline) let viewImageId = $state(null); let viewImageFilename = $state(''); @@ -998,6 +1007,14 @@ } function handleNoteClick(note: NoteClickInfo) { + // The user wrote this one, so clicking it reopens the editor rather than + // the read-only viewer. + if (note.subtype === WRITTEN_NOTE_SUBTYPE) { + openWriteNote = { + note: { id: note.noteId, title: note.title, content: note.content }, + }; + return; + } openNote = { noteId: note.noteId, title: note.title, @@ -1008,6 +1025,17 @@ }; } + async function handleSaveWrittenNote(draft: { title: string; content: string }) { + const existing = openWriteNote?.note; + if (existing) { + await commands.updateNote(existing.id, draft.title, draft.content); + } else { + await commands.createNote(branch.id, draft.title, draft.content, WRITTEN_NOTE_SUBTYPE); + } + commands.invalidateBranchTimeline(branch.id); + await loadTimeline(); + } + async function handleReviewClick(reviewId: string) { const cached = timelineReviewDetailsById[reviewId]; if (cached) { @@ -1706,8 +1734,12 @@ textPaths.map(async (filePath, i) => { try { const content = await commands.readTextFile(filePath); - const title = fileNameFromPath(filePath); - await commands.createNote(branch.id, title, content); + // Through the same split the editor saves through, so a dropped file + // is stored like any other note: its own leading H1 becomes the + // title (and leaves the body, which the viewer would otherwise show + // straight under the file name), and the file name titles the rest. + const note = splitNoteMarkdown(content, fileNameFromPath(filePath)); + await commands.createNote(branch.id, note.title, note.body); } catch (e) { const reason = e instanceof Error ? e.message : typeof e === 'string' ? e : null; const detail = reason ?? 'it may be a binary file'; @@ -1994,6 +2026,7 @@ onNewReview={hasCodeChanges || sessionMgr.hasCommitSessionInProgress ? (e) => sessionMgr.openNewSession('review', e) : undefined} + onWriteNote={() => (openWriteNote = { note: null })} onPullOrigin={handlePullOrigin} onPushOrigin={handlePushOrigin} onOpenPushSession={pushSessionId && pushSessionId !== '__pending__' @@ -2108,6 +2141,15 @@ /> {/if} +{#if openWriteNote} + (openWriteNote = null)} + /> +{/if} + {#if viewImageId} + + +
+
+ {#if loadError} + + {/if} +
+ + diff --git a/apps/staged/src/lib/features/notes/WriteNoteModal.svelte b/apps/staged/src/lib/features/notes/WriteNoteModal.svelte new file mode 100644 index 000000000..d8d73f705 --- /dev/null +++ b/apps/staged/src/lib/features/notes/WriteNoteModal.svelte @@ -0,0 +1,221 @@ + + + + { + if (!next) requestClose(); + }} +> + e.preventDefault()} + > + +
+ + + {isEdit ? 'Edit note' : 'Write note'} + +
+ +
+
+
+ + +
+ {#key editorKey} + (markdown = next)} + /> + {/key} +
+ + +
+
+ + diff --git a/apps/staged/src/lib/features/notes/noteMarkdown.test.ts b/apps/staged/src/lib/features/notes/noteMarkdown.test.ts index cee12ef2e..bfe275cbc 100644 --- a/apps/staged/src/lib/features/notes/noteMarkdown.test.ts +++ b/apps/staged/src/lib/features/notes/noteMarkdown.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { noteMarkdownWithTitle, renderNoteMarkdown } from './noteMarkdown'; +import { + UNTITLED_NOTE_TITLE, + canBeNoteTitleLine, + canBeNoteTitleText, + noteMarkdownWithTitle, + renderNoteMarkdown, + splitNoteMarkdown, + unescapeMarkdown, +} from './noteMarkdown'; describe('noteMarkdownWithTitle', () => { it('prepends the note title as a markdown H1', () => { @@ -13,9 +21,11 @@ describe('noteMarkdownWithTitle', () => { expect(noteMarkdownWithTitle('Standalone title', '')).toBe('# Standalone title'); }); - it('does not duplicate content that already starts with an H1', () => { - expect(noteMarkdownWithTitle('Stored title', '# Existing title\n\nBody text.')).toBe( - '# Existing title\n\nBody text.' + it('prepends the stored title even when the body opens with its own heading', () => { + // Hiding the title here would show the wrong one and, on the next save, + // store the body's heading as the note's title. + expect(noteMarkdownWithTitle('Stored title', '# Section\n\nBody text.')).toBe( + '# Stored title\n\n# Section\n\nBody text.' ); }); @@ -24,6 +34,236 @@ describe('noteMarkdownWithTitle', () => { }); }); +describe('splitNoteMarkdown', () => { + it('takes the leading H1 as the title and the rest as the body', () => { + expect(splitNoteMarkdown('# Release plan\n\nShip on Friday.')).toEqual({ + title: 'Release plan', + body: 'Ship on Friday.', + }); + }); + + it('handles a note that is only a title', () => { + expect(splitNoteMarkdown('# Release plan')).toEqual({ title: 'Release plan', body: '' }); + }); + + it('round-trips with noteMarkdownWithTitle', () => { + const { title, body } = splitNoteMarkdown('# Release plan\n\nShip on Friday.'); + + expect(noteMarkdownWithTitle(title, body)).toBe('# Release plan\n\nShip on Friday.'); + }); + + it('keeps a body that opens with its own heading out of the title', () => { + const original = '# Release plan\n\n# Risks\n\nShip on Friday.'; + const { title, body } = splitNoteMarkdown(original); + + expect(title).toBe('Release plan'); + expect(body).toBe('# Risks\n\nShip on Friday.'); + expect(noteMarkdownWithTitle(title, body)).toBe(original); + }); + + it('takes the first line as the title when it is not a heading', () => { + expect(splitNoteMarkdown('\n\nJust a thought.\n\nMore.')).toEqual({ + title: 'Just a thought.', + body: 'More.', + }); + }); + + it('strips heading markers from the title', () => { + expect(splitNoteMarkdown('## Overview\n\nDetails.')).toEqual({ + title: 'Overview', + body: 'Details.', + }); + }); + + it('keeps a long title whole', () => { + const long = 'x'.repeat(120); + + // Clipping the title would drop the rest of the line: the body no longer + // holds it, so the note would lose text on every save. + expect(splitNoteMarkdown(long)).toEqual({ title: long, body: '' }); + }); + + it('names a note with nothing usable in it', () => { + expect(splitNoteMarkdown(' \n\n ').title).toBe(UNTITLED_NOTE_TITLE); + }); +}); + +describe('splitNoteMarkdown with a first line that is not a title', () => { + // Markdown whose meaning is its markup: as a one-line plain-text title it + // would read as syntax, so the note is Untitled and the line stays put. + const notTitles = [ + ['an image', '![Screenshot](shot.png)'], + ['a link', '[The docs](https://example.com)'], + ['a link inside a sentence', 'Follow [the docs](https://example.com) first'], + ['a heading holding a link', '# [The docs](https://example.com)'], + ['a bare URL', 'https://example.com/page'], + ['a URL the serializer escaped', 'Read this https\\://example.com'], + ['a bullet', '- first item'], + ['a task item', '- [ ] first item'], + ['a numbered item', '1. first item'], + ['a quote', '> quoted'], + ['a code fence', '```ts'], + ['a table row', '| a | b |'], + ['a rule', '---'], + ['raw HTML', '
'], + ] as const; + + for (const [label, firstLine] of notTitles) { + it(`leaves ${label} in the body and names the note Untitled`, () => { + expect(splitNoteMarkdown(`${firstLine}\n\nRest.`)).toEqual({ + title: UNTITLED_NOTE_TITLE, + body: `${firstLine}\n\nRest.`, + }); + }); + } + + it('settles after one reopen instead of losing or repeating the line', () => { + const typed = '![Screenshot](shot.png)\n\nRest.'; + const saved = splitNoteMarkdown(typed); + + // Reopening puts a real title line above the image, and saving again reads + // that line rather than taking a second pass at the image. + const reopened = noteMarkdownWithTitle(saved.title, saved.body); + expect(reopened).toBe(`# ${UNTITLED_NOTE_TITLE}\n\n${typed}`); + expect(splitNoteMarkdown(reopened)).toEqual(saved); + }); + + it('still takes ordinary titles, including decorated ones', () => { + expect(splitNoteMarkdown('**Release** plan\n\nShip.').title).toBe('**Release** plan'); + expect(splitNoteMarkdown('Notes [draft] for v2\n\nShip.').title).toBe('Notes [draft] for v2'); + }); +}); + +describe('unescapeMarkdown', () => { + it('consumes the escaped character along with its backslash', () => { + // A lookahead that deleted the backslash alone would re-examine the second + // half of a `\\` pair and eat that too, losing every backslash typed. + expect(unescapeMarkdown('a\\\\\\*b')).toBe('a\\*b'); + expect(unescapeMarkdown('back\\\\\\slash')).toBe('back\\\\slash'); + }); + + it('only undoes escapes CommonMark defines', () => { + // The 32 ASCII punctuation characters, and nothing else: a backslash before + // a letter or non-ASCII punctuation is literal, and the serializer knows it + // and leaves it alone. + expect(unescapeMarkdown('\\—dash \\word')).toBe('\\—dash \\word'); + expect(unescapeMarkdown('\\!\\"\\#\\$\\%\\&\\\'\\(\\)\\*\\+\\,\\-\\.\\/')).toBe( + '!"#$%&\'()*+,-./' + ); + expect(unescapeMarkdown('\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^\\_\\`\\{\\|\\}\\~')).toBe( + ':;<=>?@[\\]^_`{|}~' + ); + }); +}); + +describe('splitNoteMarkdown with a title the serializer escaped', () => { + // The title column is plain text — the timeline row and `#note:` labels render + // it as-is — but the line it comes from was written for a markdown parser. + const escaped = [ + ['an identifier', '# snake\\_case\\_name', 'snake_case_name'], + ['brackets', '# Plan \\[draft] v2', 'Plan [draft] v2'], + ['an asterisk', '# Use \\* for wildcards', 'Use * for wildcards'], + ['an ampersand', '# AT\\&T outage', 'AT&T outage'], + ['a trailing hash', '# trailing hash \\#', 'trailing hash #'], + ['an angle bracket', '# \\', ''], + ] as const; + + for (const [label, firstLine, title] of escaped) { + it(`stores ${label} as the text a reader sees`, () => { + expect(splitNoteMarkdown(`${firstLine}\n\nRest.`)).toEqual({ title, body: 'Rest.' }); + }); + } + + it('keeps a backslash the user typed', () => { + // Typed as `a\*b`, the line serializes to `a\\\*b`: the backslash is escaped + // and so is the asterisk behind it. + expect(splitNoteMarkdown('# a\\\\\\*b').title).toBe('a\\*b'); + expect(splitNoteMarkdown('# 50\\\\% off').title).toBe('50\\% off'); + expect(splitNoteMarkdown('# \\—dash').title).toBe('\\—dash'); + }); + + it('reads the heading marker before unescaping, not after', () => { + // `\# Heading` is a paragraph whose visible text opens with a `#`. Unescaping + // first would take that for a marker and drop a character the user can see. + expect(splitNoteMarkdown('\\# Heading\n\nRest.')).toEqual({ + title: '# Heading', + body: 'Rest.', + }); + }); + + it("unescapes a dropped file's own heading too", () => { + expect(splitNoteMarkdown('# snake\\_case\\_name\n\nHow to build it.', 'README').title).toBe( + 'snake_case_name' + ); + }); + + it('is a fixed point: a plain title survives the next round trip', () => { + const title = 'snake_case_name'; + const body = 'Details.'; + + expect(splitNoteMarkdown(noteMarkdownWithTitle(title, body))).toEqual({ title, body }); + }); +}); + +describe('canBeNoteTitleLine and canBeNoteTitleText', () => { + // The editor asks about a parsed block's text, the save path about the line the + // serializer wrote for it. Both halves promise to agree, which they only do if + // the escapes come off exactly once, on the side that has them. + it('reject a bullet in either spelling', () => { + expect(canBeNoteTitleText('- item')).toBe(false); + expect(canBeNoteTitleLine('\\- item')).toBe(false); + }); + + it('accept a line that only looks like one', () => { + // Visible text `\- item`: a literal backslash, so not a bullet at all. + expect(canBeNoteTitleText('\\- item')).toBe(true); + expect(canBeNoteTitleLine('\\\\- item')).toBe(true); + }); +}); + +describe('splitNoteMarkdown with a fallback title', () => { + // The drag-drop writer already has a name for the note — the file's — so it + // only gives it up to the document's own H1. + it("takes the document's own H1 over the fallback", () => { + expect(splitNoteMarkdown('# Project\n\nHow to build it.', 'README')).toEqual({ + title: 'Project', + body: 'How to build it.', + }); + }); + + it('round-trips a dropped file that names itself', () => { + const file = '# Project\n\nHow to build it.'; + const { title, body } = splitNoteMarkdown(file, 'README'); + + // The heading is stored once, in the title column, so the viewer shows one. + expect(noteMarkdownWithTitle(title, body)).toBe(file); + }); + + it('keeps the fallback when the first line is ordinary text', () => { + const log = '12:00 boot\n12:01 ready'; + + expect(splitNoteMarkdown(log, 'server')).toEqual({ title: 'server', body: log }); + }); + + it('keeps the fallback for a heading below H1', () => { + // `## Overview` is a section of the document, not the name of it. + expect(splitNoteMarkdown('## Overview\n\nDetails.', 'notes')).toEqual({ + title: 'notes', + body: '## Overview\n\nDetails.', + }); + }); + + it('keeps the fallback when the H1 could not be a title', () => { + const doc = '# [The docs](https://example.com)\n\nRest.'; + + expect(splitNoteMarkdown(doc, 'links')).toEqual({ title: 'links', body: doc }); + }); + + it('names an empty file after itself rather than Untitled', () => { + expect(splitNoteMarkdown(' \n\n', 'empty')).toEqual({ title: 'empty', body: '' }); + }); +}); + describe('renderNoteMarkdown', () => { it('uses the shared markdown renderer', () => { const html = renderNoteMarkdown('```pikchr\nbox "Start" fit\n```'); diff --git a/apps/staged/src/lib/features/notes/noteMarkdown.ts b/apps/staged/src/lib/features/notes/noteMarkdown.ts index 32c688c1e..22eded8e9 100644 --- a/apps/staged/src/lib/features/notes/noteMarkdown.ts +++ b/apps/staged/src/lib/features/notes/noteMarkdown.ts @@ -3,19 +3,162 @@ import { type MarkdownRenderingOptions, } from '../../shared/markdown/renderMarkdown'; +/** + * Recombine a stored note's `(title, content)` pair into displayable markdown. + * + * The title has its own column and the content is the body with the title line + * already taken out of it, so the H1 goes back on unconditionally. Skipping it + * when the body happens to open with a heading of its own would hide the real + * title — and in the editor the next save would then read that heading as the + * title and overwrite the stored one. + * + * An empty title means a session stub the runner titles later; there is nothing + * to prepend yet. + * + * The title goes back exactly as stored, escapes and all — which is to say + * without any. A stored title holding a *complete* inline construct is therefore + * re-read as markup here: `a _b_ c` reopens italic, and the next save stores the + * equivalent `a *b* c`, stable from there. Re-escaping instead would push + * backslashes into the markdown the viewer renders and the user copies, to defend + * a case that needs a plain-text paste to reach. Unpaired punctuation — the `_` + * of an identifier, an `&` — cannot re-form markup and round-trips exactly. + */ export function noteMarkdownWithTitle(title: string, content: string): string { const normalizedTitle = title.trim(); if (!normalizedTitle) return content; const normalizedContent = content.trimStart(); if (!normalizedContent) return `# ${normalizedTitle}`; - if (startsWithMarkdownH1(normalizedContent)) return content; return `# ${normalizedTitle}\n\n${normalizedContent}`; } -function startsWithMarkdownH1(content: string): boolean { - return /^#[ \t]+\S/.test(content); +/** Title used when a written note has no usable first line. */ +export const UNTITLED_NOTE_TITLE = 'Untitled note'; + +/** The ASCII punctuation CommonMark lets a backslash escape — those 32 and no more. */ +const MARKDOWN_ESCAPE = /\\([!-/:-@[-`{-~])/g; + +/** + * The text a reader sees for one line of serialized markdown: `snake\_case\_name` + * reads as `snake_case_name`. + * + * The editor's serializer escapes any punctuation that would otherwise be markup, + * so a line coming back out of it is spelled for the parser rather than for a + * person. Undoing that is a pair rule, not a hunt for backslashes: the escaped + * character is consumed along with its backslash, so `\\` leaves the one literal + * backslash the user typed, and a backslash before anything not on the list — a + * letter, an em dash — is itself literal and stays. + */ +export function unescapeMarkdown(line: string): string { + return line.replace(MARKDOWN_ESCAPE, '$1'); +} + +/** + * Markdown that only means something as a block: a list item, a quote, a fence, + * a table row, a rule, raw HTML. Stripped of its context it is punctuation. + */ +const NON_TITLE_BLOCK = + /^(?:[-*+](?:[ \t]|$)|\d{1,9}[.)](?:[ \t]|$)|>|`{3,}|~{3,}|\||<|(?:[-*_][ \t]*){3,}$)/; + +/** An image, an inline or reference link, an autolink, or a bare URL. */ +const LINK_OR_IMAGE = + /!?\[[^\]\n]*\](?:\([^)\n]*\)|\[[^\]\n]*\])|<[a-z][a-z\d+.-]*:[^\s>]*>|\b(?:https?:\/\/|www\.)\S/i; + +/** + * Whether a line of serialized markdown can stand in as the note's title. + * + * The title is stored as plain text and shown as one line in the timeline, so + * anything whose meaning lives in its markup reads there as raw syntax — a + * bullet's `-`, a link's `[…](…)`, an image that has no text at all. Those lines + * stay in the body and the note is titled [`UNTITLED_NOTE_TITLE`] instead. + * + * Headings are the exception: `#` markers are the title's own syntax and come + * off in [`splitNoteMarkdown`]. Emphasis and inline code are left alone — they + * are decoration on text that still reads as a title. + * + * The escapes come off before the rule runs, so a typed-out URL is judged as the + * `https://…` a reader sees rather than the `https\://…` the serializer wrote. + * + * The editor applies the same rule live, so what looks like a title on screen + * is what gets stored (see `wysiwygPlugins`). + */ +export function canBeNoteTitleLine(line: string): boolean { + return canBeNoteTitleText(unescapeMarkdown(line)); +} + +/** + * [`canBeNoteTitleLine`] for text that is already plain. + * + * The editor holds parsed nodes, so a block's `textContent` never carries the + * serializer's escapes; unescaping it a second time would let the editor and the + * save path disagree about the same block. A paragraph whose visible text is + * `\- item` is the case: a second pass reads that as the bullet `- item` and + * refuses it, while the save path unescapes the serialized `\\- item` once and + * takes `\- item` — plain text, which is what it is — as the title. + */ +export function canBeNoteTitleText(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return !NON_TITLE_BLOCK.test(trimmed) && !LINK_OR_IMAGE.test(trimmed); +} + +/** A leading ATX heading marker, up to the space that ends it. */ +const HEADING_MARKER = /^(#{1,6})[ \t]+/; + +/** + * Split a note's markdown into the `(title, body)` pair the store keeps. + * + * A note is a title column plus a body with that title line already taken out of + * it — the shape `resolve_note_title_and_body` produces for session notes, so + * viewers and `#note:` references treat every note alike. `noteMarkdownWithTitle` + * is the exact inverse and puts the line back unconditionally, so every writer + * has to hand over a body the title has left. This is where that happens, for + * the editor and for a dropped file alike. + * + * `fallbackTitle` is a name the caller already has for the note — the file name, + * on the drop path. A caller holding one gives it up only to a leading `# H1`: + * the document naming itself, which is both the better title and the line that + * would otherwise be shown directly under it. Anything else on line one is + * content there — a log's first line is not its title — and stays in the body. + * + * The editor has no such name, since it has no title field: there the first + * line is the title, heading or not, and it leaves the body either way. (The + * editor shows this by promoting that line to an H1 as it's typed; see + * `wysiwygPlugins`.) + * + * Either way, a first line that can't be a title ([`canBeNoteTitleLine`]) is not + * consumed: it is content, so it stays in the body and the note keeps the + * fallback name, or is Untitled without one. Reopening then puts a real title + * line above it, and the next save reads that instead — no line is lost or + * duplicated on the way through. + * + * The title crosses out of markdown here, so this is where the serializer's + * escapes come off it: the column holds the text a reader sees. + */ +export function splitNoteMarkdown( + markdown: string, + fallbackTitle = '' +): { title: string; body: string } { + const named = fallbackTitle.trim(); + const untitled = named || UNTITLED_NOTE_TITLE; + + const lines = markdown.split('\n'); + const titleIndex = lines.findIndex((line) => line.trim().length > 0); + if (titleIndex === -1) return { title: untitled, body: '' }; + + const bodyFrom = (index: number) => lines.slice(index).join('\n').trimStart(); + const firstLine = lines[titleIndex].trim(); + // Read for structure on the raw line: only a genuine leading `# ` is the + // document naming itself. `\# Heading` is a paragraph a reader sees as + // `# Heading`, and unescaping it first would take that `#` for a marker. + const isDocumentTitle = firstLine.match(HEADING_MARKER)?.[1] === '#'; + if (!canBeNoteTitleLine(firstLine) || (named && !isDocumentTitle)) { + return { title: untitled, body: bodyFrom(titleIndex) }; + } + + const title = unescapeMarkdown(firstLine.replace(HEADING_MARKER, '')).trim(); + return { title: title || untitled, body: bodyFrom(titleIndex + 1) }; } export function renderNoteMarkdown(text: string, options: MarkdownRenderingOptions = {}): string { diff --git a/apps/staged/src/lib/features/notes/wysiwygPlugins.test.ts b/apps/staged/src/lib/features/notes/wysiwygPlugins.test.ts new file mode 100644 index 000000000..780a344f7 --- /dev/null +++ b/apps/staged/src/lib/features/notes/wysiwygPlugins.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; + +import { Editor, defaultValueCtx, editorViewCtx, rootCtx } from '@milkdown/kit/core'; +import { commonmark } from '@milkdown/kit/preset/commonmark'; +import { gfm } from '@milkdown/kit/preset/gfm'; +import { TextSelection } from '@milkdown/kit/prose/state'; +import type { EditorView } from '@milkdown/kit/prose/view'; +import { getMarkdown } from '@milkdown/kit/utils'; + +import { splitNoteMarkdown } from './noteMarkdown'; +import { wysiwygPlugins } from './wysiwygPlugins'; + +// The editor under test is Milkdown with the same presets Crepe layers its +// features over; the plugins only touch the schema and input pipeline, which +// these presets define. +let editor: Editor | null = null; + +async function createEditor(markdown: string): Promise { + editor = await Editor.make() + .config((ctx) => { + ctx.set(rootCtx, document.body); + ctx.set(defaultValueCtx, markdown); + }) + .use(commonmark) + .use(gfm) + .use(wysiwygPlugins) + .create(); + return editor.ctx.get(editorViewCtx); +} + +afterEach(async () => { + await editor?.destroy(); + editor = null; + document.body.innerHTML = ''; +}); + +/** Feed text through the same path the DOM does: input rules first. */ +function type(view: EditorView, text: string) { + for (const char of text) { + const { from, to } = view.state.selection; + const insert = () => view.state.tr.insertText(char, from, to); + const handled = view.someProp('handleTextInput', (handler) => + handler(view, from, to, char, insert) + ); + if (!handled) view.dispatch(insert()); + } +} + +/** Place the cursor at the start of the first block of the given type. */ +function selectStartOf(view: EditorView, typeName: string) { + let inside = -1; + view.state.doc.descendants((node, pos) => { + if (inside === -1 && node.type.name === typeName) inside = pos + 1; + return inside === -1; + }); + expect(inside).toBeGreaterThan(-1); + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, inside))); +} + +function currentMarkdown(): string { + return editor?.action(getMarkdown()) ?? ''; +} + +describe('firstLineTitlePlugin', () => { + it('promotes the first line to the title H1 as it is typed', async () => { + const view = await createEditor(''); + type(view, 'Hello'); + + const first = view.state.doc.firstChild; + expect(first?.type.name).toBe('heading'); + expect(first?.attrs.level).toBe(1); + expect(currentMarkdown().startsWith('# Hello')).toBe(true); + }); + + it('re-levels a deeper heading on the first line', async () => { + const view = await createEditor('## Sub'); + const end = view.state.doc.firstChild!.nodeSize - 1; + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); + type(view, '!'); + + const first = view.state.doc.firstChild; + expect(first?.type.name).toBe('heading'); + expect(first?.attrs.level).toBe(1); + expect(currentMarkdown().startsWith('# Sub!')).toBe(true); + }); + + it('leaves deeper headings alone below the first line', async () => { + const view = await createEditor('# Title\n\n## Sub\n\nbody'); + selectStartOf(view, 'paragraph'); + type(view, 'more '); + + expect(view.state.doc.child(1).attrs.level).toBe(2); + }); + + it('leaves a document that opens with a list alone', async () => { + const view = await createEditor('- item'); + selectStartOf(view, 'paragraph'); + type(view, 'more '); + + expect(view.state.doc.firstChild?.type.name).toBe('bullet_list'); + expect(currentMarkdown().includes('more item')).toBe(true); + }); + + it('leaves a first line that is only an image as a paragraph', async () => { + const view = await createEditor('![Screenshot](shot.png)'); + selectStartOf(view, 'paragraph'); + type(view, 'x'); + + // An H1 here would promise a title the save path won't store. + expect(view.state.doc.firstChild?.type.name).toBe('paragraph'); + }); + + it('leaves a first line holding a link as a paragraph', async () => { + const view = await createEditor('See [the docs](https://example.com)'); + selectStartOf(view, 'paragraph'); + type(view, 'x '); + + expect(view.state.doc.firstChild?.type.name).toBe('paragraph'); + }); + + it('demotes the title when a URL is typed into it', async () => { + const view = await createEditor('# Read this'); + const end = view.state.doc.firstChild!.nodeSize - 1; + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); + type(view, ' https://example.com'); + + expect(view.state.doc.firstChild?.type.name).toBe('paragraph'); + // The serializer escapes the colon so the line can't autolink; the saved + // markdown still opens with the demoted paragraph rather than an H1. + expect(currentMarkdown().startsWith('Read this https\\://example.com')).toBe(true); + }); + + it('saves a title the serializer escaped as the text on screen', async () => { + // Pins the assumption the unescape is there for, against the real + // serializer: underscores in the title line come back out as `\_`. Seeded + // from markdown rather than typed, since the emphasis input rule would fire + // on the second `_` and produce italics instead of the literal text. + const view = await createEditor('# snake\\_case\\_name'); + const end = view.state.doc.firstChild!.nodeSize - 1; + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); + type(view, '2'); + + expect(currentMarkdown().startsWith('# snake\\_case\\_name2')).toBe(true); + expect(splitNoteMarkdown(currentMarkdown()).title).toBe('snake_case_name2'); + }); + + it('promotes body paragraphs only in first position', async () => { + const view = await createEditor('# Title\n\nbody'); + selectStartOf(view, 'paragraph'); + type(view, 'more '); + + expect(view.state.doc.child(1).type.name).toBe('paragraph'); + }); +}); diff --git a/apps/staged/src/lib/features/notes/wysiwygPlugins.ts b/apps/staged/src/lib/features/notes/wysiwygPlugins.ts new file mode 100644 index 000000000..f8ca1bb7d --- /dev/null +++ b/apps/staged/src/lib/features/notes/wysiwygPlugins.ts @@ -0,0 +1,90 @@ +/** + * wysiwygPlugins.ts — Milkdown plugins the written-note editor adds on top of Crepe + * + * Imported lazily next to `@milkdown/crepe` (both pull in ProseMirror) and + * registered on the underlying Milkdown editor before `create()`. + */ +import type { Node } from '@milkdown/kit/prose/model'; +import { Plugin, PluginKey } from '@milkdown/kit/prose/state'; +import { $prose } from '@milkdown/kit/utils'; + +import { canBeNoteTitleText } from './noteMarkdown'; + +/** + * Keep the first line a level-1 heading exactly when it can be the note's title. + * + * The editor has no separate title field: on save `splitNoteMarkdown` takes the + * first line as the title and stores the rest as the body, so that line leaves + * the document either way. Promoting it as it's typed shows that contract + * instead of hiding it — the title line looks like the title it becomes, and + * like the H1 `noteMarkdownWithTitle` puts back when the note is reopened. + * + * A deeper heading on the first line is re-levelled for the same reason: `## Sub` + * on line one is still the title, and the title is stored as plain text, so the + * extra `#` would be dropped on save regardless. Doing it in the editor makes + * that visible while it can still be undone. + * + * The reverse holds too. A line holding a link or an image can't be the title — + * it would be stored as raw syntax, so `splitNoteMarkdown` leaves it in the body + * and names the note Untitled — and dressing it as an H1 would promise otherwise. + * Such a first block is left as a paragraph, and a title that stops qualifying + * (a link pasted into it) is demoted back to one, so what reads as a title on + * screen is what gets saved as the title. + * + * Only non-empty top-level text blocks are touched: a fresh note's empty first + * block stays a paragraph so the doc still counts as empty (the placeholder + * shows, the trailing-paragraph plugin stays idle), and a document opening with + * a list or a code fence keeps its structure — those can't be titles either, and + * `splitNoteMarkdown` treats them the same way. Composition transactions are + * skipped — retyping the block under an active IME session would break it — so a + * composed title is promoted on the next ordinary edit instead. + */ +const firstLineTitlePlugin = $prose(() => { + return new Plugin({ + key: new PluginKey('WRITTEN_NOTE_TITLE'), + appendTransaction: (transactions, _oldState, state) => { + if (!transactions.some((tr) => tr.docChanged && !tr.getMeta('composition'))) return null; + const { heading, paragraph } = state.schema.nodes; + const first = state.doc.firstChild; + if (!heading || !paragraph || !first) return null; + + const isHeading = first.type === heading; + if (!isHeading && first.type !== paragraph) return null; + + if (canHoldTitle(first)) { + if (isHeading && first.attrs.level === 1) return null; + return state.tr.setBlockType(1, 1, heading, { level: 1 }); + } + if (!isHeading || first.attrs.level !== 1) return null; + return state.tr.setBlockType(1, 1, paragraph); + }, + }); +}); + +/** + * Whether this block would survive the trip through the title column. + * + * The markdown rule (`canBeNoteTitleLine`) runs on the serialized line, so it + * sees `[docs](url)` as text. Here the same content is already parsed — a link is + * a mark and an image is a node with no text at all — so the structure is checked + * directly and the text is passed through the shared rule for what it can still + * catch, such as a bare URL typed out. + * + * The text form of that rule is the one to call: `textContent` is plain already, + * so the serializer's escapes have never been added to it and there is nothing to + * undo. + */ +function canHoldTitle(node: Node): boolean { + if (node.content.size === 0) return false; + let holdsLinkOrImage = false; + node.descendants((child) => { + if (holdsLinkOrImage) return false; + if (child.type.name === 'image' || child.marks.some((mark) => mark.type.name === 'link')) { + holdsLinkOrImage = true; + } + return !holdsLinkOrImage; + }); + return !holdsLinkOrImage && canBeNoteTitleText(node.textContent); +} + +export const wysiwygPlugins = [firstLineTitlePlugin]; diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index 292000eb0..c8c0b67f4 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -126,6 +126,7 @@ describe('timelineToHashtagItems', () => { completedAt: 1000, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + subtype: null, }, { id: 'new-note', @@ -139,6 +140,7 @@ describe('timelineToHashtagItems', () => { completedAt: 5000, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + subtype: null, }, ], commits: [ diff --git a/apps/staged/src/lib/features/sessions/noteFreshness.ts b/apps/staged/src/lib/features/sessions/noteFreshness.ts index 08bf27de0..605925f2e 100644 --- a/apps/staged/src/lib/features/sessions/noteFreshness.ts +++ b/apps/staged/src/lib/features/sessions/noteFreshness.ts @@ -1,4 +1,4 @@ -import type { Session, SessionMessage } from '../../types'; +import type { NoteSubtype, Session, SessionMessage } from '../../types'; export interface LinkedNoteContext { id: string; @@ -15,6 +15,8 @@ export interface NoteClickInfo { content: string; sessionId?: string; updatedAt?: number; + /** `'written'` routes the click to the editor instead of the read-only viewer. */ + subtype?: NoteSubtype; } export function countAssistantMessagesAfterNote( diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 88a3345cb..64cc8140f 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -14,11 +14,14 @@ import GitCommitVertical from '@lucide/svelte/icons/git-commit-vertical'; import FileSearch from '@lucide/svelte/icons/file-search'; import Plus from '@lucide/svelte/icons/plus'; + import MoreHorizontal from '@lucide/svelte/icons/more-horizontal'; + import PencilLine from '@lucide/svelte/icons/pencil-line'; import { isResumableReason } from '../../types'; import type { BranchGitState, BranchTimeline as BranchTimelineData, HashtagItem, + NoteSubtype, UpstreamRelation, } from '../../types'; import type { NoteClickInfo } from '../sessions/noteFreshness'; @@ -26,8 +29,10 @@ import TimelineContextMenu, { type TimelineContextMenuAction, } from './TimelineContextMenu.svelte'; - import { Button } from '$lib/components/ui/button'; + import { Button, buttonVariants } from '$lib/components/ui/button'; + import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import type { TimelineItemType, TimelineBadge } from './TimelineRow.svelte'; + import { computeFooterOverflow, overflowedActions } from './footerOverflow'; import { escapeHtml, hasHashtagTokens, renderHashtagTokens } from '../sessions/hashtagItems'; import { formatRelativeTime, @@ -89,7 +94,10 @@ >; onNewNote?: () => void; onNewCommit?: () => void; - onNewReview?: (e: MouseEvent) => void; + /** Alt-click skips the prompt dialog; the overflow menu item passes no event. */ + onNewReview?: (e?: MouseEvent) => void; + /** Open the editor for a note the user writes themselves (no agent session). */ + onWriteNote?: () => void; onPullOrigin?: () => void; onPushOrigin?: () => void; onRebaseBranch?: () => void; @@ -175,6 +183,7 @@ onNewNote, onNewCommit, onNewReview, + onWriteNote, onPullOrigin, onPushOrigin, onRebaseBranch, @@ -266,6 +275,7 @@ noteTitle?: string; noteContent?: string; noteUpdatedAt?: number; + noteSubtype?: NoteSubtype; reviewId?: string; imageId?: string; imageFilename?: string; @@ -744,6 +754,7 @@ noteTitle: stripXmlTags(note.title), noteContent: note.content, noteUpdatedAt: note.updatedAt, + noteSubtype: note.subtype, deleteDisabledReason: isDeleting ? 'Deleting...' : undefined, completionReason: note.completionReason, hashtagRef: type === 'note' ? `#note:${note.id}` : undefined, @@ -920,7 +931,7 @@ return actions; }); let actionFooterVisible = $derived( - !!onNewNote || !!onNewCommit || !!onNewReview || !!footerActions + !!onNewNote || !!onNewCommit || !!onNewReview || !!onWriteNote || !!footerActions ); /** True when the timeline has no content and action buttons should be enlarged. */ @@ -928,6 +939,45 @@ items.length === 0 && pendingDropNotes.length === 0 && pendingItems.length === 0 ); + // ── Footer overflow ─────────────────────────────────────────────────── + // + // The label tiers are container queries, but the `…` menu's content is + // portaled outside the `timeline` container, so which items it lists has to + // come from a measured width. See `footerOverflow.ts`. + + let timelineEl = $state(null); + let timelineWidth = $state(0); + + $effect(() => { + const el = timelineEl; + if (!el) { + timelineWidth = 0; + return; + } + const observer = new ResizeObserver((entries) => { + const entry = entries[entries.length - 1]; + // Entry sizes are in local CSS pixels, so an animating ancestor (card + // expand, dialog zoom) can't report a transformed width. + timelineWidth = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + }); + observer.observe(el); + return () => observer.disconnect(); + }); + + let footerActionsAvailable = $derived({ + note: !!onNewNote, + commit: !!onNewCommit, + review: !!onNewReview, + }); + // The enlarged empty-timeline state stacks full-width pills and hides the + // right group, so it has room for all three regardless of card width. + let footerOverflow = $derived( + actionButtonsEnlarged + ? computeFooterOverflow(0, footerActionsAvailable) + : computeFooterOverflow(timelineWidth, footerActionsAvailable) + ); + let overflowMenuActions = $derived(overflowedActions(footerOverflow, footerActionsAvailable)); + // ── Handlers ────────────────────────────────────────────────────────── function handleItemClick(item: DisplayItem) { @@ -940,6 +990,7 @@ content: item.noteContent ?? '', sessionId: item.sessionId, updatedAt: item.noteUpdatedAt, + subtype: item.noteSubtype, }); } else if (item.type === 'review' && item.reviewId && onReviewClick) { onReviewClick(item.reviewId); @@ -1040,7 +1091,7 @@ {:else} -
+
{#each normalItems as item, index (item.key)}
- {#if onNewNote} + {#if onNewNote && !footerOverflow.note}
{#if footerActions && !actionButtonsEnlarged} {@render footerActions()} diff --git a/apps/staged/src/lib/features/timeline/footerOverflow.test.ts b/apps/staged/src/lib/features/timeline/footerOverflow.test.ts new file mode 100644 index 000000000..40d6a48bb --- /dev/null +++ b/apps/staged/src/lib/features/timeline/footerOverflow.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { computeFooterOverflow, overflowedActions } from './footerOverflow'; + +const ALL = { note: true, commit: true, review: true }; + +describe('computeFooterOverflow', () => { + it('keeps every button on a wide card', () => { + expect(computeFooterOverflow(720, ALL)).toEqual({ + note: false, + commit: false, + review: false, + }); + }); + + it('sheds buttons review-first, then commit, then note', () => { + expect(computeFooterOverflow(370, ALL)).toMatchObject({ review: true, commit: false }); + expect(computeFooterOverflow(310, ALL)).toMatchObject({ review: true, commit: true }); + expect(computeFooterOverflow(250, ALL)).toEqual({ note: true, commit: true, review: true }); + }); + + it('reports actions the card is not offering as hidden', () => { + expect(computeFooterOverflow(720, { note: true, commit: true })).toEqual({ + note: false, + commit: false, + review: true, + }); + }); + + it('gives the remaining buttons the space an absent one frees', () => { + // Two buttons still fit at 340px even though three would not. + expect(computeFooterOverflow(340, { note: true, commit: true })).toMatchObject({ + note: false, + commit: false, + }); + expect(computeFooterOverflow(340, ALL)).toMatchObject({ review: true }); + }); + + it('shows everything until the container has been measured', () => { + expect(computeFooterOverflow(0, ALL)).toEqual({ note: false, commit: false, review: false }); + }); +}); + +describe('overflowedActions', () => { + it('lists overflowed actions in button order', () => { + expect(overflowedActions(computeFooterOverflow(250, ALL), ALL)).toEqual([ + 'note', + 'commit', + 'review', + ]); + }); + + it('omits actions the card never offered', () => { + const available = { note: true, commit: true }; + + expect(overflowedActions(computeFooterOverflow(250, available), available)).toEqual([ + 'note', + 'commit', + ]); + }); + + it('is empty while everything fits', () => { + expect(overflowedActions(computeFooterOverflow(720, ALL), ALL)).toEqual([]); + }); +}); diff --git a/apps/staged/src/lib/features/timeline/footerOverflow.ts b/apps/staged/src/lib/features/timeline/footerOverflow.ts new file mode 100644 index 000000000..272588143 --- /dev/null +++ b/apps/staged/src/lib/features/timeline/footerOverflow.ts @@ -0,0 +1,77 @@ +/** + * Which timeline footer buttons collapse into the `…` menu at a given width. + * + * The buttons' label tiers (full label → `+` and short label → icon only) are + * pure CSS container queries. This last tier can't be: the menu's content is + * portaled out of the `timeline` container, so no `@container` query can decide + * which items it should show. Both sides therefore read the same measured width + * through this module, keeping button and menu in sync by construction. + */ + +/** Left-aligned footer actions, in the order they appear on the card. */ +export type FooterAction = 'note' | 'commit' | 'review'; + +/** Which of the three session actions the card is currently offering. */ +export type FooterActionAvailability = Partial>; + +export type FooterOverflowState = Record; + +/** + * Order actions leave the footer in. Review goes first — it is the widest label + * and the most situational — then Commit, leaving Note as the last one standing. + */ +const OVERFLOW_ORDER: readonly FooterAction[] = ['review', 'commit', 'note']; + +/** + * Minimum timeline width (px) that still fits N icon-only buttons alongside the + * `…` trigger and the right-aligned PR/Diff group. Index is the button count, so + * index 0 is the always-fits case. + * + * These sit below the 480px icon-only tier in `BranchTimeline.svelte`, which is + * where the buttons have already shed their labels. + */ +const MIN_WIDTH_FOR_BUTTONS: readonly number[] = [0, 260, 320, 380]; + +/** + * Decide which footer buttons to hide at `widthPx`, given which ones exist. + * + * Actions the card isn't offering are reported as hidden, so callers can render + * the menu straight from this result without re-checking availability. + */ +export function computeFooterOverflow( + widthPx: number, + available: FooterActionAvailability +): FooterOverflowState { + const hidden: FooterOverflowState = { + note: !available.note, + commit: !available.commit, + review: !available.review, + }; + + // A zero width means the container hasn't been measured yet (first paint, + // or an off-screen card). Showing everything matches the pre-overflow + // behaviour and self-corrects on the first ResizeObserver callback. + if (widthPx <= 0) return hidden; + + let visible = OVERFLOW_ORDER.filter((action) => !hidden[action]).length; + for (const action of OVERFLOW_ORDER) { + if (visible === 0 || widthPx >= MIN_WIDTH_FOR_BUTTONS[visible]) break; + if (hidden[action]) continue; + hidden[action] = true; + visible -= 1; + } + return hidden; +} + +/** + * The actions that overflowed into the menu, in their original button order. + * Excludes actions the card never offered. + */ +export function overflowedActions( + overflow: FooterOverflowState, + available: FooterActionAvailability +): FooterAction[] { + return (['note', 'commit', 'review'] as const).filter( + (action) => available[action] && overflow[action] + ); +} diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 2d023080d..9977c37d1 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -184,8 +184,19 @@ export interface NoteTimelineItem { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + subtype: NoteSubtype; } +/** + * How a note's content came to be. `null` means an agent session produced it; + * `'written'` means the user authored it directly, so it opens in the editor + * rather than the read-only viewer. + */ +export type NoteSubtype = 'written' | null; + +/** Subtype marking a user-authored note. */ +export const WRITTEN_NOTE_SUBTYPE = 'written'; + /** A full branch note record, as returned by `get_branch_note_by_session`. */ export interface BranchNote { id: string; @@ -198,6 +209,7 @@ export interface BranchNote { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + subtype: NoteSubtype; } export interface ReviewTimelineItem { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26dbf7ff7..6cb483b81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,12 @@ importers: '@builderbot/diff-viewer': specifier: workspace:* version: link:../../packages/diff-viewer + '@milkdown/crepe': + specifier: ^7.22.1 + version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(typescript@6.0.3) + '@milkdown/kit': + specifier: ^7.22.1 + version: 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(typescript@6.0.3) '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 @@ -390,6 +396,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -407,6 +417,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -435,6 +450,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -457,6 +476,99 @@ packages: '@chevrotain/utils@11.1.1': resolution: {integrity: sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.0': + resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.2': + resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.9': + resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -754,20 +866,150 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.6': + resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} + + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@lucide/svelte@1.22.0': resolution: {integrity: sha512-eaNC3GGu9ma7mviB9vPL6OnawXqxdvRnoAQSq5l15mBlsuwD7kozZ7pzPXSlT6OwSl7hz4qTk+ZU3OEewwi5gQ==} peerDependencies: svelte: ^5 + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@mermaid-js/parser@1.0.0': resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@milkdown/components@7.22.1': + resolution: {integrity: sha512-6IA8fcFcBTm/x1X1typz73yUoT1JuN4srmifGAIeWEtCnayEwRjFxpQOoQrfvMgBYx4DSHcPVLhJUlR/xbBtxg==} + peerDependencies: + '@codemirror/language': ^6 + '@codemirror/state': ^6 + '@codemirror/view': ^6 + + '@milkdown/core@7.22.1': + resolution: {integrity: sha512-X4O3al2aYugpFP/Do7VoPgvVEftjJVi+6UhZmz9fSdqMtpU70AitJ8R/erUA18hay2hWvNO9m3pxIJoR6no6KQ==} + + '@milkdown/crepe@7.22.1': + resolution: {integrity: sha512-0BZTGBRV8lnlKsA3vmPK5Qyomjqd952493AIzfnbhiYwzHpluX03iMtOgHC6E2UcpMXP86P5CXhPUWjzDu+JZQ==} + + '@milkdown/ctx@7.22.1': + resolution: {integrity: sha512-6+LJdo3DcrkO1lzGcje0yWqaG6ZKgTR8kDxyDrrm7iocBjq3M+bkw04f99ZG/BLp1Rbc4yRyin2mUy6m4SEVaA==} + + '@milkdown/exception@7.22.1': + resolution: {integrity: sha512-wXUfn+fpWy0jeJ6AVEjKPieouLfJIDLQ4IRqyEL3d5yZDiZTtKu1Va0+RprrvqANpGJ1YBRQ1uCgDE6sJgTEjQ==} + + '@milkdown/kit@7.22.1': + resolution: {integrity: sha512-gXLMhjqe0j8XRSUe97LBXbpZ0sDi5EM6nnt1zXewUaGDS3mleMjNntSCWrp9ln40kz8tGs0ol50kSAr1n/JBnQ==} + + '@milkdown/plugin-block@7.22.1': + resolution: {integrity: sha512-R0gOuKRqR1DyFULD75Oic0+57DXTthagUZbbB9lsHM59aQmxKJ1nRmujptPeMfDlDfEod2ao5rsow7LO8p+ZWQ==} + + '@milkdown/plugin-clipboard@7.22.1': + resolution: {integrity: sha512-H1meyLEj2fOnFWSopeQ2H7hl6VXM+OQZoU9H7GrlHFM5GKtBFBErFFV4Cl5tUSVFY+xYy/4rEPOA23Ba1u6OfA==} + + '@milkdown/plugin-cursor@7.22.1': + resolution: {integrity: sha512-3tJzpQl5cyn+8vzsHkPBYsW6QacEN7yP2O5GkyUjrNLvMIQ5vVi7GUuztLeoouJKlOc1vjIq3h7ARXCgHAKe8g==} + + '@milkdown/plugin-diff@7.22.1': + resolution: {integrity: sha512-qjuCMx11HwhMlTJuybqbFZT8PQkasSi0qICxq2HdYqH0WQuLO99I2mb17UAJz1aF+JM9rp1StHWr4bxC0ofamA==} + + '@milkdown/plugin-history@7.22.1': + resolution: {integrity: sha512-SRD6emVhfVmA6mrcCsCiWrONN/2Kp+VT5LLojIQxNc/spzjWw15PtkA3r4nUe9cZmNN5rE7XvrPaA0WnjJIyeQ==} + + '@milkdown/plugin-indent@7.22.1': + resolution: {integrity: sha512-s/wmqxaJpIKT01gEXsC9gCkzA3Clm+MNioZLzNCYD8xI/4Hf3AhmIEoEqf3LIQ9w10Zs2yhIxIrknNWfCOgsEw==} + + '@milkdown/plugin-listener@7.22.1': + resolution: {integrity: sha512-6k8JMDrdAL0E0WygpDX1YOR0Q9Wc4pia8pIMN5nN5liEeAyNaM6LwAxXJY+awQJ0OstDKHTfjkh7WzJo4QiXOA==} + + '@milkdown/plugin-slash@7.22.1': + resolution: {integrity: sha512-eFKCjfgXfAQusTSOyzhz4nASquOApdiBy9hnGkPB/SJ6oltTC8SonB3nIxF/X0wPZHfn09vuz/JBqh9cyDtXJA==} + + '@milkdown/plugin-streaming@7.22.1': + resolution: {integrity: sha512-CR0lujyc7ae0sbjNfscvS+b+cUP7b6XJ90ioTor605UV+F8EKh5t4PMJM79IVzSUCeob51a+rtXA2LAA1b/GyA==} + + '@milkdown/plugin-tooltip@7.22.1': + resolution: {integrity: sha512-drdWA/7WlrDr2B+ABYf4tY9xTiwg/CJMUCydfD05dR2d8wOkaF6oOHZwJbWnkWLMtAJWdieOYgfXGPhQRwXxCg==} + + '@milkdown/plugin-trailing@7.22.1': + resolution: {integrity: sha512-Buy8IX3I7wwq0QPfJRep77QnvlkBeg6BvDHkbh5eTMrgnwSiWhN+tV9RVCYpdWXM7QhQZpSjU2SejluLuTyVCg==} + + '@milkdown/plugin-upload@7.22.1': + resolution: {integrity: sha512-vV0bZzdh/PhM+Jk9Nk5cKHDXHY5jt+ePcaitTfvESDYfg83YZ9AglOhw/YclPWklMvswrnCGZZFTZD4daqc4hg==} + + '@milkdown/preset-commonmark@7.22.1': + resolution: {integrity: sha512-it+G0YUG5MDXt0qLB6W083ss5i6tnWAXWCW8SgnmsehGxntkN5//gNM/8vh3rqwl8RvnBvjLWpJ6TgHSlBPhlw==} + + '@milkdown/preset-gfm@7.22.1': + resolution: {integrity: sha512-UPMdHRdMHlVourOOTiwsp3qHu614pDFWlWtVzxe8fyl8uKZUk6IOuewH5ubhqD/opos8OEB+zgEomUGfee1oTw==} + + '@milkdown/prose@7.22.1': + resolution: {integrity: sha512-fqgTHl94G7oDPY94cPYBm6ARxEoLQ10ubkTNLD1nzAlTUfJLULZkIEMSMy4CECncw2go3ExW32CZPpXLJVSxnQ==} + + '@milkdown/transformer@7.22.1': + resolution: {integrity: sha512-rU1IBtxezNg7TSWaq+RP59/t6tfD3jIPGrwAtMKPIGuYEax+8d1fyGisp98TeI3l0VQEIgMpF+KlNDV+Y1HJVA==} + + '@milkdown/utils@7.22.1': + resolution: {integrity: sha512-ye0LGy/Ez8fjtcYyYj93wp4XfH00JYpCiy1IDHYB+9HVIpnbr3ed7PvY4s2000xmDWe0Vlx07TOyv96yvFrnjQ==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@ocavue/utils@1.7.0': + resolution: {integrity: sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==} + '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -832,7 +1074,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} @@ -923,6 +1164,7 @@ packages: resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} @@ -976,6 +1218,7 @@ packages: resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} @@ -1557,6 +1800,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1631,6 +1883,33 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1745,6 +2024,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1776,6 +2058,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2113,6 +2398,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2346,6 +2634,10 @@ packages: resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==} hasBin: true + katex@0.18.4: + resolution: {integrity: sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2491,6 +2783,9 @@ packages: engines: {node: '>= 20'} hasBin: true + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -2515,6 +2810,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -2566,6 +2864,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -2649,6 +2950,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2678,6 +2989,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2753,6 +3067,10 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2786,6 +3104,65 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.2: + resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} + + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + prosemirror-drop-indicator@0.1.4: + resolution: {integrity: sha512-YaRB1pZmU5GCorPVWbc9dbhbwqr4iMBO/AjPu4BTKHCUzxEDUXje2dUyoxOHib/z4uyPUZTJz64h7mHDJZeSzA==} + + prosemirror-dropcursor@1.8.3: + resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-safari-ime-span@1.0.2: + resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.42.2: + resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} + + prosemirror-virtual-cursor@0.4.2: + resolution: {integrity: sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==} + peerDependencies: + prosemirror-model: ^1.0.0 + prosemirror-state: ^1.0.0 + prosemirror-view: ^1.0.0 + peerDependenciesMeta: + prosemirror-model: + optional: true + prosemirror-state: + optional: true + prosemirror-view: + optional: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2861,6 +3238,12 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-inline-links@7.0.0: + resolution: {integrity: sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -2870,6 +3253,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2887,6 +3273,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -2975,6 +3364,9 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3115,6 +3507,9 @@ packages: unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -3309,6 +3704,17 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -3459,6 +3865,8 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.27.1': {} @@ -3472,6 +3880,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3507,6 +3919,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -3530,6 +3947,264 @@ snapshots: '@chevrotain/utils@11.1.1': {} + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.11.0': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.7.2 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.12 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.2 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.4 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.9': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -3740,14 +4415,392 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lucide/svelte@1.22.0(svelte@5.56.4)': dependencies: svelte: 5.56.4 + '@marijn/find-cluster-break@1.0.4': {} + '@mermaid-js/parser@1.0.0': dependencies: langium: 4.2.1 + '@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(typescript@6.0.3)': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@floating-ui/dom': 1.7.6 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/plugin-tooltip': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/preset-gfm': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + dompurify: 3.3.1 + lodash-es: 4.17.23 + nanoid: 6.0.1 + unist-util-visit: 5.1.0 + vue: 3.5.41(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@milkdown/core@7.22.1': + dependencies: + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/crepe@7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(typescript@6.0.3)': + dependencies: + '@codemirror/commands': 6.11.0 + '@codemirror/language': 6.12.4 + '@codemirror/language-data': 6.5.2 + '@codemirror/state': 6.7.1 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.9 + '@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(typescript@6.0.3) + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + codemirror: 6.0.2 + dompurify: 3.3.1 + katex: 0.18.4 + lodash-es: 4.17.23 + prosemirror-virtual-cursor: 0.4.2(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + remark-math: 6.0.0 + unist-util-visit: 5.1.0 + vue: 3.5.41(typescript@6.0.3) + transitivePeerDependencies: + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + + '@milkdown/ctx@7.22.1': + dependencies: + '@milkdown/exception': 7.22.1 + + '@milkdown/exception@7.22.1': {} + + '@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(typescript@6.0.3)': + dependencies: + '@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(typescript@6.0.3) + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/plugin-block': 7.22.1 + '@milkdown/plugin-clipboard': 7.22.1 + '@milkdown/plugin-cursor': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/plugin-history': 7.22.1 + '@milkdown/plugin-indent': 7.22.1 + '@milkdown/plugin-listener': 7.22.1 + '@milkdown/plugin-slash': 7.22.1 + '@milkdown/plugin-streaming': 7.22.1 + '@milkdown/plugin-tooltip': 7.22.1 + '@milkdown/plugin-trailing': 7.22.1 + '@milkdown/plugin-upload': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/preset-gfm': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + - supports-color + - typescript + + '@milkdown/plugin-block@7.22.1': + dependencies: + '@floating-ui/dom': 1.7.6 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-clipboard@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-cursor@7.22.1': + dependencies: + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + prosemirror-drop-indicator: 0.1.4 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-diff@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-history@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-indent@7.22.1': + dependencies: + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-listener@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-slash@7.22.1': + dependencies: + '@floating-ui/dom': 1.7.6 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-streaming@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-tooltip@7.22.1': + dependencies: + '@floating-ui/dom': 1.7.6 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-trailing@7.22.1': + dependencies: + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-upload@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-commonmark@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 + remark-inline-links: 7.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-gfm@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 + prosemirror-safari-ime-span: 1.0.2 + remark-gfm: 4.0.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/prose@7.22.1': + dependencies: + '@milkdown/exception': 7.22.1 + prosemirror-changeset: 2.4.2 + prosemirror-commands: 1.7.2 + prosemirror-dropcursor: 1.8.3 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + '@milkdown/transformer@7.22.1': + dependencies: + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + remark: 15.0.1 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/utils@7.22.1': + dependencies: + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + nanoid: 6.0.1 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -3755,6 +4808,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@ocavue/utils@1.7.0': {} + '@oxc-project/types@0.137.0': {} '@playwright/test@1.58.2': @@ -4423,6 +5478,14 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4520,6 +5583,60 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -4622,6 +5739,16 @@ snapshots: clsx@2.1.1: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.0 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + comma-separated-tokens@2.0.3: {} commander@14.0.3: {} @@ -4644,6 +5771,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crelt@1.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5032,6 +6161,8 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -5298,6 +6429,10 @@ snapshots: dependencies: commander: 8.3.0 + katex@0.18.4: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5409,6 +6544,12 @@ snapshots: marked@18.0.5: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -5490,6 +6631,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -5664,6 +6817,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.33 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -5797,6 +6960,10 @@ snapshots: nanoid@3.3.15: {} + nanoid@3.3.18: {} + + nanoid@6.0.1: {} + natural-compare@1.4.0: {} node-fetch-native@1.6.7: {} @@ -5830,6 +6997,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + orderedmap@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5906,6 +7075,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier-plugin-svelte@3.5.0(prettier@3.9.4)(svelte@5.56.4): @@ -5930,6 +7105,98 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.2: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-drop-indicator@0.1.4: + dependencies: + '@ocavue/utils': 1.7.0 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + + prosemirror-dropcursor@1.8.3: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-safari-ime-span@1.0.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-virtual-cursor@0.4.2(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2): + optionalDependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + punycode@2.3.1: {} react-dom@19.2.5(react@19.2.5): @@ -6026,6 +7293,21 @@ snapshots: transitivePeerDependencies: - supports-color + remark-inline-links@7.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-definitions: 6.0.0 + unist-util-visit: 5.1.0 + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -6049,6 +7331,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + require-from-string@2.0.2: {} robust-predicates@3.0.2: {} @@ -6105,6 +7396,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -6207,6 +7500,8 @@ snapshots: dependencies: min-indent: 1.0.1 + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -6363,6 +7658,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -6515,6 +7815,18 @@ snapshots: vscode-uri@3.1.0: {} + vue@3.5.41(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 6.0.3 + + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0