diff --git a/apps/mark/package.json b/apps/mark/package.json index 2dbbed95..716f16d1 100644 --- a/apps/mark/package.json +++ b/apps/mark/package.json @@ -28,6 +28,7 @@ "@builderbot/diff-viewer": "workspace:*", "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-store": "^2.4.2", "ansi-to-html": "^0.7.2", "lucide-svelte": "^0.575.0", diff --git a/apps/mark/src-tauri/Cargo.lock b/apps/mark/src-tauri/Cargo.lock index 059c62b1..a86c60bb 100644 --- a/apps/mark/src-tauri/Cargo.lock +++ b/apps/mark/src-tauri/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-store", @@ -1136,6 +1137,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -3932,6 +3935,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -5008,6 +5035,46 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" diff --git a/apps/mark/src-tauri/Cargo.toml b/apps/mark/src-tauri/Cargo.toml index 11a01110..af2128de 100644 --- a/apps/mark/src-tauri/Cargo.toml +++ b/apps/mark/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tauri-plugin-window-state = "2.4.1" reqwest = { version = "0.13.1", features = ["json"] } tokio = { version = "1.49.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread", "time"] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" # Shared crates git-diff = { path = "../../../crates/git-diff" } diff --git a/apps/mark/src-tauri/capabilities/default.json b/apps/mark/src-tauri/capabilities/default.json index 74b20594..7905ee18 100644 --- a/apps/mark/src-tauri/capabilities/default.json +++ b/apps/mark/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "clipboard-manager:allow-write-text", "window-state:default", "store:default", - "core:window:allow-set-badge-count" + "core:window:allow-set-badge-count", + "dialog:default" ] } diff --git a/apps/mark/src-tauri/examples/acp_stream_probe.rs b/apps/mark/src-tauri/examples/acp_stream_probe.rs index 60dec1b5..814d4f2c 100644 --- a/apps/mark/src-tauri/examples/acp_stream_probe.rs +++ b/apps/mark/src-tauri/examples/acp_stream_probe.rs @@ -178,6 +178,7 @@ fn main() -> Result<()> { .run( "probe-session", &prompt, + &[], &workdir, &store, &writer, diff --git a/apps/mark/src-tauri/src/lib.rs b/apps/mark/src-tauri/src/lib.rs index 79921c5a..5ac44ca0 100644 --- a/apps/mark/src-tauri/src/lib.rs +++ b/apps/mark/src-tauri/src/lib.rs @@ -248,6 +248,19 @@ pub struct ReviewTimelineItem { pub updated_at: i64, } +/// Image with session status resolved. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageTimelineItem { + pub id: String, + pub filename: String, + pub mime_type: String, + pub size_bytes: i64, + pub session_id: Option, + pub session_status: Option, + pub created_at: i64, +} + /// Composite timeline for a branch. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -255,6 +268,7 @@ pub struct BranchTimeline { pub commits: Vec, pub notes: Vec, pub reviews: Vec, + pub images: Vec, } // ============================================================================= @@ -1142,6 +1156,258 @@ fn delete_project_note( .map_err(|e| e.to_string()) } +// ============================================================================= +// Image commands +// ============================================================================= + +const MAX_IMAGE_SIZE: u64 = 10_485_760; // 10 MB + +const ALLOWED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; + +fn mime_type_for_extension(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +/// Create an image record and copy the file to the project images directory. +/// +/// When `pending` is true the image is hidden from the branch timeline until +/// a session is started (the session runner overwrites the sentinel with the +/// real session ID). Pass `false` for images that should appear in the +/// timeline immediately (e.g. direct branch-card drops). +#[tauri::command(rename_all = "camelCase")] +fn create_image( + store: tauri::State<'_, Mutex>>>, + branch_id: Option, + project_id: String, + file_path: String, + pending: Option, +) -> Result { + let store = get_store(&store)?; + + let src = Path::new(&file_path); + if !src.exists() { + return Err(format!("File not found: {file_path}")); + } + + let filename = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid filename".to_string())? + .to_string(); + + let ext = src + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!( + "Unsupported image format: .{ext}. Allowed: {}", + ALLOWED_IMAGE_EXTENSIONS.join(", ") + )); + } + + let metadata = std::fs::metadata(src).map_err(|e| format!("Cannot read file metadata: {e}"))?; + if metadata.len() > MAX_IMAGE_SIZE { + return Err(format!( + "File too large ({} bytes). Maximum is {} bytes.", + metadata.len(), + MAX_IMAGE_SIZE + )); + } + + let mime_type = mime_type_for_extension(&ext).to_string(); + let size_bytes = metadata.len() as i64; + + let filename = store + .unique_image_filename(branch_id.as_deref(), &project_id, &filename) + .map_err(|e| e.to_string())?; + + let image = store::Image::new( + branch_id.as_deref(), + &project_id, + &filename, + &mime_type, + size_bytes, + pending.unwrap_or(false), + ); + + // Compute destination path and ensure the images directory exists. + let dest = store::images::image_file_path(&project_id, &image.id, &filename)?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create images directory: {e}"))?; + } + + // Copy the file to the images directory. + std::fs::copy(src, &dest).map_err(|e| format!("Cannot copy image file: {e}"))?; + + // Persist the DB record. + if let Err(e) = store.create_image(&image) { + let _ = std::fs::remove_file(&dest); + return Err(e.to_string()); + } + + Ok(image) +} + +/// Return the filesystem path for an image (the frontend uses convertFileSrc). +#[tauri::command(rename_all = "camelCase")] +fn get_image_path( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = store::images::image_file_path(&image.project_id, &image.id, &image.filename)?; + Ok(path.to_string_lossy().to_string()) +} + +/// Delete an image record and its file on disk. +#[tauri::command(rename_all = "camelCase")] +fn delete_image( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result<(), String> { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + + // Delete the DB record first (triggers session cleanup). + store.delete_image(&image_id).map_err(|e| e.to_string())?; + + // Best-effort file removal. + if let Ok(path) = store::images::image_file_path(&image.project_id, &image.id, &image.filename) + { + if let Err(e) = std::fs::remove_file(&path) { + log::warn!("Failed to remove image file {}: {e}", path.display()); + } + } + + Ok(()) +} + +/// List all images for a branch. +#[tauri::command(rename_all = "camelCase")] +fn list_branch_images( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result, String> { + get_store(&store)? + .list_images_for_branch(&branch_id) + .map_err(|e| e.to_string()) +} + +/// Read an image file and return its data as a base64-encoded data URL. +#[tauri::command(rename_all = "camelCase")] +fn get_image_data( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .map_err(|e| e.to_string())?; + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(format!("data:{};base64,{}", image.mime_type, encoded)) +} + +/// Create an image from base64-encoded data (for browser file input / clipboard paste). +/// +/// See [`create_image`] for the meaning of the `pending` flag. +#[tauri::command(rename_all = "camelCase")] +fn create_image_from_data( + store: tauri::State<'_, Mutex>>>, + branch_id: Option, + project_id: String, + filename: String, + mime_type: String, + data: String, + pending: Option, +) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&data) + .map_err(|e| format!("Invalid base64 data: {e}"))?; + + // Validate size + if bytes.len() as u64 > MAX_IMAGE_SIZE { + return Err(format!( + "Image too large: {} bytes (max {})", + bytes.len(), + MAX_IMAGE_SIZE + )); + } + + // Validate extension + let ext = std::path::Path::new(&filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!("Unsupported image format: .{ext}")); + } + + let store = get_store(&store)?; + const ALLOWED_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; + let mime = if mime_type.is_empty() { + mime_type_for_extension(&ext).to_string() + } else { + if !ALLOWED_MIME_TYPES.contains(&mime_type.as_str()) { + return Err(format!( + "Unsupported MIME type: {mime_type}. Allowed: {}", + ALLOWED_MIME_TYPES.join(", ") + )); + } + mime_type + }; + + let filename = store + .unique_image_filename(branch_id.as_deref(), &project_id, &filename) + .map_err(|e| e.to_string())?; + + let image = store::Image::new( + branch_id.as_deref(), + &project_id, + &filename, + &mime, + bytes.len() as i64, + pending.unwrap_or(false), + ); + let path = crate::store::images::image_file_path(&project_id, &image.id, &filename) + .map_err(|e| e.to_string())?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create images directory: {e}"))?; + } + std::fs::write(&path, &bytes).map_err(|e| format!("Failed to save image: {e}"))?; + + if let Err(e) = store.create_image(&image) { + let _ = std::fs::remove_file(&path); + return Err(e.to_string()); + } + Ok(image) +} + /// Delete a review and all its comments, optionally deleting its linked session. #[tauri::command(rename_all = "camelCase")] fn delete_review( @@ -1422,10 +1688,32 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result = db_images + .into_iter() + .map(|img| { + let (session_id, session_status) = + store.resolve_session_status(img.session_id.as_deref()); + ImageTimelineItem { + id: img.id, + filename: img.filename, + mime_type: img.mime_type, + size_bytes: img.size_bytes, + session_id, + session_status, + created_at: img.created_at, + } + }) + .collect(); + Ok(BranchTimeline { commits, notes, reviews, + images, }) } @@ -2359,6 +2647,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_window_state::Builder::new() .with_state_flags( @@ -2621,6 +2910,13 @@ pub fn run() { Arc::clone(&store_arc), app.handle().clone(), ); + // Clean up images left in "pending" state from compose + // dialogs that were abandoned (e.g. user quit mid-dialog). + match store_arc.cleanup_pending_images() { + Ok(0) => {} + Ok(n) => log::info!("Cleaned up {n} pending image(s) from previous run"), + Err(e) => log::warn!("Failed to clean up pending images: {e}"), + } (Mutex::new(Some(store_arc)), None) } store::DbCompatibility::NeedsReset { db_app_version } => { @@ -2733,6 +3029,12 @@ pub fn run() { create_project_note, list_project_notes, delete_project_note, + create_image, + get_image_path, + get_image_data, + delete_image, + list_branch_images, + create_image_from_data, delete_review, delete_commit, delete_pending_commit, diff --git a/apps/mark/src-tauri/src/project_mcp.rs b/apps/mark/src-tauri/src/project_mcp.rs index 3bf2a54b..46c9846f 100644 --- a/apps/mark/src-tauri/src/project_mcp.rs +++ b/apps/mark/src-tauri/src/project_mcp.rs @@ -417,6 +417,7 @@ impl ProjectToolsHandler { action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, Arc::clone(&self.store), self.app_handle.clone(), diff --git a/apps/mark/src-tauri/src/prs.rs b/apps/mark/src-tauri/src/prs.rs index 55cb149d..95c6a307 100644 --- a/apps/mark/src-tauri/src/prs.rs +++ b/apps/mark/src-tauri/src/prs.rs @@ -166,6 +166,7 @@ This is critical - the application parses this to link the PR. action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, store, app_handle, @@ -556,6 +557,7 @@ The push must succeed before you finish (unless you output the non-fast-forward action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, store, app_handle, diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index 70b179af..ad0916d8 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -160,6 +160,7 @@ pub fn start_session( action_executor: None, action_registry: None, remote_working_dir: None, + image_ids: vec![], }, store, app_handle, @@ -185,6 +186,7 @@ pub fn resume_session( app_handle: tauri::AppHandle, session_id: String, prompt: String, + image_ids: Option>, ) -> Result<(), String> { let store = get_store(&store)?; @@ -232,6 +234,7 @@ pub fn resume_session( action_executor: None, action_registry: None, remote_working_dir: None, + image_ids: image_ids.unwrap_or_default(), }, store, app_handle, @@ -334,6 +337,7 @@ pub async fn start_project_session( project_id: String, prompt: String, provider: Option, + image_ids: Option>, ) -> Result { let store = get_store(&store)?; @@ -435,6 +439,7 @@ pub async fn start_project_session( action_executor: Some(Arc::clone(&action_executor)), action_registry: Some(Arc::clone(&action_registry)), remote_working_dir: None, + image_ids: image_ids.unwrap_or_default(), }, store, app_handle, @@ -455,6 +460,7 @@ pub async fn start_project_session( /// For remote branches (those with a `workspace_name`), the session runs via /// `blox acp` instead of a local agent binary. Branch context and commit /// detection are skipped since there is no local worktree. +#[allow(clippy::too_many_arguments)] #[tauri::command(rename_all = "camelCase")] pub async fn start_branch_session( store: tauri::State<'_, Mutex>>>, @@ -464,6 +470,7 @@ pub async fn start_branch_session( prompt: String, session_type: BranchSessionType, provider: Option, + image_ids: Option>, ) -> Result { let store = get_store(&store)?; @@ -651,6 +658,7 @@ pub async fn start_branch_session( action_executor: None, action_registry: None, remote_working_dir, + image_ids: image_ids.unwrap_or_default(), }, store, app_handle, @@ -694,6 +702,7 @@ pub(crate) fn build_branch_context( // Notes and reviews from DB timeline.extend(note_timeline_entries(store, branch_id, None)); timeline.extend(review_timeline_entries(store, branch_id)); + timeline.extend(image_timeline_entries(store, branch_id)); // Project-level notes timeline.extend(project_note_timeline_entries(store, project_id, None)); @@ -754,6 +763,7 @@ pub(crate) fn build_remote_branch_context( Some(workspace_name), )); timeline.extend(review_timeline_entries(store, branch_id)); + timeline.extend(image_timeline_entries(store, branch_id)); // Project-level notes timeline.extend(project_note_timeline_entries( @@ -1038,6 +1048,7 @@ fn build_branch_timeline_summary( // remote workspace when available, otherwise local temp files. timeline.extend(note_timeline_entries(store, &branch.id, workspace_name)); timeline.extend(review_timeline_entries(store, &branch.id)); + timeline.extend(image_timeline_entries(store, &branch.id)); if timeline.is_empty() { if let Some(err) = commit_error { @@ -1362,6 +1373,40 @@ fn review_timeline_entries(store: &Arc, branch_id: &str) -> Vec, branch_id: &str) -> Vec { + let images = match store.list_images_for_branch(branch_id) { + Ok(imgs) => imgs, + Err(e) => { + log::warn!("Failed to list images for branch context: {e}"); + return Vec::new(); + } + }; + + images + .iter() + .map(|img| { + let size_label = if img.size_bytes > 1_000_000 { + format!("{:.1} MB", img.size_bytes as f64 / 1_000_000.0) + } else if img.size_bytes > 1_000 { + format!("{:.0} KB", img.size_bytes as f64 / 1_000.0) + } else { + format!("{} B", img.size_bytes) + }; + TimelineEntry { + timestamp: img.created_at, + content: format!( + "### Image: {}\n\nAttached image ({}, {}). If this image was included in the current prompt, it will appear as an image content block.", + img.filename, img.mime_type, size_label + ), + } + }) + .collect() +} + /// Assemble the full prompt from action instructions + branch context + user prompt. fn build_full_prompt( user_prompt: &str, diff --git a/apps/mark/src-tauri/src/session_runner.rs b/apps/mark/src-tauri/src/session_runner.rs index 8b7665b5..63c7028b 100644 --- a/apps/mark/src-tauri/src/session_runner.rs +++ b/apps/mark/src-tauri/src/session_runner.rs @@ -161,6 +161,9 @@ pub struct SessionConfig { /// the agent operates in the correct repo directory (e.g. /// `/home/bloxer/cash-server` instead of the workspace default). pub remote_working_dir: Option, + /// Image IDs to include in the prompt. The runner reads the image files, + /// base64-encodes them, and passes them as content blocks to the driver. + pub image_ids: Vec, } /// Start a session: persist the user message, spawn the agent, stream to DB. @@ -192,10 +195,31 @@ pub fn start_session( }; // Persist the user message right away so it's visible immediately. + // Include image IDs so the frontend can display them alongside the text. + // We also mark attached images as session-scoped immediately after so they + // don't appear in the branch timeline. Both operations are kept together; + // if set_images_session_id fails we log a warning rather than aborting the + // session, since the message was already persisted. store - .add_session_message(&config.session_id, MessageRole::User, &config.prompt) + .add_session_message_with_images( + &config.session_id, + MessageRole::User, + &config.prompt, + &config.image_ids, + ) .map_err(|e| format!("Failed to persist user message: {e}"))?; + if !config.image_ids.is_empty() { + if let Err(e) = store.set_images_session_id(&config.image_ids, &config.session_id) { + log::warn!( + "Failed to associate images {:?} with session {}: {e}. \ + Images may appear orphaned in the branch timeline.", + config.image_ids, + config.session_id + ); + } + } + let cancel_token = registry.register(&config.session_id); // The agent protocol may use !Send futures, so we spin up a dedicated @@ -254,6 +278,43 @@ pub fn start_session( Arc::clone(&store), )); + // Read and base64-encode images for the prompt content blocks. + let mut image_data: Vec<(String, String)> = Vec::new(); + for image_id in &config.image_ids { + match store.get_image(image_id) { + Ok(Some(image)) => { + match crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) { + Ok(path) => { + if let Ok(bytes) = std::fs::read(&path) { + use base64::Engine; + let encoded = + base64::engine::general_purpose::STANDARD.encode(&bytes); + image_data.push((encoded, image.mime_type.clone())); + } else { + log::warn!( + "Failed to read image file for image {image_id}: {}", + path.display() + ); + } + } + Err(e) => { + log::warn!("Failed to resolve file path for image {image_id}: {e}"); + } + } + } + Ok(None) => { + log::warn!("Image {image_id} not found in store, skipping"); + } + Err(e) => { + log::warn!("Failed to fetch image {image_id} from store: {e}"); + } + } + } + // Cast to trait objects for the driver let store_trait: Arc = store; let writer_trait: Arc = writer; @@ -262,6 +323,7 @@ pub fn start_session( .run( &config.session_id, &config.prompt, + &image_data, &config.working_dir, &store_trait, &writer_trait, diff --git a/apps/mark/src-tauri/src/store/images.rs b/apps/mark/src-tauri/src/store/images.rs new file mode 100644 index 00000000..38b5818d --- /dev/null +++ b/apps/mark/src-tauri/src/store/images.rs @@ -0,0 +1,192 @@ +//! Image CRUD operations and filesystem helpers. + +use std::path::PathBuf; + +use rusqlite::{params, OptionalExtension}; + +use super::models::Image; +use super::{Store, StoreError}; + +impl Store { + pub fn create_image(&self, image: &Image) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO images (id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + image.id, + image.branch_id, + image.project_id, + image.session_id, + image.filename, + image.mime_type, + image.size_bytes, + image.created_at, + ], + )?; + Ok(()) + } + + pub fn get_image(&self, id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at + FROM images WHERE id = ?1", + params![id], + Self::row_to_image, + ) + .optional() + .map_err(Into::into) + } + + /// List images attached directly to a branch (not scoped to a session). + /// + /// Images with a `session_id` are excluded — those are session-scoped + /// attachments that only appear in the session message history. + pub fn list_images_for_branch(&self, branch_id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at + FROM images WHERE branch_id = ?1 AND session_id IS NULL ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![branch_id], Self::row_to_image)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Return a filename that is unique among images on the given branch (or project if no branch). + /// If `filename` is already taken, appends ` 2`, ` 3`, … before the extension + /// (e.g. `Screenshot.png` → `Screenshot 2.png`). + pub fn unique_image_filename( + &self, + branch_id: Option<&str>, + project_id: &str, + filename: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let existing: std::collections::HashSet = match branch_id { + Some(bid) => { + let mut stmt = conn.prepare("SELECT filename FROM images WHERE branch_id = ?1")?; + let rows = stmt.query_map(params![bid], |row| row.get::<_, String>(0))?; + rows.filter_map(|r| r.ok()).collect() + } + None => { + let mut stmt = conn.prepare( + "SELECT filename FROM images WHERE project_id = ?1 AND branch_id IS NULL", + )?; + let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?; + rows.filter_map(|r| r.ok()).collect() + } + }; + + if !existing.contains(filename) { + return Ok(filename.to_string()); + } + + let path = std::path::Path::new(filename); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(filename); + let ext = path.extension().and_then(|e| e.to_str()); + + let mut counter = 2u32; + loop { + let candidate = match ext { + Some(e) => format!("{stem} {counter}.{e}"), + None => format!("{stem} {counter}"), + }; + if !existing.contains(&candidate) { + return Ok(candidate); + } + counter += 1; + } + } + + /// Associate images with a session so they are scoped to that session + /// and excluded from the branch timeline. + pub fn set_images_session_id( + &self, + image_ids: &[String], + session_id: &str, + ) -> Result<(), StoreError> { + if image_ids.is_empty() { + return Ok(()); + } + let conn = self.conn.lock().unwrap(); + for id in image_ids { + conn.execute( + "UPDATE images SET session_id = ?1 WHERE id = ?2", + params![session_id, id], + )?; + } + Ok(()) + } + + pub fn delete_image(&self, id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM images WHERE id = ?1", params![id])?; + Ok(()) + } + + /// Delete all images still marked as pending and remove their files from + /// disk. Called once at app startup to clean up images from compose + /// sessions that were abandoned (e.g. the user quit the app mid-dialog). + pub fn cleanup_pending_images(&self) -> Result { + use super::models::PENDING_SESSION_ID; + + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT id, project_id, filename FROM images WHERE session_id = ?1")?; + let rows: Vec<(String, String, String)> = stmt + .query_map(params![PENDING_SESSION_ID], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + + let count = rows.len(); + for (id, project_id, filename) in &rows { + if let Ok(path) = image_file_path(project_id, id, filename) { + let _ = std::fs::remove_file(path); + } + } + + conn.execute( + "DELETE FROM images WHERE session_id = ?1", + params![PENDING_SESSION_ID], + )?; + + Ok(count) + } + + fn row_to_image(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Image { + id: row.get(0)?, + branch_id: row.get(1)?, + project_id: row.get(2)?, + session_id: row.get(3)?, + filename: row.get(4)?, + mime_type: row.get(5)?, + size_bytes: row.get(6)?, + created_at: row.get(7)?, + }) + } +} + +/// Compute the filesystem path for an image file. +/// Path: `/images/.` +pub fn image_file_path( + project_id: &str, + image_id: &str, + filename: &str, +) -> Result { + let project_root = crate::git::project_worktree_root_for(project_id) + .map_err(|e| format!("Cannot determine project root: {e}"))?; + let ext = std::path::Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + Ok(project_root + .join("images") + .join(format!("{image_id}.{ext}"))) +} diff --git a/apps/mark/src-tauri/src/store/messages.rs b/apps/mark/src-tauri/src/store/messages.rs index 0d81eab4..4b55810a 100644 --- a/apps/mark/src-tauri/src/store/messages.rs +++ b/apps/mark/src-tauri/src/store/messages.rs @@ -5,18 +5,50 @@ use rusqlite::params; use super::models::{MessageRole, SessionMessage}; use super::{now_timestamp, Store, StoreError}; +/// Parse a JSON array string into a Vec, returning an empty vec on +/// NULL or invalid JSON. +fn parse_image_ids(raw: Option) -> Vec { + raw.and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + impl Store { pub fn add_session_message( &self, session_id: &str, role: MessageRole, content: &str, + ) -> Result { + self.add_session_message_with_images(session_id, role, content, &[]) + } + + /// Insert a session message, optionally recording attached image IDs. + /// + /// Image IDs are stored as a JSON array string. An empty slice results + /// in NULL (no image_ids column value). + pub fn add_session_message_with_images( + &self, + session_id: &str, + role: MessageRole, + content: &str, + image_ids: &[String], ) -> Result { let conn = self.conn.lock().unwrap(); + let image_ids_json: Option = if image_ids.is_empty() { + None + } else { + Some(serde_json::to_string(image_ids).unwrap()) + }; conn.execute( - "INSERT INTO session_messages (session_id, role, content, created_at) - VALUES (?1, ?2, ?3, ?4)", - params![session_id, role.as_str(), content, now_timestamp()], + "INSERT INTO session_messages (session_id, role, content, created_at, image_ids) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + session_id, + role.as_str(), + content, + now_timestamp(), + image_ids_json + ], )?; Ok(conn.last_insert_rowid()) } @@ -27,17 +59,19 @@ impl Store { ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, created_at + "SELECT id, session_id, role, content, created_at, image_ids FROM session_messages WHERE session_id = ?1 ORDER BY id ASC", )?; let rows = stmt.query_map(params![session_id], |row| { let role_str: String = row.get(2)?; + let image_ids_raw: Option = row.get(5)?; Ok(SessionMessage { id: row.get(0)?, session_id: row.get(1)?, role: MessageRole::parse(&role_str).unwrap_or(MessageRole::User), content: row.get(3)?, created_at: row.get(4)?, + image_ids: parse_image_ids(image_ids_raw), }) })?; rows.collect::, _>>().map_err(Into::into) @@ -62,17 +96,19 @@ impl Store { ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, created_at + "SELECT id, session_id, role, content, created_at, image_ids FROM session_messages WHERE session_id = ?1 AND id >= ?2 ORDER BY id ASC", )?; let rows = stmt.query_map(params![session_id, since_id], |row| { let role_str: String = row.get(2)?; + let image_ids_raw: Option = row.get(5)?; Ok(SessionMessage { id: row.get(0)?, session_id: row.get(1)?, role: MessageRole::parse(&role_str).unwrap_or(MessageRole::User), content: row.get(3)?, created_at: row.get(4)?, + image_ids: parse_image_ids(image_ids_raw), }) })?; rows.collect::, _>>().map_err(Into::into) diff --git a/apps/mark/src-tauri/src/store/mod.rs b/apps/mark/src-tauri/src/store/mod.rs index 59e5cc27..03078a16 100644 --- a/apps/mark/src-tauri/src/store/mod.rs +++ b/apps/mark/src-tauri/src/store/mod.rs @@ -6,13 +6,14 @@ //! a reset-and-recreate dialog. //! //! Tables: schema_version, projects, project_repos, branches, workdirs, commits, -//! sessions, session_messages, notes, project_notes, reviews, action_contexts, repo_actions. +//! sessions, session_messages, notes, project_notes, reviews, images, action_contexts, repo_actions. pub mod models; mod actions; mod branches; mod commits; +pub mod images; mod messages; mod notes; mod project_notes; @@ -62,7 +63,7 @@ impl From for StoreError { /// /// Bump this whenever the schema changes. /// Many app versions may share the same schema version. -pub const SCHEMA_VERSION: i64 = 18; +pub const SCHEMA_VERSION: i64 = 21; /// Oldest schema version we can migrate forward from. /// @@ -340,7 +341,8 @@ impl Store { session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + image_ids TEXT ); CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id); @@ -442,6 +444,18 @@ impl Store { CREATE INDEX IF NOT EXISTS idx_recent_repos_last_used ON recent_repos(last_used_at DESC); + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + branch_id TEXT REFERENCES branches(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_images_branch ON images(branch_id); + -- Session cleanup triggers: when a commit, note, project note, or -- review is deleted (directly or via cascade from branch/project -- deletion), delete the referenced session if no other row still @@ -457,7 +471,8 @@ impl Store { AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_note_delete @@ -470,7 +485,8 @@ impl Store { AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_review_delete @@ -483,7 +499,8 @@ impl Store { AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_project_note_delete @@ -496,7 +513,22 @@ impl Store { AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_image_delete + AFTER DELETE ON images + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; ", )?; @@ -527,6 +559,106 @@ impl Store { .ok(); // Ignore error if column already exists (fresh DB) } + if db_version < 21 { + // v18 → v21: add images table, image_ids column on + // session_messages, and update session cleanup triggers. + + conn.execute_batch( + "ALTER TABLE session_messages ADD COLUMN image_ids TEXT DEFAULT NULL;", + ) + .ok(); // Ignore "duplicate column" on fresh DBs + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + branch_id TEXT REFERENCES branches(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_images_branch ON images(branch_id); + + DROP TRIGGER IF EXISTS trg_cleanup_session_after_commit_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_note_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_review_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_project_note_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_image_delete; + + CREATE TRIGGER trg_cleanup_session_after_commit_delete + AFTER DELETE ON commits + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_note_delete + AFTER DELETE ON notes + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_review_delete + AFTER DELETE ON reviews + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_project_note_delete + AFTER DELETE ON project_notes + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_image_delete + AFTER DELETE ON images + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END;", + )?; + } + // Stamp the current schema version so future opens skip applied migrations. conn.execute( "UPDATE schema_version SET version = ?1", diff --git a/apps/mark/src-tauri/src/store/models.rs b/apps/mark/src-tauri/src/store/models.rs index acd16ff1..46cf447f 100644 --- a/apps/mark/src-tauri/src/store/models.rs +++ b/apps/mark/src-tauri/src/store/models.rs @@ -587,6 +587,10 @@ pub struct SessionMessage { pub role: MessageRole, pub content: String, pub created_at: i64, + /// Image IDs attached to this message (user messages only). + /// Stored as a JSON array string in the DB, deserialized to a Vec here. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub image_ids: Vec, } // ============================================================================= @@ -671,6 +675,75 @@ impl ProjectNote { } } +// ============================================================================= +// Images +// ============================================================================= + +/// An image attached to a branch. +/// +/// The image file is stored on disk at +/// `/images/.`. The `project_id` field +/// determines the filesystem location; the `filename` field preserves the +/// original upload name (and its extension). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub id: String, + pub branch_id: Option, + pub project_id: String, + pub session_id: Option, + pub filename: String, + pub mime_type: String, + pub size_bytes: i64, + pub created_at: i64, +} + +/// Sentinel value stored in `images.session_id` for images that are being +/// composed in a modal but haven't been submitted yet. The branch-timeline +/// query (`WHERE session_id IS NULL`) naturally excludes these, so they never +/// appear in the timeline. When a session is actually started the runner +/// overwrites this with the real session ID via `set_images_session_id`. +/// On app startup any images still marked pending are cleaned up. +pub const PENDING_SESSION_ID: &str = "pending"; + +impl Image { + /// Create a new image record. + /// + /// When `pending` is true the image is created with + /// `session_id = "pending"` so it is invisible in the branch timeline + /// until a session is started (at which point the runner overwrites it + /// with the real session ID). Pass `false` for images that should + /// appear in the timeline immediately (e.g. direct branch-card drops). + pub fn new( + branch_id: Option<&str>, + project_id: &str, + filename: &str, + mime_type: &str, + size_bytes: i64, + pending: bool, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + branch_id: branch_id.map(|s| s.to_string()), + project_id: project_id.to_string(), + session_id: if pending { + Some(PENDING_SESSION_ID.to_string()) + } else { + None + }, + filename: filename.to_string(), + mime_type: mime_type.to_string(), + size_bytes, + created_at: now_timestamp(), + } + } + + pub fn with_session(mut self, session_id: &str) -> Self { + self.session_id = Some(session_id.to_string()); + self + } +} + // ============================================================================= // Recent Repos // ============================================================================= diff --git a/apps/mark/src/lib/commands.ts b/apps/mark/src/lib/commands.ts index 53cbe806..9b1574fb 100644 --- a/apps/mark/src/lib/commands.ts +++ b/apps/mark/src/lib/commands.ts @@ -28,6 +28,7 @@ import type { Issue, PrStatus, PollWorkspaceResult, + Image, } from './types'; // ============================================================================= @@ -175,12 +176,14 @@ export function deleteProjectNote(noteId: string): Promise { export function startProjectSession( projectId: string, prompt: string, - provider?: string + provider?: string, + imageIds?: string[] ): Promise { return invoke('start_project_session', { projectId, prompt, provider: provider ?? null, + imageIds: imageIds?.length ? imageIds : null, }); } @@ -503,8 +506,12 @@ export function startSession( /** Send a follow-up message to an existing session. * The backend uses the provider that originally created the session. */ -export function resumeSession(sessionId: string, prompt: string): Promise { - return invoke('resume_session', { sessionId, prompt }); +export function resumeSession( + sessionId: string, + prompt: string, + imageIds?: string[] +): Promise { + return invoke('resume_session', { sessionId, prompt, imageIds: imageIds ?? null }); } export function cancelSession(sessionId: string): Promise { @@ -520,13 +527,15 @@ export function startBranchSession( branchId: string, prompt: string, sessionType: BranchSessionType, - provider?: string + provider?: string, + imageIds?: string[] ): Promise { return invoke('start_branch_session', { branchId, prompt, sessionType, provider: provider ?? null, + imageIds: imageIds ?? null, }); } @@ -801,3 +810,56 @@ export function refreshPrStatus(branchId: string): Promise { export function refreshAllPrStatuses(projectId: string): Promise { return invoke('refresh_all_pr_statuses', { projectId }); } + +// ============================================================================= +// Images +// ============================================================================= + +/** Upload a local image file and create a DB record for it. */ +export function createImage( + branchId: string | null, + projectId: string, + filePath: string, + pending?: boolean +): Promise { + return invoke('create_image', { branchId, projectId, filePath, pending }); +} + +/** Get the filesystem path for a stored image. */ +export function getImagePath(imageId: string): Promise { + return invoke('get_image_path', { imageId }); +} + +/** Delete an image record and its stored file. */ +export function deleteImage(imageId: string): Promise { + return invoke('delete_image', { imageId }); +} + +/** List all images for a branch. */ +export function listBranchImages(branchId: string): Promise { + return invoke('list_branch_images', { branchId }); +} + +/** Get the base64-encoded data URL for an image. */ +export function getImageData(imageId: string): Promise { + return invoke('get_image_data', { imageId }); +} + +/** Create an image from base64-encoded data (browser file input / clipboard paste). */ +export function createImageFromData( + branchId: string | null, + projectId: string, + filename: string, + mimeType: string, + data: string, + pending?: boolean +): Promise { + return invoke('create_image_from_data', { + branchId, + projectId, + filename, + mimeType, + data, + pending, + }); +} diff --git a/apps/mark/src/lib/features/branches/BranchCard.svelte b/apps/mark/src/lib/features/branches/BranchCard.svelte index cc46ca39..65e6317f 100644 --- a/apps/mark/src/lib/features/branches/BranchCard.svelte +++ b/apps/mark/src/lib/features/branches/BranchCard.svelte @@ -56,6 +56,7 @@ import * as commands from '../../api/commands'; import type { ProjectAction } from '../../api/commands'; import BranchTimeline from '../timeline/BranchTimeline.svelte'; + import ImageViewerModal from '../timeline/ImageViewerModal.svelte'; import DiffModal from '../diff/DiffModal.svelte'; import SessionModal from '../sessions/SessionModal.svelte'; import NewSessionModal from '../sessions/NewSessionModal.svelte'; @@ -87,6 +88,7 @@ groupActionsByType, isPushRejectedNonFastForward, isTextFile, + isImageFile, } from './branchCardHelpers'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; @@ -351,10 +353,19 @@ // Note modal (opened by clicking a note in the timeline) let openNote = $state<{ title: string; content: string } | null>(null); + // Image viewer modal (opened by clicking an image in the timeline) + let viewImageId = $state(null); + let viewImageFilename = $state(''); + let deletingImageIds = $state>(new Set()); + let timelineDeletingItems = $derived( + [...deletingImageIds].map((id) => ({ type: 'image' as const, id })) + ); + // New session modal state let showNewSession = $state(false); let newSessionMode = $state('commit'); let draftPrompt = $state(''); + let draftImageIds = $state([]); type PendingSessionItemType = 'pending-commit' | 'generating-note' | 'generating-review'; type PendingSessionItem = { key: string; @@ -1199,7 +1210,11 @@ } } - async function startBranchSessionWithPendingItem(mode: BranchSessionType, prompt: string) { + async function startBranchSessionWithPendingItem( + mode: BranchSessionType, + prompt: string, + imageIds: string[] = [] + ) { const pendingKey = `session-start-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; pendingSessionItems = [ ...pendingSessionItems, @@ -1217,7 +1232,8 @@ branch.id, prompt, mode, - getPreferredAgent(agents) ?? undefined + getPreferredAgent(agents) ?? undefined, + imageIds.length > 0 ? imageIds : undefined ); if (!result || !result.sessionId) { @@ -1265,20 +1281,31 @@ newSessionMode = 'review'; showNewSession = false; draftPrompt = ''; + draftImageIds = []; await startBranchSessionWithPendingItem('review', reviewPrompt); } - function handleNewSessionClose(draft: { prompt: string; mode: BranchSessionType }) { + function handleNewSessionClose(draft: { + prompt: string; + mode: BranchSessionType; + imageIds: string[]; + }) { draftPrompt = draft.prompt; newSessionMode = draft.mode; + draftImageIds = draft.imageIds; showNewSession = false; } - function handleNewSessionSubmit(data: { prompt: string; mode: BranchSessionType }) { + function handleNewSessionSubmit(data: { + prompt: string; + mode: BranchSessionType; + imageIds: string[]; + }) { newSessionMode = data.mode; showNewSession = false; draftPrompt = ''; - void startBranchSessionWithPendingItem(data.mode, data.prompt); + draftImageIds = []; + void startBranchSessionWithPendingItem(data.mode, data.prompt, data.imageIds); } // ========================================================================= @@ -1410,6 +1437,38 @@ } } + function handleImageClick(imageId: string) { + const image = timeline?.images.find((img) => img.id === imageId); + if (image) { + viewImageId = imageId; + viewImageFilename = image.filename; + } + } + + function handleDeleteImage(imageId: string) { + confirmDelete = { + title: 'Delete Image', + message: 'Are you sure you want to delete this image?', + onConfirm: async () => { + confirmDelete = null; + deletingImageIds = new Set([...deletingImageIds, imageId]); + // Close the viewer if the deleted image is currently being viewed + if (viewImageId === imageId) { + viewImageId = null; + } + try { + await commands.deleteImage(imageId); + loadTimeline(); + } catch (e) { + console.error('Failed to delete image:', e); + notifyError('Failed to delete image', e); + } finally { + deletingImageIds = new Set([...deletingImageIds].filter((id) => id !== imageId)); + } + }, + }; + } + // ========================================================================= // PR creation // ========================================================================= @@ -1663,32 +1722,50 @@ function handleFileDrop(paths: string[]) { const textPaths = paths.filter(isTextFile); - if (textPaths.length === 0) return; - - // Show placeholder items immediately - const placeholders = textPaths.map((filePath) => ({ - key: `drop-${Date.now()}-${filePath}`, - title: fileNameFromPath(filePath), - })); - pendingDropNotes = [...pendingDropNotes, ...placeholders]; - - // Process each file asynchronously without blocking the UI - Promise.all( - textPaths.map(async (filePath, i) => { - try { - const content = await commands.readTextFile(filePath); - const title = fileNameFromPath(filePath); - await commands.createNote(branch.id, title, content); - } catch (e) { - console.error('Failed to create note from dropped file:', e); - } finally { - // Remove this placeholder - pendingDropNotes = pendingDropNotes.filter((p) => p.key !== placeholders[i].key); - } - }) - ).then(() => { - loadTimeline(); - }); + const imagePaths = paths.filter(isImageFile); + + // Handle text file drops (create notes) + if (textPaths.length > 0) { + // Show placeholder items immediately + const placeholders = textPaths.map((filePath) => ({ + key: `drop-${Date.now()}-${filePath}`, + title: fileNameFromPath(filePath), + })); + pendingDropNotes = [...pendingDropNotes, ...placeholders]; + + // Process each file asynchronously without blocking the UI + Promise.all( + textPaths.map(async (filePath, i) => { + try { + const content = await commands.readTextFile(filePath); + const title = fileNameFromPath(filePath); + await commands.createNote(branch.id, title, content); + } catch (e) { + console.error('Failed to create note from dropped file:', e); + } finally { + // Remove this placeholder + pendingDropNotes = pendingDropNotes.filter((p) => p.key !== placeholders[i].key); + } + }) + ).then(() => { + loadTimeline(); + }); + } + + // Handle image file drops + if (imagePaths.length > 0) { + Promise.all( + imagePaths.map(async (filePath) => { + try { + await commands.createImage(branch.id, branch.projectId, filePath); + } catch (e) { + console.error('Failed to create image from drop:', e); + } + }) + ).then(() => { + loadTimeline(); + }); + } } // Subscribe to the shared drag-drop service (local branches only). @@ -2120,6 +2197,7 @@ repoDir={branch.worktreePath} pendingDropNotes={isLocal ? pendingDropNotes : undefined} pendingItems={pendingSessionItems} + deletingItems={timelineDeletingItems} reviewCommentBreakdown={timelineReviewDetailsById} onSessionClick={handleTimelineSessionClick} onCommitClick={handleCommitClick} @@ -2129,6 +2207,8 @@ onDeletePendingCommit={handleDeletePendingCommit} onDeleteNote={handleDeleteNote} onDeleteReview={handleDeleteReview} + onImageClick={handleImageClick} + onDeleteImage={handleDeleteImage} onNewNote={() => openNewSession('note')} onNewCommit={() => openNewSession('commit')} onNewReview={hasCodeChanges ? (e) => openNewSession('review', e) : undefined} @@ -2273,11 +2353,27 @@ (openNote = null)} /> {/if} +{#if viewImageId} + { + viewImageId = null; + }} + onDelete={() => { + if (viewImageId) { + handleDeleteImage(viewImageId); + } + }} + /> +{/if} + {#if showNewSession} { const closedSessionId = openSessionId; openSessionId = null; diff --git a/apps/mark/src/lib/features/branches/branchCardHelpers.ts b/apps/mark/src/lib/features/branches/branchCardHelpers.ts index bd1c5be1..deed9276 100644 --- a/apps/mark/src/lib/features/branches/branchCardHelpers.ts +++ b/apps/mark/src/lib/features/branches/branchCardHelpers.ts @@ -1,6 +1,7 @@ import type { ProjectAction } from '../../api/commands'; const TEXT_EXTENSIONS = ['.txt', '.md', '.markdown', '.text', '.rst', '.org', '.adoc']; +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp']; export function groupActionsByType(actions: ProjectAction[]): Record { const groups: Record = { @@ -113,6 +114,11 @@ export function isTextFile(filePath: string): boolean { return TEXT_EXTENSIONS.some((ext) => lower.endsWith(ext)); } +export function isImageFile(filePath: string): boolean { + const lower = filePath.toLowerCase(); + return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + export function fileNameFromPath(filePath: string): string { const parts = filePath.split('/'); const name = parts[parts.length - 1] || filePath; diff --git a/apps/mark/src/lib/features/branches/dragDrop.ts b/apps/mark/src/lib/features/branches/dragDrop.ts index c5a66bb5..4a63c50e 100644 --- a/apps/mark/src/lib/features/branches/dragDrop.ts +++ b/apps/mark/src/lib/features/branches/dragDrop.ts @@ -1,10 +1,11 @@ /** - * Shared drag-drop service for BranchCards. + * Shared drag-drop service for file drops via Tauri native events. * * Registers a single global Tauri `onDragDropEvent` listener instead of one - * per BranchCard. Each card subscribes with its DOM element and callbacks; - * the service hit-tests the drag position against all registered cards and - * dispatches to the correct one. + * per component. Each subscriber registers its DOM element and callbacks; + * the service hit-tests the drag position against all registered elements + * and dispatches to the topmost match (last registered wins when elements + * overlap, e.g. a modal over a branch card). * * This eliminates the O(N) listener storm that caused UI freezes when * multiple branch cards were rendered. @@ -19,6 +20,12 @@ export type DragDropSubscription = { onDragOver: (over: boolean) => void; /** Called when files are dropped on this card. */ onDrop: (paths: string[]) => void; + /** + * When true, this subscriber blocks all earlier subscribers from receiving + * events — even at positions outside this element's bounds. Use this for + * modal dialogs whose backdrop overlay covers the entire viewport. + */ + blocking?: boolean; }; let subscribers: DragDropSubscription[] = []; @@ -37,11 +44,19 @@ let currentHover: DragDropSubscription | null = null; function handleEvent(type: string, x: number, y: number, paths?: string[]) { if (type === 'enter' || type === 'over') { - // Find which subscriber the cursor is over + // Find which subscriber the cursor is over. + // Iterate in reverse so that later subscribers (e.g. modals layered on + // top of branch cards) take priority over earlier ones at the same + // coordinates. let found: DragDropSubscription | null = null; - for (const sub of subscribers) { - if (isPositionOverElement(sub.element, x, y)) { - found = sub; + for (let i = subscribers.length - 1; i >= 0; i--) { + if (isPositionOverElement(subscribers[i].element, x, y)) { + found = subscribers[i]; + break; + } + // A blocking subscriber (e.g. a modal with a backdrop) prevents events + // from reaching any earlier subscribers, even outside its own bounds. + if (subscribers[i].blocking) { break; } } @@ -58,10 +73,13 @@ function handleEvent(type: string, x: number, y: number, paths?: string[]) { currentHover = found; } } else if (type === 'drop') { - // Find the drop target - for (const sub of subscribers) { - if (isPositionOverElement(sub.element, x, y)) { - sub.onDrop(paths ?? []); + // Find the drop target (reverse order — prefer topmost element) + for (let i = subscribers.length - 1; i >= 0; i--) { + if (isPositionOverElement(subscribers[i].element, x, y)) { + subscribers[i].onDrop(paths ?? []); + break; + } + if (subscribers[i].blocking) { break; } } @@ -104,10 +122,11 @@ function ensureGlobalListener(): Promise { } /** - * Subscribe a BranchCard to drag-drop events. + * Subscribe a component to drag-drop events. * * The global listener is lazily created on the first subscription and - * torn down when the last subscriber unsubscribes. + * torn down when the last subscriber unsubscribes. Later subscribers + * take priority when elements overlap (e.g. modals over cards). * * Returns an unsubscribe function. */ diff --git a/apps/mark/src/lib/features/projects/ProjectSection.svelte b/apps/mark/src/lib/features/projects/ProjectSection.svelte index 2fb867f5..566f38af 100644 --- a/apps/mark/src/lib/features/projects/ProjectSection.svelte +++ b/apps/mark/src/lib/features/projects/ProjectSection.svelte @@ -7,6 +7,7 @@ + + + + + +{#if imageIds.length > 0} +
+ {#each imageIds as imageId} +
+ {#if previews.get(imageId)} + attached + {:else} +
+ {/if} + {#if !disabled} + + {/if} +
+ {/each} + {#if !disabled} + + {/if} +
+{:else if !disabled} + +{/if} + + diff --git a/apps/mark/src/lib/features/sessions/NewSessionModal.svelte b/apps/mark/src/lib/features/sessions/NewSessionModal.svelte index 98ed1143..6fad7309 100644 --- a/apps/mark/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/mark/src/lib/features/sessions/NewSessionModal.svelte @@ -10,26 +10,40 @@ branch — the branch to create a session on mode — 'commit', 'note', or 'review' (shown as title, not togglable) initialPrompt — pre-fill the textarea (e.g. from a previous close) - onClose — called with { prompt, mode } when dismissed - onSubmit — called with { prompt, mode } when submit is pressed + onClose — called with { prompt, mode, imageIds } when dismissed + onSubmit — called with { prompt, mode, imageIds } when submit is pressed --> @@ -112,7 +182,13 @@ onkeydown={(e) => e.key === 'Escape' && handleClose()} > -