diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 3e325ef..07e76c3 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,6 +1,8 @@ use serde::Deserialize; use serde_json::Value as JsonValue; -use tauri::State; +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{Manager, State}; use tauri_plugin_sql::{DbInstances, DbPool}; #[derive(Debug, Deserialize)] @@ -47,6 +49,49 @@ pub async fn execute_transaction( } } +#[tauri::command] +pub fn backup_database_file( + app: tauri::AppHandle, + reason: String, +) -> Result, String> { + let app_config_dir = app + .path() + .app_config_dir() + .map_err(|error| error.to_string())?; + let db_path = app_config_dir.join("glossa.db"); + if !db_path.exists() { + return Ok(None); + } + + let safe_reason: String = reason + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_secs(); + let backup_path = app_config_dir.join(format!("glossa.{timestamp}.{safe_reason}.db.bak")); + fs::copy(&db_path, &backup_path).map_err(|error| error.to_string())?; + + for suffix in ["wal", "shm"] { + let sidecar_path = app_config_dir.join(format!("glossa.db-{suffix}")); + if sidecar_path.exists() { + let sidecar_backup_path = + app_config_dir.join(format!("glossa.{timestamp}.{safe_reason}.db-{suffix}.bak")); + fs::copy(&sidecar_path, sidecar_backup_path).map_err(|error| error.to_string())?; + } + } + + Ok(Some(backup_path.to_string_lossy().into_owned())) +} + fn bind_json_value<'q>( query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>, value: JsonValue, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b56a77..bebbdd5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,9 +62,10 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + db::backup_database_file, db::execute_transaction, llm::pipeline::compute_blobs, -llm::pipeline::run_stage, + llm::pipeline::run_stage, llm::pipeline::run_stage_stream, llm::pipeline::cancel_stream, llm::pipeline::judge_translation, @@ -86,6 +87,9 @@ llm::pipeline::run_stage, vector::embedding::get_embeddings, vector::embedding::split_phrases_llm, vector::embedding::vec_upsert_source_phrase, + vector::embedding::vec_list_phrase_memory, + vector::embedding::vec_delete_phrase_memory, + vector::embedding::vec_update_phrase_memory, vector::embedding::vec_search_phrase_memory, vector::embedding::vec_save_locked_phrases, ]) diff --git a/src-tauri/src/vector/embedding.rs b/src-tauri/src/vector/embedding.rs index f9d4d2f..42809ec 100644 --- a/src-tauri/src/vector/embedding.rs +++ b/src-tauri/src/vector/embedding.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::time::{Duration, Instant}; use tauri::Manager; +const OPENAI_CONNECT_TIMEOUT_SECS: u64 = 10; +const OPENAI_REQUEST_TIMEOUT_SECS: u64 = 45; + #[derive(Debug, thiserror::Error)] pub enum EmbeddingError { #[error("API key not found for provider openai")] @@ -20,7 +24,7 @@ impl Serialize for EmbeddingError { fn db_path(app: &tauri::AppHandle) -> Result { app.path() - .app_data_dir() + .app_config_dir() .map(|p| p.join("glossa.db")) .map_err(|e| EmbeddingError::Http(format!("cannot resolve db path: {e}"))) } @@ -29,6 +33,53 @@ fn floats_to_blob(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn openai_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(OPENAI_CONNECT_TIMEOUT_SECS)) + .timeout(Duration::from_secs(OPENAI_REQUEST_TIMEOUT_SECS)) + .build() + .map_err(|e| EmbeddingError::Http(format!("cannot build OpenAI client: {e}"))) +} + +fn ensure_source_phrase_embeddings_schema( + conn: &rusqlite::Connection, +) -> Result<(), EmbeddingError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS source_phrase_embeddings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + chunk_id TEXT, + source_phrase TEXT NOT NULL, + embedding BLOB NOT NULL, + created_at TEXT NOT NULL + );", + ) + .map_err(|e| EmbeddingError::Http(e.to_string())) +} + +fn ensure_phrase_memory_schema(conn: &rusqlite::Connection) -> Result<(), EmbeddingError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS phrase_memory ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + source_phrase TEXT NOT NULL, + target_phrase TEXT NOT NULL, + source_language TEXT NOT NULL, + target_language TEXT NOT NULL, + author TEXT, + work TEXT, + domain TEXT, + tags TEXT, + notes TEXT, + chunk_id TEXT, + project_id TEXT REFERENCES projects(id), + embedding BLOB NOT NULL, + created_at TEXT NOT NULL + );", + ) + .map_err(|e| EmbeddingError::Http(e.to_string())) +} + // ── OpenAI response types ──────────────────────────────────────────── #[derive(Deserialize)] @@ -52,9 +103,15 @@ pub async fn get_embeddings( if texts.is_empty() { return Ok(vec![]); } + let request_started = Instant::now(); + let total_chars: usize = texts.iter().map(|text| text.len()).sum(); + log::debug!( + "phrase_memory.get_embeddings.start model={model} input_count={} total_chars={total_chars}", + texts.len() + ); - let api_key = crate::keystore::get_api_key(&app, "openai") - .map_err(|_| EmbeddingError::MissingApiKey)?; + let api_key = + crate::keystore::get_api_key(&app, "openai").map_err(|_| EmbeddingError::MissingApiKey)?; if api_key.is_empty() { return Err(EmbeddingError::MissingApiKey); } @@ -65,25 +122,51 @@ pub async fn get_embeddings( "encoding_format": "float" }); - let response = reqwest::Client::new() + let response = openai_client()? .post("https://api.openai.com/v1/embeddings") .bearer_auth(&api_key) .json(&body) .send() .await - .map_err(|e| EmbeddingError::Http(e.to_string()))?; + .map_err(|e| { + log::warn!( + "phrase_memory.get_embeddings.request_failed model={model} input_count={} elapsed_ms={} error={e}", + texts.len(), + request_started.elapsed().as_millis() + ); + EmbeddingError::Http(e.to_string()) + })?; if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); + let preview: String = text.chars().take(500).collect(); + log::warn!( + "phrase_memory.get_embeddings.http_error model={model} input_count={} elapsed_ms={} status={status} body_preview={preview:?}", + texts.len(), + request_started.elapsed().as_millis() + ); return Err(EmbeddingError::Http(format!("{status}: {text}"))); } let parsed: OpenAiEmbeddingResponse = response .json() .await - .map_err(|e| EmbeddingError::Parse(e.to_string()))?; - + .map_err(|e| { + log::warn!( + "phrase_memory.get_embeddings.parse_failed model={model} input_count={} elapsed_ms={} error={e}", + texts.len(), + request_started.elapsed().as_millis() + ); + EmbeddingError::Parse(e.to_string()) + })?; + + log::debug!( + "phrase_memory.get_embeddings.done model={model} input_count={} output_count={} elapsed_ms={}", + texts.len(), + parsed.data.len(), + request_started.elapsed().as_millis() + ); Ok(parsed.data.into_iter().map(|o| o.embedding).collect()) } @@ -92,8 +175,13 @@ pub async fn split_phrases_llm( app: tauri::AppHandle, source_text: String, ) -> Result, EmbeddingError> { - let api_key = crate::keystore::get_api_key(&app, "openai") - .map_err(|_| EmbeddingError::MissingApiKey)?; + let request_started = Instant::now(); + log::debug!( + "phrase_memory.split_phrases_llm.start source_chars={}", + source_text.len() + ); + let api_key = + crate::keystore::get_api_key(&app, "openai").map_err(|_| EmbeddingError::MissingApiKey)?; if api_key.is_empty() { return Err(EmbeddingError::MissingApiKey); } @@ -113,24 +201,41 @@ pub async fn split_phrases_llm( "response_format": {"type": "json_object"} }); - let response = reqwest::Client::new() + let response = openai_client()? .post("https://api.openai.com/v1/chat/completions") .bearer_auth(&api_key) .json(&body) .send() .await - .map_err(|e| EmbeddingError::Http(e.to_string()))?; + .map_err(|e| { + log::warn!( + "phrase_memory.split_phrases_llm.request_failed source_chars={} elapsed_ms={} error={e}", + source_text.len(), + request_started.elapsed().as_millis() + ); + EmbeddingError::Http(e.to_string()) + })?; if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); + let preview: String = text.chars().take(500).collect(); + log::warn!( + "phrase_memory.split_phrases_llm.http_error source_chars={} elapsed_ms={} status={status} body_preview={preview:?}", + source_text.len(), + request_started.elapsed().as_millis() + ); return Err(EmbeddingError::Http(format!("{status}: {text}"))); } - let json: serde_json::Value = response - .json() - .await - .map_err(|e| EmbeddingError::Parse(e.to_string()))?; + let json: serde_json::Value = response.json().await.map_err(|e| { + log::warn!( + "phrase_memory.split_phrases_llm.parse_failed source_chars={} elapsed_ms={} error={e}", + source_text.len(), + request_started.elapsed().as_millis() + ); + EmbeddingError::Parse(e.to_string()) + })?; let content = json["choices"][0]["message"]["content"] .as_str() @@ -157,6 +262,12 @@ pub async fn split_phrases_llm( .map(|s| s.to_string()) .collect(); + log::debug!( + "phrase_memory.split_phrases_llm.done source_chars={} phrase_count={} elapsed_ms={}", + source_text.len(), + validated.len(), + request_started.elapsed().as_millis() + ); Ok(validated) } @@ -171,6 +282,7 @@ pub async fn vec_upsert_source_phrase( let path = db_path(&app)?; let conn = crate::vector::open_vec_connection(&path) .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_source_phrase_embeddings_schema(&conn)?; conn.execute( "INSERT OR REPLACE INTO source_phrase_embeddings \ @@ -191,6 +303,122 @@ pub struct PhraseMatchResult { pub distance: f64, } +#[derive(Debug, Serialize, Deserialize)] +pub struct PhraseMemoryEntryResult { + pub id: String, + pub workspace_id: String, + pub source_phrase: String, + pub target_phrase: String, + pub source_language: String, + pub target_language: String, + pub author: Option, + pub work: Option, + pub domain: Option, + pub tags: Option, + pub notes: Option, + pub chunk_id: Option, + pub project_id: Option, + pub created_at: String, +} + +#[tauri::command] +pub async fn vec_list_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, +) -> Result, EmbeddingError> { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let mut stmt = conn + .prepare( + "SELECT id, workspace_id, source_phrase, target_phrase, source_language, target_language, \ + author, work, domain, tags, notes, chunk_id, project_id, created_at \ + FROM phrase_memory \ + WHERE workspace_id = ?1 \ + ORDER BY datetime(created_at) DESC, id DESC", + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + let results: rusqlite::Result> = stmt + .query_map(rusqlite::params![workspace_id], |row| { + Ok(PhraseMemoryEntryResult { + id: row.get(0)?, + workspace_id: row.get(1)?, + source_phrase: row.get(2)?, + target_phrase: row.get(3)?, + source_language: row.get(4)?, + target_language: row.get(5)?, + author: row.get(6)?, + work: row.get(7)?, + domain: row.get(8)?, + tags: row.get(9)?, + notes: row.get(10)?, + chunk_id: row.get(11)?, + project_id: row.get(12)?, + created_at: row.get(13)?, + }) + }) + .map_err(|e| EmbeddingError::Http(e.to_string()))? + .collect(); + + results.map_err(|e| EmbeddingError::Http(e.to_string())) +} + +#[tauri::command] +pub async fn vec_delete_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, + phrase_memory_id: String, +) -> Result { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let deleted = conn + .execute( + "DELETE FROM phrase_memory WHERE id = ?1 AND workspace_id = ?2", + rusqlite::params![phrase_memory_id, workspace_id], + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + Ok(deleted as u32) +} + +#[tauri::command] +pub async fn vec_update_phrase_memory( + app: tauri::AppHandle, + workspace_id: String, + phrase_memory_id: String, + source_phrase: String, + target_phrase: String, + embedding: Vec, +) -> Result { + let path = db_path(&app)?; + let conn = crate::vector::open_vec_connection(&path) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; + + let updated = conn + .execute( + "UPDATE phrase_memory \ + SET source_phrase = ?1, target_phrase = ?2, embedding = ?3 \ + WHERE id = ?4 AND workspace_id = ?5", + rusqlite::params![ + source_phrase, + target_phrase, + floats_to_blob(&embedding), + phrase_memory_id, + workspace_id + ], + ) + .map_err(|e| EmbeddingError::Http(e.to_string()))?; + + Ok(updated as u32) +} + #[tauri::command] pub async fn vec_search_phrase_memory( app: tauri::AppHandle, @@ -202,6 +430,7 @@ pub async fn vec_search_phrase_memory( let path = db_path(&app)?; let conn = crate::vector::open_vec_connection(&path) .map_err(|e| EmbeddingError::Http(e.to_string()))?; + ensure_phrase_memory_schema(&conn)?; let blob = floats_to_blob(&query_embedding); @@ -259,22 +488,87 @@ pub async fn vec_save_locked_phrases( source_language: String, target_language: String, ) -> Result { + let save_started = Instant::now(); + log::debug!( + "phrase_memory.vec_save_locked_phrases.start workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={} min_phrase_length={min_phrase_length}", + pairs.len() + ); if pairs.is_empty() { return Ok(0); } let path = db_path(&app)?; - let conn = crate::vector::open_vec_connection(&path) - .map_err(|e| EmbeddingError::Http(e.to_string()))?; + log::debug!( + "phrase_memory.vec_save_locked_phrases.db_path path={}", + path.display() + ); + let mut conn = crate::vector::open_vec_connection(&path).map_err(|e| { + log::warn!( + "phrase_memory.vec_save_locked_phrases.db_open_failed path={} error={e}", + path.display() + ); + EmbeddingError::Http(e.to_string()) + })?; + log::debug!("phrase_memory.vec_save_locked_phrases.db_opened"); + ensure_phrase_memory_schema(&conn).map_err(|e| { + log::warn!("phrase_memory.vec_save_locked_phrases.schema_phrase_failed error={e}"); + e + })?; + ensure_source_phrase_embeddings_schema(&conn).map_err(|e| { + log::warn!("phrase_memory.vec_save_locked_phrases.schema_source_failed error={e}"); + e + })?; + log::debug!("phrase_memory.vec_save_locked_phrases.schema_ready"); + + let workspace_exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE id = ?1", + rusqlite::params![&workspace_id], + |row| row.get(0), + ) + .map_err(|e| { + log::warn!( + "phrase_memory.vec_save_locked_phrases.workspace_check_failed workspace_id={workspace_id} error={e}" + ); + EmbeddingError::Http(e.to_string()) + })?; + let project_exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM projects WHERE id = ?1", + rusqlite::params![&project_id], + |row| row.get(0), + ) + .map_err(|e| { + log::warn!( + "phrase_memory.vec_save_locked_phrases.project_check_failed project_id={project_id} error={e}" + ); + EmbeddingError::Http(e.to_string()) + })?; + log::debug!( + "phrase_memory.vec_save_locked_phrases.refs workspace_id={workspace_id} workspace_exists={workspace_exists} project_id={project_id} project_exists={project_exists}" + ); + if workspace_exists == 0 || project_exists == 0 { + return Err(EmbeddingError::Http(format!( + "phrase memory references missing: workspace_id={workspace_id} exists={workspace_exists}, project_id={project_id} exists={project_exists}" + ))); + } let mut saved: u32 = 0; - - for pair in &pairs { + let mut attempted: u32 = 0; + let mut skipped_short: u32 = 0; + let tx = conn.transaction().map_err(|e| { + log::warn!("phrase_memory.vec_save_locked_phrases.transaction_failed error={e}"); + EmbeddingError::Http(e.to_string()) + })?; + + for (index, pair) in pairs.iter().enumerate() { if (pair.source_phrase.len() as u32) < min_phrase_length { + skipped_short += 1; continue; } - let rows = conn + attempted += 1; + let rows = tx .execute( "INSERT OR IGNORE INTO phrase_memory \ (id, workspace_id, project_id, chunk_id, source_phrase, target_phrase, \ @@ -291,16 +585,46 @@ pub async fn vec_save_locked_phrases( floats_to_blob(&pair.source_embedding) ], ) - .map_err(|e| EmbeddingError::Http(e.to_string()))?; + .map_err(|e| { + log::warn!( + "phrase_memory.vec_save_locked_phrases.insert_failed workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_index={index} source_chars={} target_chars={} embedding_dim={} error={e}", + pair.source_phrase.len(), + pair.target_phrase.len(), + pair.source_embedding.len() + ); + EmbeddingError::Http(format!("phrase memory insert failed at pair {index}: {e}")) + })?; saved += rows as u32; } + log::debug!( + "phrase_memory.vec_save_locked_phrases.insert_loop_done pair_count={} attempted={attempted} skipped_short={skipped_short} saved={saved}", + pairs.len() + ); - conn.execute( - "DELETE FROM source_phrase_embeddings WHERE chunk_id = ?1 AND project_id = ?2", - rusqlite::params![chunk_id, project_id], - ) - .map_err(|e| EmbeddingError::Http(e.to_string()))?; - + let deleted_source_embeddings = tx + .execute( + "DELETE FROM source_phrase_embeddings WHERE chunk_id = ?1 AND project_id = ?2", + rusqlite::params![&chunk_id, &project_id], + ) + .map_err(|e| { + log::warn!( + "phrase_memory.vec_save_locked_phrases.cleanup_failed project_id={project_id} chunk_id={chunk_id} error={e}" + ); + EmbeddingError::Http(e.to_string()) + })?; + log::debug!( + "phrase_memory.vec_save_locked_phrases.cleanup_done deleted_source_embeddings={deleted_source_embeddings}" + ); + tx.commit().map_err(|e| { + log::warn!("phrase_memory.vec_save_locked_phrases.commit_failed error={e}"); + EmbeddingError::Http(e.to_string()) + })?; + + log::info!( + "phrase_memory.vec_save_locked_phrases.done workspace_id={workspace_id} project_id={project_id} chunk_id={chunk_id} pair_count={} attempted={attempted} skipped_short={skipped_short} saved={saved} elapsed_ms={}", + pairs.len(), + save_started.elapsed().as_millis() + ); Ok(saved) } diff --git a/src-tauri/src/vector/mod.rs b/src-tauri/src/vector/mod.rs index fd07cfa..31e22e0 100644 --- a/src-tauri/src/vector/mod.rs +++ b/src-tauri/src/vector/mod.rs @@ -18,7 +18,9 @@ pub fn register_vec_extension() { pub fn open_vec_connection(db_path: &PathBuf) -> RusqliteResult { let conn = Connection::open(db_path)?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?; + conn.execute_batch( + "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=10000;", + )?; Ok(conn) } @@ -26,15 +28,13 @@ pub fn open_vec_connection(db_path: &PathBuf) -> RusqliteResult { pub fn vec_ping(app: tauri::AppHandle) -> Result { let db_path = get_db_path(&app)?; open_vec_connection(&db_path) - .and_then(|conn| { - conn.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0)) - }) + .and_then(|conn| conn.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0))) .map_err(|e| e.to_string()) } pub fn get_db_path(app: &tauri::AppHandle) -> Result { app.path() - .app_data_dir() + .app_config_dir() .map(|d| d.join("glossa.db")) .map_err(|e| e.to_string()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d415eb..ef2f526 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,10 +13,10 @@ "windows": [ { "title": "Glossa — Translation Pipeline", - "width": 1600, - "height": 1280, - "minWidth": 1200, - "minHeight": 900, + "width": 1920, + "height": 1080, + "minWidth": 1280, + "minHeight": 760, "resizable": true, "fullscreen": false } diff --git a/src/App.tsx b/src/App.tsx index 86f91cf..7f49c32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -356,7 +356,7 @@ export default function App() { // ── Shell loading ──────────────────────────────────────────────────── if (!isLoaded) { return ( -
+
Caricamento...
); @@ -378,9 +378,9 @@ export default function App() { } return ( - + -
+
diff --git a/src/components/common/HighlightedText.tsx b/src/components/common/HighlightedText.tsx index 739687e..b29f4c4 100644 --- a/src/components/common/HighlightedText.tsx +++ b/src/components/common/HighlightedText.tsx @@ -1,19 +1,20 @@ import { forwardRef } from 'react'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, HTMLAttributes } from 'react'; -interface Props { +interface Props extends HTMLAttributes { html: string; className?: string; style?: CSSProperties; } export const HighlightedText = forwardRef( - function HighlightedText({ html, className = '', style }, ref) { + function HighlightedText({ html, className = '', style, ...rest }, ref) { return (
diff --git a/src/components/common/MarkdownEditor.test.tsx b/src/components/common/MarkdownEditor.test.tsx new file mode 100644 index 0000000..8eb47ea --- /dev/null +++ b/src/components/common/MarkdownEditor.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { MarkdownEditor } from './MarkdownEditor'; + +function EditableFixture() { + const [value, setValue] = useState('Ciao'); + return ( + + ); +} + +describe('MarkdownEditor', () => { + it('gestisce Ctrl+Z su textarea controlled', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'Ciao mondo' } }); + expect(textarea).toHaveValue('Ciao mondo'); + + fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true }); + + await waitFor(() => { + expect(textarea).toHaveValue('Ciao'); + }); + }); + + it('non renderizza una textarea per contenuti readonly senza highlight', () => { + render( + {}} + readOnly + identityKey="chunk-1:stage:translation" + />, + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText('Output stage readonly')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/MarkdownEditor.tsx b/src/components/common/MarkdownEditor.tsx index 7177957..c004a29 100644 --- a/src/components/common/MarkdownEditor.tsx +++ b/src/components/common/MarkdownEditor.tsx @@ -1,7 +1,8 @@ -import { Bold, Columns2, Eye, Heading1, Heading2, Heading3, Italic, Link2, List, ListOrdered, Minus, PanelTopClose, PanelTopOpen, Pencil, Pilcrow, Plus, Type } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { Bold, CircleHelp, Columns2, Eye, Heading1, Heading2, Heading3, Italic, Link2, List, ListOrdered, Minus, PanelTopClose, PanelTopOpen, Pencil, Pilcrow, Plus, Type } from 'lucide-react'; +import type { KeyboardEvent as ReactKeyboardEvent, MutableRefObject, ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useUiStore } from '../../stores/uiStore'; import { renderMarkdownToHtmlFragment } from '../../services/markdown'; import { applyMarkdownCommand, @@ -9,9 +10,16 @@ import { type MarkdownCommand, } from './markdownEditorUtils'; import { HighlightedText } from './HighlightedText'; +import { IconButton } from '../ui'; type EditorMode = 'write' | 'preview' | 'split'; +type HistoryEntry = { + value: string; + selectionStart: number; + selectionEnd: number; +}; + const TEXT_SIZE_STEPS = [ { fontSize: '0.75rem', lineHeight: '1.5rem' }, { fontSize: '0.825rem', lineHeight: '1.65rem' }, @@ -22,6 +30,8 @@ const TEXT_SIZE_STEPS = [ { fontSize: '1.375rem', lineHeight: '2.75rem' }, ] as const; const DEFAULT_TEXT_SIZE_STEP = 3; +const MAX_UNDO_ENTRIES = 100; +const TYPING_UNDO_COALESCE_MS = 800; interface MarkdownEditorProps { value: string; @@ -38,6 +48,7 @@ interface MarkdownEditorProps { focusRequestId?: number; onFocusQueryHandled?: () => void; fillHeight?: boolean; + identityKey?: string; } export function MarkdownEditor({ @@ -55,10 +66,18 @@ export function MarkdownEditor({ focusRequestId = 0, onFocusQueryHandled, fillHeight = false, + identityKey = 'default', }: MarkdownEditorProps) { const { t } = useTranslation(); + const setShowHelp = useUiStore((state) => state.setShowHelp); const textareaRef = useRef(null); const highlightLayerRef = useRef(null); + const readOnlyHighlightRef = useRef(null); + const undoStackRef = useRef([]); + const redoStackRef = useRef([]); + const lastValueRef = useRef(value); + const lastTypingChangeAtRef = useRef(0); + const previousIdentityRef = useRef(identityKey); const [mode, setMode] = useState('write'); const [textSizeStep, setTextSizeStep] = useState(DEFAULT_TEXT_SIZE_STEP); const [selection, setSelection] = useState({ start: 0, end: 0 }); @@ -86,6 +105,29 @@ export function MarkdownEditor({ }, [markdownEnabled, mode, selection.end, selection.start, value]); const commandEditingDisabled = readOnly || disabled || mode === 'preview'; + useEffect(() => { + if (previousIdentityRef.current === identityKey) { + lastValueRef.current = value; + return; + } + + previousIdentityRef.current = identityKey; + lastValueRef.current = value; + lastTypingChangeAtRef.current = 0; + undoStackRef.current = []; + redoStackRef.current = []; + updateSelection(0, 0); + requestAnimationFrame(() => { + const element = textareaRef.current ?? readOnlyHighlightRef.current; + if (!element) return; + element.scrollTop = 0; + if (element instanceof HTMLTextAreaElement) { + element.setSelectionRange(0, 0); + } + syncHighlightLayer(); + }); + }, [identityKey, value]); + useEffect(() => { if (!markdownEnabled && mode === 'split') { setMode('write'); @@ -106,8 +148,6 @@ export function MarkdownEditor({ useEffect(() => { if (!focusQuery) return; - const element = textareaRef.current; - if (!element) return; const normalizedQuery = focusQuery.trim(); if (!normalizedQuery) return; const lowerValue = value.toLowerCase(); @@ -117,8 +157,11 @@ export function MarkdownEditor({ setMode('write'); requestAnimationFrame(() => { + const element = textareaRef.current ?? readOnlyHighlightRef.current; + if (!element) return; element.scrollTop = Math.max(0, element.scrollHeight * (matchIndex / Math.max(1, value.length)) - 120); syncHighlightLayer(); + element.dispatchEvent(new Event('scroll', { bubbles: true })); onFocusQueryHandled?.(); }); }, [focusQuery, focusRequestId, onFocusQueryHandled, value]); @@ -143,15 +186,99 @@ export function MarkdownEditor({ updateSelection(element.selectionStart, element.selectionEnd); }; + const currentHistoryEntry = (): HistoryEntry => { + const element = textareaRef.current; + return { + value: lastValueRef.current, + selectionStart: element?.selectionStart ?? selection.start, + selectionEnd: element?.selectionEnd ?? selection.end, + }; + }; + + const pushUndoEntry = (entry: HistoryEntry, coalesceTyping = false) => { + const now = Date.now(); + const stack = undoStackRef.current; + const shouldCoalesce = + coalesceTyping && + stack.length > 0 && + now - lastTypingChangeAtRef.current < TYPING_UNDO_COALESCE_MS; + + if (!shouldCoalesce && stack[stack.length - 1]?.value !== entry.value) { + stack.push(entry); + if (stack.length > MAX_UNDO_ENTRIES) stack.shift(); + } + + lastTypingChangeAtRef.current = coalesceTyping ? now : 0; + redoStackRef.current = []; + }; + + const restoreHistoryEntry = ( + entry: HistoryEntry, + oppositeStack: MutableRefObject, + ) => { + oppositeStack.current.push(currentHistoryEntry()); + if (oppositeStack.current.length > MAX_UNDO_ENTRIES) oppositeStack.current.shift(); + lastValueRef.current = entry.value; + onChange(entry.value); + requestAnimationFrame(() => { + const element = textareaRef.current; + if (!element) return; + element.focus(); + const safeStart = Math.min(entry.selectionStart, entry.value.length); + const safeEnd = Math.min(entry.selectionEnd, entry.value.length); + element.setSelectionRange(safeStart, safeEnd); + updateSelection(safeStart, safeEnd); + syncHighlightLayer(); + }); + }; + + const undo = () => { + const entry = undoStackRef.current.pop(); + if (!entry) return; + restoreHistoryEntry(entry, redoStackRef); + }; + + const redo = () => { + const entry = redoStackRef.current.pop(); + if (!entry) return; + restoreHistoryEntry(entry, undoStackRef); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (readOnly || disabled) return; + const modifier = event.metaKey || event.ctrlKey; + const key = event.key.toLowerCase(); + if (!modifier || event.altKey) return; + + if (key === 'z') { + event.preventDefault(); + if (event.shiftKey) redo(); + else undo(); + } else if (key === 'y') { + event.preventDefault(); + redo(); + } + }; + + const handleTextChange = (nextValue: string) => { + if (readOnly || disabled) return; + if (nextValue === lastValueRef.current) return; + pushUndoEntry(currentHistoryEntry(), true); + lastValueRef.current = nextValue; + onChange(nextValue); + }; + const applyCommand = (command: MarkdownCommand) => { const element = textareaRef.current; if (!element || readOnly || disabled || !markdownEnabled) return; + pushUndoEntry(currentHistoryEntry()); const result = applyMarkdownCommand({ command, value, selectionStart: element.selectionStart, selectionEnd: element.selectionEnd, }); + lastValueRef.current = result.value; onChange(result.value); requestAnimationFrame(() => { element.focus(); @@ -160,19 +287,22 @@ export function MarkdownEditor({ }); }; - const textareaClassName = `${fillHeight ? 'flex-1 min-h-[100px] h-0' : minHeightClassName} w-full resize-y bg-transparent outline-none ${textClassName} disabled:opacity-70 read-only:cursor-not-allowed`; + const textareaClassName = `${fillHeight ? 'flex-1 min-h-[100px] h-0' : minHeightClassName} w-full resize-y bg-transparent outline-none custom-scrollbar ${textClassName} disabled:opacity-70 read-only:cursor-not-allowed`; const textarea = (