From 6753e29f844f9d944178e0d8be851c7acb9b0eb2 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 15:30:31 +0800 Subject: [PATCH 1/9] refactor(orgtrack): remove unused imported history modules --- .../src/sources/imported_history/chunks.rs | 141 ----------- .../src/sources/imported_history/impact.rs | 232 ------------------ .../src/sources/imported_history/rows.rs | 227 ----------------- 3 files changed, 600 deletions(-) delete mode 100644 src-tauri/crates/orgtrack-core/src/sources/imported_history/chunks.rs delete mode 100644 src-tauri/crates/orgtrack-core/src/sources/imported_history/impact.rs delete mode 100644 src-tauri/crates/orgtrack-core/src/sources/imported_history/rows.rs diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/chunks.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/chunks.rs deleted file mode 100644 index 3643636a9a..0000000000 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/chunks.rs +++ /dev/null @@ -1,141 +0,0 @@ -use core_types::activity::ActivityChunk; -use serde_json::json; - -use super::{ - ImportedToolCall, ACTION_TYPE_ASSISTANT, ACTION_TYPE_RAW, ACTION_TYPE_THINKING, - ACTION_TYPE_TOOL_CALL, FUNCTION_ASSISTANT, FUNCTION_THINKING, FUNCTION_USER_MESSAGE, - IMPORTED_STATUS_COMPLETED, -}; - -/// Internal wrapper blocks ORGII prepends to the prompt it hands the CLI: -/// the GUI exec-mode briefing and the IDE-context injection -/// (`inject_ide_context_into_prompt`). The CLI's native transcript stores -/// the full prompt verbatim, so replay readers must strip these to recover -/// what the user actually typed. -const INTERNAL_CONTEXT_BLOCKS: &[(&str, &str)] = &[ - ("", ""), - ("", ""), -]; - -/// Repeatedly strip LEADING internal wrapper blocks (exec-mode briefing, -/// IDE context) from `text`, in any order. -/// -/// If a known tag opens but never closes (e.g. a truncated title), the whole -/// remainder is treated as internal and `""` is returned — an unclosed -/// internal block never carries user-authored text after it. -pub fn strip_internal_context_blocks(text: &str) -> &str { - let mut remaining = text; - let mut stripped = false; - 'outer: loop { - let candidate = remaining.trim_start(); - for (open, close) in INTERNAL_CONTEXT_BLOCKS { - if let Some(rest) = candidate.strip_prefix(open) { - match rest.find(close) { - Some(end) => { - remaining = &rest[end + close.len()..]; - stripped = true; - continue 'outer; - } - None => return "", - } - } - } - break; - } - if stripped { - remaining.trim_start() - } else { - text - } -} - -/// GUI-launched runs prefix the task with an internal exec-mode briefing; -/// strip it so titles/replay show only what the user typed. -/// -/// Back-compat name: now also strips the `` injection via -/// [`strip_internal_context_blocks`]. -pub fn strip_orgii_exec_mode_bridge(text: &str) -> &str { - strip_internal_context_blocks(text) -} - -pub fn user_message_chunk( - session_id: &str, - provider_slug: &str, - sequence: usize, - created_at: &str, - message: &str, -) -> ActivityChunk { - // Single funnel for every imported reader's user bubbles: strip the - // GUI exec-mode briefing and IDE-context injection here so no source - // can leak them into replay. - let message = strip_internal_context_blocks(message); - let mut chunk = ActivityChunk::new(session_id, ACTION_TYPE_RAW, FUNCTION_USER_MESSAGE); - chunk.chunk_id = format!("{provider_slug}-user-{sequence}"); - chunk.created_at = created_at.to_string(); - chunk.result = json!({ - "type": "user", - "message": { "content": message, "role": "user" }, - }); - chunk -} - -pub fn assistant_message_chunk( - session_id: &str, - provider_slug: &str, - sequence: usize, - created_at: &str, - message: &str, -) -> ActivityChunk { - let mut chunk = ActivityChunk::new(session_id, ACTION_TYPE_ASSISTANT, FUNCTION_ASSISTANT); - chunk.chunk_id = format!("{provider_slug}-asst-{sequence}"); - chunk.created_at = created_at.to_string(); - chunk.result = json!({ - "observation": message, - "content": message, - "role": "assistant", - "is_delta": false, - "is_full_content": true, - }); - chunk -} - -pub fn thinking_chunk( - session_id: &str, - provider_slug: &str, - sequence: usize, - created_at: &str, - thought: &str, -) -> ActivityChunk { - let mut chunk = ActivityChunk::new(session_id, ACTION_TYPE_THINKING, FUNCTION_THINKING); - chunk.chunk_id = format!("{provider_slug}-thinking-{sequence}"); - chunk.created_at = created_at.to_string(); - chunk.result = json!({ - "thought": thought, - "content": thought, - "observation": thought, - "is_delta": false, - }); - chunk -} - -pub fn tool_call_chunk( - session_id: &str, - provider_slug: &str, - sequence: usize, - call: &ImportedToolCall, - output: &str, -) -> ActivityChunk { - let mut chunk = ActivityChunk::new(session_id, ACTION_TYPE_TOOL_CALL, &call.canonical_name); - chunk.chunk_id = format!("{provider_slug}-tool-{sequence}-{}", call.call_id); - chunk.created_at = call.created_at.clone(); - chunk.args = call.args.clone(); - chunk.result = json!({ - "success": true, - "status": IMPORTED_STATUS_COMPLETED, - "call_id": call.call_id, - "output": output, - "observation": output, - "raw_tool_name": call.raw_name, - }); - chunk -} diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/impact.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/impact.rs deleted file mode 100644 index 580899d6bb..0000000000 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/impact.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::collections::BTreeSet; - -use core_types::activity::ActivityChunk; -use serde_json::Value; - -use super::metadata::ImportedHistoryImpactStats; -use super::{ACTION_TYPE_TOOL_CALL, FUNCTION_EDIT_FILE}; - -/// Derive conservative file-impact metadata from normalized edit tool calls. -/// -/// Source loaders remain responsible for recognizing their native tool names and -/// reshaping them to [`FUNCTION_EDIT_FILE`]. This collector intentionally ignores -/// failed edits and only counts line changes when the source exposes a diff or -/// before/after text. -pub fn impact_from_edit_chunks(chunks: &[ActivityChunk]) -> ImportedHistoryImpactStats { - let mut touched_files = BTreeSet::new(); - let mut lines_added = 0_i64; - let mut lines_removed = 0_i64; - - for chunk in chunks { - if chunk.action_type != ACTION_TYPE_TOOL_CALL - || chunk.function != FUNCTION_EDIT_FILE - || edit_chunk_failed(chunk) - { - continue; - } - - collect_edit_paths(&chunk.args, &mut touched_files); - - if let Some(patch) = find_string(&chunk.args, &["patch", "diff"]) { - collect_patch_paths(patch, &mut touched_files); - let (added, removed) = count_patch_lines(patch); - lines_added += added; - lines_removed += removed; - continue; - } - - let old = find_string( - &chunk.args, - &[ - "old_string", - "oldString", - "old_text", - "oldText", - "old_content", - "oldContent", - ], - ) - .or_else(|| { - find_string( - &chunk.result, - &[ - "old_content", - "oldContent", - "before_content", - "beforeContent", - ], - ) - }); - let new = find_string( - &chunk.args, - &[ - "new_string", - "newString", - "new_text", - "newText", - "new_content", - "newContent", - "content", - ], - ) - .or_else(|| { - find_string( - &chunk.result, - &["new_content", "newContent", "after_content", "afterContent"], - ) - }); - - if old.is_some() || new.is_some() { - lines_removed += old.map(nonempty_line_count).unwrap_or_default(); - lines_added += new.map(nonempty_line_count).unwrap_or_default(); - } - } - - let touched_files = touched_files.into_iter().collect::>(); - ImportedHistoryImpactStats { - files_changed: touched_files.len() as i64, - lines_added, - lines_removed, - touched_files, - } -} - -fn edit_chunk_failed(chunk: &ActivityChunk) -> bool { - if chunk.result.get("success").and_then(Value::as_bool) == Some(false) { - return true; - } - chunk - .result - .get("status") - .and_then(Value::as_str) - .is_some_and(|status| { - matches!( - status.trim().to_ascii_lowercase().as_str(), - "failed" | "error" | "cancelled" | "canceled" | "rejected" - ) - }) -} - -fn collect_edit_paths(value: &Value, paths: &mut BTreeSet) { - const PATH_KEYS: &[&str] = &[ - "file_path", - "filePath", - "path", - "targetFile", - "relativeWorkspacePath", - ]; - let Some(object) = value.as_object() else { - return; - }; - for key in PATH_KEYS { - if let Some(path) = object.get(*key).and_then(Value::as_str) { - insert_touched_path(path, paths); - } - } - if let Some(payload) = object.get("payload") { - collect_edit_paths(payload, paths); - } -} - -fn find_string<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> { - let object = value.as_object()?; - for key in keys { - if let Some(text) = object.get(*key).and_then(Value::as_str) { - return Some(text); - } - } - object - .get("payload") - .and_then(|payload| find_string(payload, keys)) -} - -fn collect_patch_paths(patch: &str, paths: &mut BTreeSet) { - for line in patch.lines() { - let candidate = line - .strip_prefix("*** Add File: ") - .or_else(|| line.strip_prefix("*** Update File: ")) - .or_else(|| line.strip_prefix("*** Delete File: ")) - .or_else(|| line.strip_prefix("*** Move to: ")) - .or_else(|| line.strip_prefix("rename from ")) - .or_else(|| line.strip_prefix("rename to ")) - .or_else(|| line.strip_prefix("+++ ")) - .or_else(|| line.strip_prefix("--- ")); - if let Some(candidate) = candidate { - insert_touched_path(candidate, paths); - } - } -} - -fn insert_touched_path(path: &str, paths: &mut BTreeSet) { - let path = path.trim().trim_matches('"'); - let path = path - .strip_prefix("a/") - .or_else(|| path.strip_prefix("b/")) - .unwrap_or(path); - if !path.is_empty() && path != "/dev/null" { - paths.insert(path.to_string()); - } -} - -fn count_patch_lines(patch: &str) -> (i64, i64) { - patch.lines().fold((0, 0), |(added, removed), line| { - if line.starts_with("+++") || line.starts_with("---") { - (added, removed) - } else if line.starts_with('+') { - (added + 1, removed) - } else if line.starts_with('-') { - (added, removed + 1) - } else { - (added, removed) - } - }) -} - -fn nonempty_line_count(text: &str) -> i64 { - if text.is_empty() { - 0 - } else { - text.lines().count() as i64 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn impact_collector_counts_normalized_edit_and_patch_paths() { - let edit = ActivityChunk::new("session", ACTION_TYPE_TOOL_CALL, FUNCTION_EDIT_FILE) - .with_args(json!({ - "file_path": "src/main.rs", - "old_string": "old\nline", - "new_string": "new\nline\nadded" - })) - .with_result(json!({"success": true, "status": "completed"})); - let patch = ActivityChunk::new("session", ACTION_TYPE_TOOL_CALL, FUNCTION_EDIT_FILE) - .with_args(json!({ - "payload": {"patch": "*** Update File: src/lib.rs\n*** Move to: src/moved.rs\n-old\n+new\n+extra"} - })) - .with_result(json!({"success": true})); - - let impact = impact_from_edit_chunks(&[edit, patch]); - - assert_eq!( - impact.touched_files, - vec!["src/lib.rs", "src/main.rs", "src/moved.rs"] - ); - assert_eq!(impact.files_changed, 3); - assert_eq!(impact.lines_added, 5); - assert_eq!(impact.lines_removed, 3); - } - - #[test] - fn impact_collector_ignores_failed_edits() { - let failed = ActivityChunk::new("session", ACTION_TYPE_TOOL_CALL, FUNCTION_EDIT_FILE) - .with_args(json!({"file_path": "src/failed.rs", "new_string": "new"})) - .with_result(json!({"success": true, "status": "failed"})); - - assert_eq!(impact_from_edit_chunks(&[failed]).files_changed, 0); - } -} diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/rows.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/rows.rs deleted file mode 100644 index b86d7630bb..0000000000 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/rows.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::collections::HashMap; - -use serde::Serialize; -use serde_json::Value; - -use super::{ - epoch_ms_to_iso, repo_name_from_path, DEFAULT_LIST_LIMIT, IMPORTED_HISTORY_CATEGORY, - IMPORTED_STATUS_COMPLETED, -}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedHistorySessionRow { - pub session_id: String, - pub name: String, - pub status: String, - pub created_at: String, - pub updated_at: String, - pub category: &'static str, - pub read_only: bool, - pub model: Option, - pub total_tokens: i64, - pub background: bool, - pub is_active: bool, - pub repo_path: Option, - pub storage_path: Option, - pub repo_name: Option, - pub branch: Option, - pub files_changed: i64, - pub lines_added: i64, - pub lines_removed: i64, - pub touched_files: Vec, - pub parent_session_id: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedHistorySessionPage { - pub sessions: Vec, - pub has_more: bool, -} - -/// Lightweight cached row for list-only surfaces such as the session sidebar. -/// Carries the impact/model fields that card surfaces (e.g. the Kanban board) -/// render inline; the heavier source metadata stays in SQLite until requested. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedHistorySidebarRow { - pub session_id: String, - pub name: String, - pub created_at: String, - pub updated_at: String, - /// Live status override (`running`, `waiting_for_user`, `failed`) - /// decorated by the desktop layer from lifecycle-hook signals or the - /// transcript-mtime fallback. Absent means the frontend's historical - /// default ("completed") applies. The core query never sets these. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_active: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_path: Option, - /// The source app's own transcript file — the store of record for an - /// imported session, which never has a `sessions.db` copy. Absent for - /// rows cached before the path was recorded. - #[serde(skip_serializing_if = "Option::is_none")] - pub storage_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - pub total_tokens: i64, - pub files_changed: i64, - pub lines_added: i64, - pub lines_removed: i64, - pub touched_files: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedHistorySidebarPage { - pub sessions: Vec, - pub has_more: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedHistoryRecentPath { - pub path: String, - pub name: Option, - pub last_used_at: String, - pub session_count: usize, -} - -pub struct ImportedHistoryRowInput { - pub session_id: String, - pub name: String, - pub created_at_ms: i64, - pub updated_at_ms: i64, - pub model: Option, - pub input_tokens: i64, - pub output_tokens: i64, - pub repo_path: Option, - pub storage_path: Option, - pub branch: Option, - pub files_changed: i64, - pub lines_added: i64, - pub lines_removed: i64, - pub touched_files: Vec, - pub parent_session_id: Option, -} - -#[derive(Debug, Clone)] -pub struct ImportedToolCall { - pub call_id: String, - pub raw_name: String, - pub canonical_name: String, - pub args: Value, - pub created_at: String, -} - -pub fn effective_limit(limit: usize) -> usize { - if limit == 0 { - DEFAULT_LIST_LIMIT - } else { - limit - } -} - -pub fn page_from_rows( - mut rows: Vec, - limit: usize, - offset: usize, -) -> ImportedHistorySessionPage { - rows.sort_by(|session_a, session_b| session_b.updated_at.cmp(&session_a.updated_at)); - let limit = effective_limit(limit); - let has_more = rows.len() > offset.saturating_add(limit); - let sessions = rows.into_iter().skip(offset).take(limit).collect(); - ImportedHistorySessionPage { sessions, has_more } -} - -pub fn row_from_input(input: ImportedHistoryRowInput) -> ImportedHistorySessionRow { - let repo_name = input.repo_path.as_deref().and_then(repo_name_from_path); - ImportedHistorySessionRow { - session_id: input.session_id, - name: input.name, - status: IMPORTED_STATUS_COMPLETED.to_string(), - created_at: epoch_ms_to_iso(input.created_at_ms), - updated_at: epoch_ms_to_iso(input.updated_at_ms), - category: IMPORTED_HISTORY_CATEGORY, - read_only: true, - model: input.model, - total_tokens: input.input_tokens + input.output_tokens, - background: false, - is_active: false, - repo_path: input.repo_path, - storage_path: input.storage_path, - repo_name, - branch: input.branch, - files_changed: input.files_changed, - lines_added: input.lines_added, - lines_removed: input.lines_removed, - touched_files: input.touched_files, - parent_session_id: input.parent_session_id, - } -} - -pub fn recent_paths_from_rows( - rows: &[ImportedHistorySessionRow], -) -> Vec { - let paths = rows - .iter() - .filter_map(|row| { - let path = row.repo_path.as_deref()?.trim(); - if path.is_empty() { - return None; - } - Some(ImportedHistoryRecentPath { - path: path.to_string(), - name: repo_name_from_path(path), - last_used_at: row.updated_at.clone(), - session_count: 1, - }) - }) - .collect::>(); - recent_paths_from_paths(&paths) -} - -pub fn recent_paths_from_paths( - paths: &[ImportedHistoryRecentPath], -) -> Vec { - let mut path_stats: HashMap, String, usize)> = HashMap::new(); - - for recent_path in paths { - let path = recent_path.path.trim(); - if path.is_empty() { - continue; - } - - let entry = path_stats.entry(path.to_string()).or_insert_with(|| { - ( - recent_path - .name - .clone() - .or_else(|| repo_name_from_path(path)), - recent_path.last_used_at.clone(), - 0, - ) - }); - if recent_path.last_used_at > entry.1 { - entry.1 = recent_path.last_used_at.clone(); - } - entry.2 += recent_path.session_count; - } - - let mut recent_paths = path_stats - .into_iter() - .map( - |(path, (name, last_used_at, session_count))| ImportedHistoryRecentPath { - name, - path, - last_used_at, - session_count, - }, - ) - .collect::>(); - recent_paths.sort_by(|path_a, path_b| path_b.last_used_at.cmp(&path_a.last_used_at)); - recent_paths -} From 179237cc68207d8159f0cab9f92a2c50e4b9517d Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 15:37:37 +0800 Subject: [PATCH 2/9] refactor(session): unify simulator event classification --- .../core/atoms/actions.simulatorPreview.ts | 30 +------------ .../core/simulatorEventFilterCategory.ts | 42 +++++++++++++++++++ ...napshotMaterialization.simulatorPreview.ts | 36 ++-------------- .../derived/simulatorEventFilters.ts | 39 ++--------------- 4 files changed, 51 insertions(+), 96 deletions(-) create mode 100644 src/engines/SessionCore/core/simulatorEventFilterCategory.ts diff --git a/src/engines/SessionCore/core/atoms/actions.simulatorPreview.ts b/src/engines/SessionCore/core/atoms/actions.simulatorPreview.ts index 0d4b3fff9d..df14ebd11e 100644 --- a/src/engines/SessionCore/core/atoms/actions.simulatorPreview.ts +++ b/src/engines/SessionCore/core/atoms/actions.simulatorPreview.ts @@ -6,6 +6,7 @@ * the authoritative Rust snapshot arrives. Extracted from actions.ts. */ import { isLiveRuntimeResourceEvent } from "../runningEventGate"; +import { getFallbackSimulatorEventFilterCategory } from "../simulatorEventFilterCategory"; import type { SessionEvent, SimulatorEventPreview } from "../types"; /** @@ -34,33 +35,6 @@ export function isSimulatorVisibleApprox(event: SessionEvent): boolean { ); } -function getSimulatorFilterCategory( - event: SessionEvent -): SimulatorEventPreview["filterCategory"] { - if (event.source === "user") return "key_interactions"; - if ( - event.uiCanonical === "edit_file" || - event.uiCanonical === "delete_file" - ) { - return "file_changes"; - } - if (event.command || event.uiCanonical === "run_shell") { - return "terminal_events"; - } - if ( - event.uiCanonical === "read_file" || - event.uiCanonical === "list_dir" || - event.uiCanonical === "code_search" || - event.uiCanonical === "glob" || - event.uiCanonical === "find_files" || - event.uiCanonical === "search" - ) { - return "explore"; - } - if (event.filePath) return "file_changes"; - return "other"; -} - function buildSimulatorPreview(event: SessionEvent): SimulatorEventPreview { return { id: event.id, @@ -74,7 +48,7 @@ function buildSimulatorPreview(event: SessionEvent): SimulatorEventPreview { displayStatus: event.displayStatus, displayVariant: event.displayVariant, activityStatus: event.activityStatus, - filterCategory: getSimulatorFilterCategory(event), + filterCategory: getFallbackSimulatorEventFilterCategory(event), threadId: event.threadId, processId: event.processId, callId: event.callId, diff --git a/src/engines/SessionCore/core/simulatorEventFilterCategory.ts b/src/engines/SessionCore/core/simulatorEventFilterCategory.ts new file mode 100644 index 0000000000..e224681bd0 --- /dev/null +++ b/src/engines/SessionCore/core/simulatorEventFilterCategory.ts @@ -0,0 +1,42 @@ +import type { SessionEvent, SimulatorEventFilterValue } from "./types"; + +export const SIMULATOR_EVENT_FILTER_VALUES = [ + "key_interactions", + "file_changes", + "terminal_events", + "explore", + "other", +] as const satisfies readonly SimulatorEventFilterValue[]; + +/** + * Canonical fallback category used by every local simulator preview path. + * The backend-projected category remains authoritative once its snapshot + * arrives; this function keeps optimistic and materialized local previews in + * lockstep until then. + */ +export function getFallbackSimulatorEventFilterCategory( + event: SessionEvent +): SimulatorEventFilterValue { + if (event.source === "user") return "key_interactions"; + if ( + event.uiCanonical === "edit_file" || + event.uiCanonical === "delete_file" + ) { + return "file_changes"; + } + if (event.command || event.uiCanonical === "run_shell") { + return "terminal_events"; + } + if ( + event.uiCanonical === "read_file" || + event.uiCanonical === "list_dir" || + event.uiCanonical === "code_search" || + event.uiCanonical === "glob" || + event.uiCanonical === "find_files" || + event.uiCanonical === "search" + ) { + return "explore"; + } + if (event.filePath) return "file_changes"; + return "other"; +} diff --git a/src/engines/SessionCore/core/store/snapshotMaterialization.simulatorPreview.ts b/src/engines/SessionCore/core/store/snapshotMaterialization.simulatorPreview.ts index 6d9a3161f2..fe998c289d 100644 --- a/src/engines/SessionCore/core/store/snapshotMaterialization.simulatorPreview.ts +++ b/src/engines/SessionCore/core/store/snapshotMaterialization.simulatorPreview.ts @@ -7,40 +7,10 @@ * objects are cached by event object identity so events untouched by a * delta reuse their preview across materializations. */ -import type { - SessionEvent, - SimulatorEventFilterValue, - SimulatorEventPreview, -} from "../types"; +import { getFallbackSimulatorEventFilterCategory } from "../simulatorEventFilterCategory"; +import type { SessionEvent, SimulatorEventPreview } from "../types"; import type { NormalizedSnapshotCache } from "./EventStoreProxyTypes"; -function getFallbackFilterCategory( - event: SessionEvent -): SimulatorEventFilterValue { - if (event.source === "user") return "key_interactions"; - if ( - event.uiCanonical === "edit_file" || - event.uiCanonical === "delete_file" - ) { - return "file_changes"; - } - if (event.command || event.uiCanonical === "run_shell") { - return "terminal_events"; - } - if ( - event.uiCanonical === "read_file" || - event.uiCanonical === "list_dir" || - event.uiCanonical === "code_search" || - event.uiCanonical === "glob" || - event.uiCanonical === "find_files" || - event.uiCanonical === "search" - ) { - return "explore"; - } - if (event.filePath) return "file_changes"; - return "other"; -} - function buildSimulatorEventPreview( event: SessionEvent ): SimulatorEventPreview { @@ -56,7 +26,7 @@ function buildSimulatorEventPreview( displayStatus: event.displayStatus, displayVariant: event.displayVariant, activityStatus: event.activityStatus, - filterCategory: getFallbackFilterCategory(event), + filterCategory: getFallbackSimulatorEventFilterCategory(event), threadId: event.threadId, processId: event.processId, callId: event.callId, diff --git a/src/engines/SessionCore/derived/simulatorEventFilters.ts b/src/engines/SessionCore/derived/simulatorEventFilters.ts index 3a96841ae9..4bca6c5c41 100644 --- a/src/engines/SessionCore/derived/simulatorEventFilters.ts +++ b/src/engines/SessionCore/derived/simulatorEventFilters.ts @@ -1,46 +1,15 @@ import type { - SessionEvent, SimulatorEventFilterValue, SimulatorEventPreview, } from "../core/types"; -export const SIMULATOR_EVENT_FILTER_VALUES = [ - "key_interactions", - "file_changes", - "terminal_events", - "explore", - "other", -] as const satisfies readonly SimulatorEventFilterValue[]; +export { + SIMULATOR_EVENT_FILTER_VALUES, + getFallbackSimulatorEventFilterCategory, +} from "../core/simulatorEventFilterCategory"; export type { SimulatorEventFilterValue }; -export function getFallbackSimulatorEventFilterCategory( - event: SessionEvent -): SimulatorEventFilterValue { - if (event.source === "user") return "key_interactions"; - if ( - event.uiCanonical === "edit_file" || - event.uiCanonical === "delete_file" - ) { - return "file_changes"; - } - if (event.command || event.uiCanonical === "run_shell") { - return "terminal_events"; - } - if ( - event.uiCanonical === "read_file" || - event.uiCanonical === "list_dir" || - event.uiCanonical === "code_search" || - event.uiCanonical === "glob" || - event.uiCanonical === "find_files" || - event.uiCanonical === "search" - ) { - return "explore"; - } - if (event.filePath) return "file_changes"; - return "other"; -} - export function isSimulatorEventVisibleForFilters( preview: SimulatorEventPreview, selectedFilters: readonly SimulatorEventFilterValue[] From ad33b0f2ba88e660c4d66bbd811165d69b1d7434 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 15:44:51 +0800 Subject: [PATCH 3/9] refactor(cloud): unify session scope resolution --- .../useCloudSessionShareDialog.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts index d046904123..a8227ea873 100644 --- a/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts +++ b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts @@ -9,11 +9,8 @@ import { useAtomValue, useStore } from "jotai"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; -import { persistedScopeKeysForImportedSession } from "@src/features/TeamCollaboration/importedSessionScopeMatch"; import { getShareableScopeKeyVersion, - peekShareableScopeKeys, - primeShareableScopeKey, subscribeShareableScopeKeys, } from "@src/features/TeamCollaboration/repoScopeResolver"; import { sessionOrgTagsAtom } from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; @@ -27,23 +24,9 @@ import { } from "../org2CloudOrgsAtom"; import { org2CloudRepoScopesAtom } from "../org2CloudSyncAtoms"; import { isCloudPushCandidate } from "../org2CloudSyncEngine"; +import { getSessionScopeKeys } from "../org2CloudSyncEngine.repoScopeSync"; import { getActiveCloudShareOrgsForSession } from "./shareEligibility"; -/** - * Resolved scope keys for one session, backed by the module-level resolver - * cache the sync engine also feeds (same idiom as useSessionShareDialog: - * `undefined` = still resolving → not eligible yet; the subscription - * re-renders the consumer when the keys land). - */ -function getSessionScopeKeys(session: Session): string[] | null | undefined { - const persistedKeys = persistedScopeKeysForImportedSession(session); - if (persistedKeys !== undefined) return persistedKeys; - if (!session.repoPath) return null; - const keys = peekShareableScopeKeys(session.repoPath); - if (keys === undefined) primeShareableScopeKey(session.repoPath); - return keys; -} - export interface UseCloudSessionShareDialogResult { /** Session the dialog is open for; null = closed. */ cloudShareSession: Session | null; From 0f5b13f8a20ef8327b179096efb3be74ae75f277 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 15:59:33 +0800 Subject: [PATCH 4/9] refactor(agent): unify external MCP config loading --- .../external_import/commands.rs | 45 +------- .../external_import/detect/mcp.rs | 47 +------- .../external_import/mcp_config.rs | 105 ++++++++++++++++++ .../src/specialization/external_import/mod.rs | 1 + 4 files changed, 109 insertions(+), 89 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/specialization/external_import/mcp_config.rs diff --git a/src-tauri/crates/agent-core/src/specialization/external_import/commands.rs b/src-tauri/crates/agent-core/src/specialization/external_import/commands.rs index 951746be01..8a8b505c8c 100644 --- a/src-tauri/crates/agent-core/src/specialization/external_import/commands.rs +++ b/src-tauri/crates/agent-core/src/specialization/external_import/commands.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use super::detect::detect_all; +use super::mcp_config::load_external_mcp_config; use super::types::{ frontmatter_declares_readonly, readonly_excluded_tool_names, DetectedItem, ImportItemReport, ImportReport, ImportSelection, ImportStatus, ItemKind, SourceScope, @@ -368,50 +369,6 @@ fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), String> { // MCP import // ============================================================ -fn load_external_mcp_config(path: &Path) -> Result { - let raw = std::fs::read_to_string(path) - .map_err(|err| format!("Failed to read MCP config {}: {}", path.display(), err))?; - let mut value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|err| format!("Failed to parse MCP config {}: {}", path.display(), err))?; - let Some(servers) = value - .get_mut("mcpServers") - .and_then(|entry| entry.as_object_mut()) - else { - return Ok(McpConfigFile::default()); - }; - - for server in servers.values_mut() { - let Some(server_obj) = server.as_object_mut() else { - continue; - }; - if !server_obj.contains_key("type") { - let inferred = if server_obj.contains_key("url") { - "streamableHttp" - } else { - "stdio" - }; - server_obj.insert( - "type".to_string(), - serde_json::Value::String(inferred.to_string()), - ); - } - if server_obj.get("type").and_then(|entry| entry.as_str()) == Some("http") { - server_obj.insert( - "type".to_string(), - serde_json::Value::String("streamableHttp".to_string()), - ); - } - } - - serde_json::from_value(value).map_err(|err| { - format!( - "Failed to parse MCP server entries {}: {}", - path.display(), - err - ) - }) -} - fn apply_mcp_import( selection: &ImportSelection, target_repo_path: Option<&Path>, diff --git a/src-tauri/crates/agent-core/src/specialization/external_import/detect/mcp.rs b/src-tauri/crates/agent-core/src/specialization/external_import/detect/mcp.rs index af036377f4..b0e187f1e6 100644 --- a/src-tauri/crates/agent-core/src/specialization/external_import/detect/mcp.rs +++ b/src-tauri/crates/agent-core/src/specialization/external_import/detect/mcp.rs @@ -6,9 +6,10 @@ use std::path::Path; +use super::super::mcp_config::load_external_mcp_config; use super::super::types::{DetectedItem, ItemKind, ItemPreview, SourceAgent, SourceScope}; use super::helpers::{home_dir, orgii_mcp_exists, path_has_denied_ancestor, MAX_ITEMS_PER_BATCH}; -use crate::specialization::mcp::config::{McpConfigFile, McpTransportType}; +use crate::specialization::mcp::config::McpTransportType; pub(super) fn detect_mcp_servers(repo_path: Option<&Path>) -> Vec { let mut out = Vec::new(); @@ -70,50 +71,6 @@ pub(super) fn detect_mcp_servers(repo_path: Option<&Path>) -> Vec out } -fn load_external_mcp_config(path: &Path) -> Result { - let raw = std::fs::read_to_string(path) - .map_err(|err| format!("Failed to read MCP config {}: {}", path.display(), err))?; - let mut value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|err| format!("Failed to parse MCP config {}: {}", path.display(), err))?; - let Some(servers) = value - .get_mut("mcpServers") - .and_then(|entry| entry.as_object_mut()) - else { - return Ok(McpConfigFile::default()); - }; - - for server in servers.values_mut() { - let Some(server_obj) = server.as_object_mut() else { - continue; - }; - if !server_obj.contains_key("type") { - let inferred = if server_obj.contains_key("url") { - "streamableHttp" - } else { - "stdio" - }; - server_obj.insert( - "type".to_string(), - serde_json::Value::String(inferred.to_string()), - ); - } - if server_obj.get("type").and_then(|entry| entry.as_str()) == Some("http") { - server_obj.insert( - "type".to_string(), - serde_json::Value::String("streamableHttp".to_string()), - ); - } - } - - serde_json::from_value(value).map_err(|err| { - format!( - "Failed to parse MCP server entries {}: {}", - path.display(), - err - ) - }) -} - fn scan_mcp_config_file( path: &Path, source_agent: SourceAgent, diff --git a/src-tauri/crates/agent-core/src/specialization/external_import/mcp_config.rs b/src-tauri/crates/agent-core/src/specialization/external_import/mcp_config.rs new file mode 100644 index 0000000000..feb08b7542 --- /dev/null +++ b/src-tauri/crates/agent-core/src/specialization/external_import/mcp_config.rs @@ -0,0 +1,105 @@ +use std::path::Path; + +use crate::specialization::mcp::config::McpConfigFile; + +/// Load an MCP config authored by another agent and normalize the transport +/// spellings that ORGII accepts before deserializing it into the canonical +/// config model. +pub(super) fn load_external_mcp_config(path: &Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|err| format!("Failed to read MCP config {}: {}", path.display(), err))?; + let mut value: serde_json::Value = serde_json::from_str(&raw) + .map_err(|err| format!("Failed to parse MCP config {}: {}", path.display(), err))?; + let Some(servers) = value + .get_mut("mcpServers") + .and_then(|entry| entry.as_object_mut()) + else { + return Ok(McpConfigFile::default()); + }; + + for server in servers.values_mut() { + let Some(server_obj) = server.as_object_mut() else { + continue; + }; + if !server_obj.contains_key("type") { + let inferred = if server_obj.contains_key("url") { + "streamableHttp" + } else { + "stdio" + }; + server_obj.insert( + "type".to_string(), + serde_json::Value::String(inferred.to_string()), + ); + } + if server_obj.get("type").and_then(|entry| entry.as_str()) == Some("http") { + server_obj.insert( + "type".to_string(), + serde_json::Value::String("streamableHttp".to_string()), + ); + } + } + + serde_json::from_value(value).map_err(|err| { + format!( + "Failed to parse MCP server entries {}: {}", + path.display(), + err + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::specialization::mcp::config::McpTransportType; + use tempfile::TempDir; + + #[test] + fn normalizes_external_transport_variants() { + let temp = TempDir::new().expect("create temp dir"); + let path = temp.path().join("mcp.json"); + std::fs::write( + &path, + r#"{ + "mcpServers": { + "implicit-stdio": { "command": "server" }, + "implicit-http": { "url": "https://example.com/mcp" }, + "legacy-http": { "type": "http", "url": "https://example.com/legacy" }, + "explicit-sse": { "type": "sse", "url": "https://example.com/sse" } + } + }"#, + ) + .expect("write config"); + + let config = load_external_mcp_config(&path).expect("load config"); + + assert_eq!( + config.mcp_servers["implicit-stdio"].transport_type, + McpTransportType::Stdio + ); + assert_eq!( + config.mcp_servers["implicit-http"].transport_type, + McpTransportType::StreamableHttp + ); + assert_eq!( + config.mcp_servers["legacy-http"].transport_type, + McpTransportType::StreamableHttp + ); + assert_eq!( + config.mcp_servers["explicit-sse"].transport_type, + McpTransportType::Sse + ); + } + + #[test] + fn treats_missing_mcp_servers_as_empty() { + let temp = TempDir::new().expect("create temp dir"); + let path = temp.path().join("mcp.json"); + std::fs::write(&path, r#"{ "other": true }"#).expect("write config"); + + let config = load_external_mcp_config(&path).expect("load config"); + + assert!(config.mcp_servers.is_empty()); + } +} diff --git a/src-tauri/crates/agent-core/src/specialization/external_import/mod.rs b/src-tauri/crates/agent-core/src/specialization/external_import/mod.rs index 9f847a9dd4..63d62b149d 100644 --- a/src-tauri/crates/agent-core/src/specialization/external_import/mod.rs +++ b/src-tauri/crates/agent-core/src/specialization/external_import/mod.rs @@ -20,6 +20,7 @@ pub mod commands; pub mod detect; +mod mcp_config; pub mod types; // Wildcard re-export needed: `#[tauri::command]` generates hidden From e1ed463a7020596cb732220cba5c7e4760a3c3ba Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 16:06:18 +0800 Subject: [PATCH 5/9] refactor(lsp): unify command detection --- src-tauri/crates/lsp/src/command_detection.rs | 53 +++++++++++++++++++ .../crates/lsp/src/commands/discovery.rs | 28 +--------- .../lsp/src/commands/package_manager.rs | 2 +- src-tauri/crates/lsp/src/install_pipeline.rs | 2 +- src-tauri/crates/lsp/src/lib.rs | 1 + src-tauri/crates/lsp/src/lint_tools.rs | 25 +-------- .../crates/lsp/src/workspace_scan/mod.rs | 3 +- .../lsp/src/workspace_scan/orchestrator.rs | 3 +- .../crates/lsp/src/workspace_scan/process.rs | 25 +-------- 9 files changed, 64 insertions(+), 78 deletions(-) create mode 100644 src-tauri/crates/lsp/src/command_detection.rs diff --git a/src-tauri/crates/lsp/src/command_detection.rs b/src-tauri/crates/lsp/src/command_detection.rs new file mode 100644 index 0000000000..616e02cdfb --- /dev/null +++ b/src-tauri/crates/lsp/src/command_detection.rs @@ -0,0 +1,53 @@ +use std::process::Command; + +/// Check whether a command-line tool is available on the system PATH. +/// +/// Forward PATH explicitly so app startup code that augments the process +/// environment is reflected consistently across every LSP discovery surface. +pub fn command_exists(command_name: &str) -> bool { + let current_path = std::env::var_os("PATH"); + + #[cfg(unix)] + let mut command = { + let mut command = Command::new("which"); + command.arg(command_name); + command + }; + + #[cfg(windows)] + let mut command = { + let mut command = Command::new("where"); + command.arg(command_name); + command + }; + + if let Some(path) = current_path { + command.env("PATH", path); + } + app_platform::hide_console(&mut command); + command + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_a_platform_shell() { + #[cfg(unix)] + assert!(command_exists("sh")); + + #[cfg(windows)] + assert!(command_exists("cmd")); + } + + #[test] + fn rejects_a_missing_command() { + assert!(!command_exists( + "orgii-command-detection-test-definitely-missing" + )); + } +} diff --git a/src-tauri/crates/lsp/src/commands/discovery.rs b/src-tauri/crates/lsp/src/commands/discovery.rs index 70ccf9353d..55f911c054 100644 --- a/src-tauri/crates/lsp/src/commands/discovery.rs +++ b/src-tauri/crates/lsp/src/commands/discovery.rs @@ -2,8 +2,6 @@ //! //! Tauri commands for detecting installed language servers and lint tools. -use std::process::Command; - use super::cache; use crate::lint_tools::LintToolInfo; use crate::server_defs::{servers, servers_for_language_id}; @@ -55,31 +53,7 @@ pub const LANGUAGE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("zig", "Zig"), ]; -/// Check if a command exists in PATH -pub fn command_exists(cmd: &str) -> bool { - // Explicitly forward PATH so the login-shell-augmented PATH is visible. - let current_path = std::env::var("PATH").unwrap_or_default(); - #[cfg(unix)] - { - Command::new("which") - .arg(cmd) - .env("PATH", ¤t_path) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - let mut command = Command::new("where"); - command.arg(cmd).env("PATH", ¤t_path); - // Suppress console window on Windows. - app_platform::hide_console(&mut command); - command - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } -} +pub use crate::command_detection::command_exists; /// Check if uninstall is supported based on install hint fn is_uninstall_supported(install_hint: &str) -> bool { diff --git a/src-tauri/crates/lsp/src/commands/package_manager.rs b/src-tauri/crates/lsp/src/commands/package_manager.rs index 7c9a32cc91..b7559b64c2 100644 --- a/src-tauri/crates/lsp/src/commands/package_manager.rs +++ b/src-tauri/crates/lsp/src/commands/package_manager.rs @@ -3,7 +3,7 @@ //! Utilities for detecting installed package managers and extracting //! package names from install hints. -use super::discovery::command_exists; +use crate::command_detection::command_exists; #[cfg(test)] #[path = "tests/package_manager_tests.rs"] diff --git a/src-tauri/crates/lsp/src/install_pipeline.rs b/src-tauri/crates/lsp/src/install_pipeline.rs index af5016b5c2..cd4b57e1bf 100644 --- a/src-tauri/crates/lsp/src/install_pipeline.rs +++ b/src-tauri/crates/lsp/src/install_pipeline.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::Command; -use super::commands::discovery::command_exists; +use super::command_detection::command_exists; use super::commands::package_manager::detect_package_manager; use app_paths::lsp_bin_dir; diff --git a/src-tauri/crates/lsp/src/lib.rs b/src-tauri/crates/lsp/src/lib.rs index a42ead9140..9d37322abb 100644 --- a/src-tauri/crates/lsp/src/lib.rs +++ b/src-tauri/crates/lsp/src/lib.rs @@ -7,6 +7,7 @@ pub mod broadcast; pub mod codec; +mod command_detection; pub mod commands; pub mod config; pub mod eslint; diff --git a/src-tauri/crates/lsp/src/lint_tools.rs b/src-tauri/crates/lsp/src/lint_tools.rs index 20c2956d26..5d69b263b5 100644 --- a/src-tauri/crates/lsp/src/lint_tools.rs +++ b/src-tauri/crates/lsp/src/lint_tools.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use std::process::Command; +use crate::command_detection::command_exists; + /// Information about a lint tool #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -361,29 +363,6 @@ const LINT_TOOLS: &[LintToolConfig] = &[ }, ]; -/// Check if a command exists in PATH -fn command_exists(cmd: &str) -> bool { - #[cfg(unix)] - { - Command::new("which") - .arg(cmd) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - let mut command = Command::new("where"); - command.arg(cmd); - // Suppress console window on Windows. - app_platform::hide_console(&mut command); - command - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } -} - /// Get version of a tool fn get_tool_version(config: &LintToolConfig) -> Option { // Special case for clippy which needs cargo clippy --version diff --git a/src-tauri/crates/lsp/src/workspace_scan/mod.rs b/src-tauri/crates/lsp/src/workspace_scan/mod.rs index 025ccda109..c05daf5dff 100644 --- a/src-tauri/crates/lsp/src/workspace_scan/mod.rs +++ b/src-tauri/crates/lsp/src/workspace_scan/mod.rs @@ -24,8 +24,9 @@ use std::path::Path; use types::{AvailableTool, SingleToolResult}; +use super::command_detection::command_exists; use super::workspace_config::is_lint_tool_enabled; -use process::{command_exists, eslint_available}; +use process::eslint_available; // ============================================ // Helpers for tool detection diff --git a/src-tauri/crates/lsp/src/workspace_scan/orchestrator.rs b/src-tauri/crates/lsp/src/workspace_scan/orchestrator.rs index 398b40421e..af09272528 100644 --- a/src-tauri/crates/lsp/src/workspace_scan/orchestrator.rs +++ b/src-tauri/crates/lsp/src/workspace_scan/orchestrator.rs @@ -21,11 +21,12 @@ use super::clippy; use super::css; use super::eslint; use super::golangci_lint; -use super::process::{command_exists, eslint_available}; +use super::process::eslint_available; use super::python; use super::shell; use super::types::{AvailableTool, SingleToolResult, WorkspaceDiagnostic}; use super::typescript; +use crate::command_detection::command_exists; use crate::workspace_config::is_lint_tool_enabled; // ============================================ diff --git a/src-tauri/crates/lsp/src/workspace_scan/process.rs b/src-tauri/crates/lsp/src/workspace_scan/process.rs index 3e3c187158..8b9393e94d 100644 --- a/src-tauri/crates/lsp/src/workspace_scan/process.rs +++ b/src-tauri/crates/lsp/src/workspace_scan/process.rs @@ -61,34 +61,11 @@ pub fn run_command_with_custom_timeout( } } -/// Check whether a command-line tool is available on the system PATH. -pub fn command_exists(cmd: &str) -> bool { - #[cfg(unix)] - { - Command::new("which") - .arg(cmd) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - let mut command = Command::new("where"); - command.arg(cmd); - // Suppress console window on Windows. - app_platform::hide_console(&mut command); - command - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } -} - /// Check whether ESLint is available (local node_modules or global). pub fn eslint_available(workspace_path: &str) -> bool { let local = Path::new(workspace_path) .join("node_modules") .join(".bin") .join("eslint"); - local.exists() || command_exists("eslint") + local.exists() || crate::command_detection::command_exists("eslint") } From 3b2be78d163de2e91eebf2501c0e0db5c27b79d0 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 16:11:26 +0800 Subject: [PATCH 6/9] refactor(chat): share activity group projection --- .../ActivityGroups.md | 18 +++ .../ChatItems/EditActivityGroup/index.tsx | 121 ++---------------- .../ChatItems/TerminalActivityGroup/index.tsx | 120 ++--------------- .../ChatItems/activityGroupProjection.test.ts | 71 ++++++++++ .../ChatItems/activityGroupProjection.tsx | 107 ++++++++++++++++ 5 files changed, 220 insertions(+), 217 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-08-11/ActivityGroups.md create mode 100644 src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts create mode 100644 src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx diff --git a/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md b/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md new file mode 100644 index 0000000000..ffd061e0e7 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md @@ -0,0 +1,18 @@ +# Frontend UI Audit — Activity Groups + +Scope: `EditActivityGroup`, `TerminalActivityGroup`, and their shared event projection. This is a behavior-preserving component refactor; no rendered styles, copy, layout, focus behavior, or interaction contract changed. + +| Line | Element | Verdict | Reason | Suggested change | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx:17` | Event-item projection, intermediate running-state normalization, lazy registry rendering, and tool-usage aggregation | abstract | Edit and terminal groups previously duplicated the same presentation pipeline. One shared owner prevents loading-state and usage-badge behavior from drifting while leaving domain summaries separate. | Reuse the shared projection from both activity-group components. | +| `src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx:111` | Edit activity stack | keep with reason | `StackedBlock`, tool icons, workstation diff tokens, and the shared usage badge already implement the design-system contracts. The edit/read and diff-stat summary is specific to edit activity. | Keep the edit summary local and continue using shared primitives. | +| `src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx:140` | Terminal activity stack | keep with reason | The stack uses the same shared primitives, while terminal/MCP/wait counts and durable Work Item result cards are terminal-domain behavior. Moving them into the generic projection would leak domain rules. | Keep terminal summary and Work Item projection local. | +| `src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx:119` | Existing summary typography and spacing | keep with reason | The existing classes compose established text and diff-stat tokens; this refactor introduces no arbitrary visual value or parallel component style. | No visual change. | + +## Summary + +- Fix: 0 +- Keep with reason: 3 +- Abstract: 1 +- Sweep candidates: 0 +- Accessibility: no semantic or interactive changes; `StackedBlock` retains the existing keyboard/collapse contract. diff --git a/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx b/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx index 806b166b53..163a8ad7b5 100644 --- a/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx @@ -4,38 +4,28 @@ * Groups file edits and the reads performed after them into one collapsible * stack. Each event still renders through the event registry. */ -import React, { Suspense, useMemo } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getToolIcon } from "@src/config/toolIcons"; import { DIFF_STATS } from "@src/config/workstation/tokens"; import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; -import { - ChatLoadingBlock, - StackedBlock, -} from "@src/engines/ChatPanel/blocks/primitives"; -import { - type SessionEvent, - TOOL_USAGE_ARGS_KEY, - type ToolUsageMetadata, -} from "@src/engines/SessionCore/core/types"; +import { StackedBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { type SessionEvent } from "@src/engines/SessionCore/core/types"; import { extractEditData } from "@src/engines/SessionCore/rendering/props/propsDataExtractors"; -import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; +import { normalizeFunctionName } from "@src/lib/activityData/activityNormalizers"; + import { - getRegistryEventType, - normalizeFunctionName, -} from "@src/lib/activityData/activityNormalizers"; + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + renderActivityGroupEvent, +} from "../activityGroupProjection"; interface EditActivityGroupProps { events: SessionEvent[]; closedByBoundary?: boolean; } -interface EditEventItem { - event: SessionEvent; - isLastItem: boolean; -} - function getCanonicalName(event: SessionEvent): string { return ( event.uiCanonical || @@ -88,99 +78,12 @@ export function sumEditDiffStats(events: readonly SessionEvent[]): { ); } -function ActivityBlock({ event }: { event: SessionEvent }) { - const eventType = getRegistryEventType( - event as unknown as Record - ); - const EventComponent = getChatLazyComponent(eventType); - return ( - }> - {React.createElement(EventComponent, { event })} - - ); -} - -function suppressLoadingForNonLastRunningEvent( - event: SessionEvent, - isLastItem: boolean -): SessionEvent { - if (isLastItem || event.displayStatus !== "running") return event; - return { - ...event, - displayStatus: "completed", - activityStatus: "processed", - isDelta: false, - }; -} - -function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { - if (event.toolUsage) return event.toolUsage; - const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; - if (!raw || typeof raw !== "object") return undefined; - return raw as ToolUsageMetadata; -} - -function aggregateToolUsage( - items: readonly EditEventItem[] -): ToolUsageMetadata | undefined { - const usages = items - .map((item) => readToolUsage(item.event)) - .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); - if (usages.length === 0) return undefined; - - return usages.reduce( - (total, usage) => ({ - decisionCompletionTokens: - total.decisionCompletionTokens + usage.decisionCompletionTokens, - resultContextTokens: - total.resultContextTokens + usage.resultContextTokens, - followupCompletionTokens: - total.followupCompletionTokens + usage.followupCompletionTokens, - inputBytes: total.inputBytes + usage.inputBytes, - outputBytes: total.outputBytes + usage.outputBytes, - relatedCacheReadTokens: - total.relatedCacheReadTokens + usage.relatedCacheReadTokens, - relatedCacheWriteTokens: - total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, - attributionMethod: - total.attributionMethod === usage.attributionMethod - ? total.attributionMethod - : usage.attributionMethod, - }), - { - decisionCompletionTokens: 0, - resultContextTokens: 0, - followupCompletionTokens: 0, - inputBytes: 0, - outputBytes: 0, - relatedCacheReadTokens: 0, - relatedCacheWriteTokens: 0, - attributionMethod: usages[0].attributionMethod, - } - ); -} - -function renderEditEvent({ event, isLastItem }: EditEventItem) { - return ( - - ); -} - const EditActivityGroup: React.FC = ({ events, closedByBoundary = true, }) => { const { t } = useTranslation("sessions"); - const items = useMemo( - () => - events.map((event, index) => ({ - event, - isLastItem: index === events.length - 1, - })), - [events] - ); + const items = useMemo(() => buildActivityGroupItems(events), [events]); if (items.length === 0) return null; @@ -194,7 +97,7 @@ const EditActivityGroup: React.FC = ({ const hasDiffStats = diffStats.additions > 0 || diffStats.deletions > 0; const firstEvent = items[0].event; - const groupToolUsage = aggregateToolUsage(items); + const groupToolUsage = aggregateActivityGroupToolUsage(events); return (
= ({ rightContent={ groupToolUsage ? : undefined } - renderItem={renderEditEvent} + renderItem={renderActivityGroupEvent} />
); diff --git a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx index ec1784908a..0fa60fd560 100644 --- a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx @@ -6,7 +6,7 @@ * renders through the registry, preserving its specialized behavior. */ import { useAtomValue } from "jotai"; -import React, { Suspense, useMemo } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getToolIcon } from "@src/config/toolIcons"; @@ -14,29 +14,21 @@ import { isMcpToolEvent } from "@src/engines/ChatPanel/ChatHistory/chatItemPipel import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; import OrgtrackEnvelopeCard from "@src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard"; import { parseOrgtrackEnvelope } from "@src/engines/ChatPanel/blocks/ToolCallBlock/helpers"; -import { - ChatLoadingBlock, - StackedBlock, -} from "@src/engines/ChatPanel/blocks/primitives"; -import { - type SessionEvent, - TOOL_USAGE_ARGS_KEY, - type ToolUsageMetadata, -} from "@src/engines/SessionCore/core/types"; -import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; -import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; +import { StackedBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { type SessionEvent } from "@src/engines/SessionCore/core/types"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; +import { + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + renderActivityGroupEvent, +} from "../activityGroupProjection"; + interface TerminalActivityGroupProps { events: SessionEvent[]; closedByBoundary?: boolean; } -interface TerminalEventItem { - event: SessionEvent; - isLastItem: boolean; -} - function parseTerminalOrgtrackEnvelope( event: SessionEvent, context: { @@ -98,101 +90,13 @@ export function buildGroupSummary( return parts.join(t("tools.terminalSummary.separator")); } -function ActivityBlock({ event }: { event: SessionEvent }) { - const eventType = getRegistryEventType( - event as unknown as Record - ); - const EventComponent = getChatLazyComponent(eventType); - const renderedEvent = React.createElement(EventComponent, { event }); - return }>{renderedEvent}; -} - -function suppressLoadingForNonLastRunningEvent( - event: SessionEvent, - isLastItem: boolean -): SessionEvent { - if (isLastItem || event.displayStatus !== "running") return event; - - return { - ...event, - displayStatus: "completed", - activityStatus: "processed", - isDelta: false, - }; -} - -function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { - if (event.toolUsage) return event.toolUsage; - const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; - if (!raw || typeof raw !== "object") return undefined; - return raw as ToolUsageMetadata; -} - -function aggregateToolUsage( - items: readonly TerminalEventItem[] -): ToolUsageMetadata | undefined { - const usages = items - .map((item) => readToolUsage(item.event)) - .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); - if (usages.length === 0) return undefined; - - return usages.reduce( - (total, usage) => ({ - decisionCompletionTokens: - total.decisionCompletionTokens + usage.decisionCompletionTokens, - resultContextTokens: - total.resultContextTokens + usage.resultContextTokens, - followupCompletionTokens: - total.followupCompletionTokens + usage.followupCompletionTokens, - inputBytes: total.inputBytes + usage.inputBytes, - outputBytes: total.outputBytes + usage.outputBytes, - relatedCacheReadTokens: - total.relatedCacheReadTokens + usage.relatedCacheReadTokens, - relatedCacheWriteTokens: - total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, - attributionMethod: - total.attributionMethod === usage.attributionMethod - ? total.attributionMethod - : usage.attributionMethod, - }), - { - decisionCompletionTokens: 0, - resultContextTokens: 0, - followupCompletionTokens: 0, - inputBytes: 0, - outputBytes: 0, - relatedCacheReadTokens: 0, - relatedCacheWriteTokens: 0, - attributionMethod: usages[0].attributionMethod, - } - ); -} - -function renderTerminalEvent( - { event, isLastItem }: TerminalEventItem, - _index: number -): React.ReactNode { - return ( - - ); -} - const TerminalActivityGroup: React.FC = ({ events, closedByBoundary = true, }) => { const { t } = useTranslation("sessions"); const session = useAtomValue(sessionByIdAtom(events[0]?.sessionId ?? "")); - const items = useMemo( - () => - events.map((event, index) => ({ - event, - isLastItem: index === events.length - 1, - })), - [events] - ); + const items = useMemo(() => buildActivityGroupItems(events), [events]); const workItemResults = useMemo( () => events.flatMap((event) => { @@ -220,7 +124,7 @@ const TerminalActivityGroup: React.FC = ({ if (items.length === 0) return null; const firstEvent = items[0].event; - const groupToolUsage = aggregateToolUsage(items); + const groupToolUsage = aggregateActivityGroupToolUsage(events); const groupSummary = buildGroupSummary(events, t); return ( @@ -249,7 +153,7 @@ const TerminalActivityGroup: React.FC = ({ ) : undefined } - renderItem={renderTerminalEvent} + renderItem={renderActivityGroupEvent} /> {workItemResults.map((card, index) => ( diff --git a/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts b/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts new file mode 100644 index 0000000000..b03c872c46 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import { makeSessionEvent } from "@src/engines/SessionCore/rendering/props/__tests__/fixtures"; + +import { + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + suppressIntermediateRunningState, +} from "./activityGroupProjection"; + +function usage(value: number, attributionMethod: string): ToolUsageMetadata { + return { + decisionCompletionTokens: value, + resultContextTokens: value, + followupCompletionTokens: value, + inputBytes: value, + outputBytes: value, + relatedCacheReadTokens: value, + relatedCacheWriteTokens: value, + attributionMethod, + }; +} + +describe("activity group projection", () => { + it("marks only the final event as the live group tail", () => { + const first = makeSessionEvent(); + const second = makeSessionEvent(); + + expect(buildActivityGroupItems([first, second])).toEqual([ + { event: first, isLastItem: false }, + { event: second, isLastItem: true }, + ]); + }); + + it("suppresses a stale running state only before the live tail", () => { + const running = makeSessionEvent({ + displayStatus: "running", + activityStatus: "agent", + isDelta: true, + }); + + expect(suppressIntermediateRunningState(running, true)).toBe(running); + expect(suppressIntermediateRunningState(running, false)).toMatchObject({ + displayStatus: "completed", + activityStatus: "processed", + isDelta: false, + }); + }); + + it("aggregates direct and serialized tool usage metadata", () => { + const first = makeSessionEvent(); + first.toolUsage = usage(2, "direct"); + const second = makeSessionEvent({ + args: { [TOOL_USAGE_ARGS_KEY]: usage(3, "fallback") }, + }); + + expect(aggregateActivityGroupToolUsage([first, second])).toEqual( + usage(5, "fallback") + ); + }); + + it("returns no badge data when the group has no usage metadata", () => { + expect( + aggregateActivityGroupToolUsage([makeSessionEvent()]) + ).toBeUndefined(); + }); +}); diff --git a/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx b/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx new file mode 100644 index 0000000000..b6fde5b3a1 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx @@ -0,0 +1,107 @@ +import React, { Suspense } from "react"; + +import { ChatLoadingBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; +import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; + +export interface ActivityGroupEventItem { + event: SessionEvent; + isLastItem: boolean; +} + +export function buildActivityGroupItems( + events: readonly SessionEvent[] +): ActivityGroupEventItem[] { + return events.map((event, index) => ({ + event, + isLastItem: index === events.length - 1, + })); +} + +export function suppressIntermediateRunningState( + event: SessionEvent, + isLastItem: boolean +): SessionEvent { + if (isLastItem || event.displayStatus !== "running") return event; + return { + ...event, + displayStatus: "completed", + activityStatus: "processed", + isDelta: false, + }; +} + +function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { + if (event.toolUsage) return event.toolUsage; + const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as ToolUsageMetadata; +} + +export function aggregateActivityGroupToolUsage( + events: readonly SessionEvent[] +): ToolUsageMetadata | undefined { + const usages = events + .map(readToolUsage) + .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); + if (usages.length === 0) return undefined; + + return usages.reduce( + (total, usage) => ({ + decisionCompletionTokens: + total.decisionCompletionTokens + usage.decisionCompletionTokens, + resultContextTokens: + total.resultContextTokens + usage.resultContextTokens, + followupCompletionTokens: + total.followupCompletionTokens + usage.followupCompletionTokens, + inputBytes: total.inputBytes + usage.inputBytes, + outputBytes: total.outputBytes + usage.outputBytes, + relatedCacheReadTokens: + total.relatedCacheReadTokens + usage.relatedCacheReadTokens, + relatedCacheWriteTokens: + total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, + attributionMethod: + total.attributionMethod === usage.attributionMethod + ? total.attributionMethod + : usage.attributionMethod, + }), + { + decisionCompletionTokens: 0, + resultContextTokens: 0, + followupCompletionTokens: 0, + inputBytes: 0, + outputBytes: 0, + relatedCacheReadTokens: 0, + relatedCacheWriteTokens: 0, + attributionMethod: usages[0].attributionMethod, + } + ); +} + +function ActivityGroupEventBlock({ event }: { event: SessionEvent }) { + const eventType = getRegistryEventType( + event as unknown as Record + ); + const EventComponent = getChatLazyComponent(eventType); + return ( + }> + {React.createElement(EventComponent, { event })} + + ); +} + +export function renderActivityGroupEvent({ + event, + isLastItem, +}: ActivityGroupEventItem): React.ReactNode { + return ( + + ); +} From ba4fbc1a2302a18e334bcd9de2b59550e0a13aa8 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 16:22:52 +0800 Subject: [PATCH 7/9] refactor(auth): share OAuth session setup shell --- .../OAuthSessionSetup.md | 21 + .../ClaudeCodeSessionSetup/index.tsx | 356 +++------------ .../components/CodexSessionSetup/index.tsx | 359 +++------------ .../components/OAuthSessionSetupShell.test.ts | 119 +++++ .../components/OAuthSessionSetupShell.tsx | 421 ++++++++++++++++++ 5 files changed, 700 insertions(+), 576 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md create mode 100644 src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts create mode 100644 src/features/SessionSetup/components/OAuthSessionSetupShell.tsx diff --git a/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md b/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md new file mode 100644 index 0000000000..7196a54d60 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md @@ -0,0 +1,21 @@ +# Frontend UI Audit — OAuth Session Setup + +Scope: the Claude Code and Codex OAuth session-setup components plus their extracted shared shell. Provider-specific credential mapping remains outside the shared presentation/lifecycle boundary. + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:63` | OAuth idle/browser/loading/error/success presentation | abstract | Claude Code and Codex duplicated the same section layout, WebView chrome, progress indicator, overlays, alerts, and debug container. A provider-copy contract preserves localized differences without parallel JSX. | Reuse one shell and pass provider copy/test identity explicitly. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:286` | OAuth browser lifecycle | abstract | Both providers used the same open, delayed native start, success collapse, retry, parent-layout sync, and external-close transitions. One owner makes cleanup and duplicate retry policy consistent. | Keep transient browser intent in the shared shell; retain provider capture state in the existing hooks. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:123` | Refresh and close icon buttons | fix | The duplicated icon-only controls had no accessible names. The shared shell can apply localized labels once for both providers. | Add `aria-label` and `title` from existing common actions copy. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:172` | Loading and error overlays | fix | Visual states existed but did not expose status/alert semantics to assistive technology. | Mark loading as `role="status"` and errors as `role="alert"`. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:391` | Two-step progress indicator | fix | Active styling was only visual. | Expose the active item with `aria-current="step"`. | +| `src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx:58` | Claude Code capture and account metadata mapping | keep with reason | Claude Code owns its callback response and optional organization metadata; generalizing this mapping would weaken provider types. | Keep mapping in the provider adapter and pass normalized capture state to the shell. | +| `src/features/SessionSetup/components/CodexSessionSetup/index.tsx:41` | Codex capture and required token mapping | keep with reason | Codex requires refresh and ID tokens and supports initial auto-start. Those are provider contract differences, not presentation variants. | Keep mapping and auto-start input in the provider adapter. | + +## Summary + +- Fix: 3 +- Keep with reason: 2 +- Abstract: 2 +- Sweep candidates: 0 +- Visual behavior: unchanged; accessibility semantics are additive. diff --git a/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx b/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx index 4e2ad6f96a..16dc82f753 100644 --- a/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx +++ b/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx @@ -1,26 +1,12 @@ -import { - AlertCircle, - CheckCircle, - ChevronRight, - Loader2, - LogIn, - RefreshCw, - X, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; import { useClaudeCodeOAuthCapture, useWebviewPositionSync, } from "@src/hooks/workStation/sessionCapture"; -import { - SectionContainer, - SectionRow, -} from "@src/modules/shared/layouts/SectionLayout"; + +import { OAuthSessionSetupShell } from "../OAuthSessionSetupShell"; export interface ClaudeCodeSessionValues { accessToken: string; @@ -58,7 +44,7 @@ function toClaudeCodeAccountMetadata( return Object.keys(out).length > 0 ? out : undefined; } -const ClaudeCodeSessionSetup: React.FC = ({ +export default function ClaudeCodeSessionSetup({ onSessionCaptured, onBrowserStateChange, debug = false, @@ -66,27 +52,10 @@ const ClaudeCodeSessionSetup: React.FC = ({ tokenError = null, onClearTokenError, closeSignal = 0, -}) => { +}: ClaudeCodeSessionSetupProps) { const { t } = useTranslation("integrations"); - const [showBrowser, setShowBrowser] = useState(false); const containerRef = useRef(null); - - const { - isSigningIn, - isSignedIn, - isWebviewOpen, - isWebviewLoading, - currentUrl, - authUrl, - error, - accessToken, - refreshToken, - expiresIn, - startLogin, - closeWebview, - reset, - updatePosition, - } = useClaudeCodeOAuthCapture({ + const capture = useClaudeCodeOAuthCapture({ containerRef, debug, onTokenCaptured: (response) => { @@ -99,262 +68,71 @@ const ClaudeCodeSessionSetup: React.FC = ({ }, }); - useEffect(() => { - onBrowserStateChange?.(showBrowser); - }, [showBrowser, onBrowserStateChange]); - - useEffect(() => { - if (!isWebviewOpen && isSignedIn) { - queueMicrotask(() => setShowBrowser(false)); - } - }, [isSignedIn, isWebviewOpen]); - - useEffect(() => { - if (!showBrowser || isWebviewOpen || isSigningIn) return; - - const timer = setTimeout(() => { - void startLogin(); - }, 100); - - return () => clearTimeout(timer); - }, [isSigningIn, isWebviewOpen, showBrowser, startLogin]); - - useWebviewPositionSync(containerRef, isWebviewOpen, updatePosition); - - const handleCloseBrowser = useCallback(() => { - void closeWebview(); - setShowBrowser(false); - }, [closeWebview]); - - useEffect(() => { - if (closeSignal <= 0 || !showBrowser) return; - queueMicrotask(() => handleCloseBrowser()); - }, [closeSignal, handleCloseBrowser, showBrowser]); - - const handleRetry = useCallback(() => { - reset(); - setShowBrowser(true); - void startLogin(); - }, [reset, startLogin]); + useWebviewPositionSync( + containerRef, + capture.isWebviewOpen, + capture.updatePosition + ); - const hasToken = tokenDetected || isSignedIn || Boolean(accessToken); - const displayError = error ?? tokenError; - const currentStep = hasToken ? 2 : 1; + const hasToken = + tokenDetected || capture.isSignedIn || Boolean(capture.accessToken); return ( -
- {!showBrowser ? ( - - - - - - ) : ( -
-
-
- {currentUrl || authUrl || t("keyVault.claudeCodeReadyToSignIn")} + +
+ Access Token:{" "} + {capture.accessToken + ? `${capture.accessToken.slice(0, 24)}...` + : "null"}
-
- -
-
- - - +
+ Refresh Token:{" "} + {capture.refreshToken + ? `${capture.refreshToken.slice(0, 24)}...` + : "null"}
- {!hasToken && ( - - {t("keyVault.claudeCodeBrowserHint")} - - )} -
- -
- {(isSigningIn || isWebviewLoading) && ( -
- - - {t("keyVault.loadingText")} - -
- )} - {displayError && ( -
- -
- {t("keyVault.failedToLoadBrowser")} -
-
- {displayError} -
- -
- )} - {!isWebviewOpen && !isSigningIn && !displayError && ( -
- {hasToken ? ( - - ) : ( - - )} -
- {hasToken - ? t("keyVault.claudeCodeSignedIn") - : t("keyVault.claudeCodeReadyToSignIn")} -
-
- {t("keyVault.claudeCodeOAuthHint")} -
-
- )} -
-
- )} - - {hasToken && !showBrowser && ( - - {t("keyVault.claudeCodeSignedIn")} - - )} - - {displayError && !showBrowser && ( - - {t("keyVault.claudeCodeSignInErrorHint")} - - )} - - {debug && ( -
-
- Access Token:{" "} - {accessToken ? `${accessToken.slice(0, 24)}...` : "null"} -
-
- Refresh Token:{" "} - {refreshToken ? `${refreshToken.slice(0, 24)}...` : "null"} -
-
Expires In: {expiresIn ?? "null"}
-
Is Webview Open: {String(isWebviewOpen)}
-
Current URL: {currentUrl || "null"}
-
- )} -
+
Expires In: {capture.expiresIn ?? "null"}
+
Is Webview Open: {String(capture.isWebviewOpen)}
+
Current URL: {capture.currentUrl || "null"}
+ + ) : undefined + } + /> ); -}; - -interface StepIndicatorProps { - step: number; - currentStep: number; - label: string; - completed: boolean; } - -const StepIndicator: React.FC = ({ - step, - currentStep, - label, - completed, -}) => { - const isActive = step === currentStep; - const isPast = step < currentStep || completed; - - return ( -
-
- {isPast ? : step} -
- - {label} - -
- ); -}; - -export default ClaudeCodeSessionSetup; diff --git a/src/features/SessionSetup/components/CodexSessionSetup/index.tsx b/src/features/SessionSetup/components/CodexSessionSetup/index.tsx index a16616b720..4b9b339562 100644 --- a/src/features/SessionSetup/components/CodexSessionSetup/index.tsx +++ b/src/features/SessionSetup/components/CodexSessionSetup/index.tsx @@ -1,26 +1,12 @@ -import { - AlertCircle, - CheckCircle, - ChevronRight, - Loader2, - LogIn, - RefreshCw, - X, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; import { useCodexOAuthCapture, useWebviewPositionSync, } from "@src/hooks/workStation/sessionCapture"; -import { - SectionContainer, - SectionRow, -} from "@src/modules/shared/layouts/SectionLayout"; + +import { OAuthSessionSetupShell } from "../OAuthSessionSetupShell"; export interface CodexSessionValues { accessToken: string; @@ -40,7 +26,7 @@ export interface CodexSessionSetupProps { autoStart?: boolean; } -const CodexSessionSetup: React.FC = ({ +export default function CodexSessionSetup({ onSessionCaptured, onBrowserStateChange, debug = false, @@ -49,28 +35,10 @@ const CodexSessionSetup: React.FC = ({ onClearTokenError, closeSignal = 0, autoStart = false, -}) => { +}: CodexSessionSetupProps) { const { t } = useTranslation("integrations"); - const [showBrowser, setShowBrowser] = useState(autoStart); const containerRef = useRef(null); - - const { - isSigningIn, - isSignedIn, - isWebviewOpen, - isWebviewLoading, - currentUrl, - authUrl, - error, - accessToken, - refreshToken, - idToken, - expiresIn, - startLogin, - closeWebview, - reset, - updatePosition, - } = useCodexOAuthCapture({ + const capture = useCodexOAuthCapture({ containerRef, debug, onTokenCaptured: (response) => { @@ -83,259 +51,76 @@ const CodexSessionSetup: React.FC = ({ }, }); - useEffect(() => { - onBrowserStateChange?.(showBrowser); - }, [showBrowser, onBrowserStateChange]); - - useEffect(() => { - if (!isWebviewOpen && isSignedIn) { - queueMicrotask(() => setShowBrowser(false)); - } - }, [isSignedIn, isWebviewOpen]); - - useEffect(() => { - if (!showBrowser || isWebviewOpen || isSigningIn) return; - - const timer = setTimeout(() => { - void startLogin(); - }, 100); - - return () => clearTimeout(timer); - }, [isSigningIn, isWebviewOpen, showBrowser, startLogin]); - - useWebviewPositionSync(containerRef, isWebviewOpen, updatePosition); - - const handleCloseBrowser = useCallback(() => { - void closeWebview(); - setShowBrowser(false); - }, [closeWebview]); - - useEffect(() => { - if (closeSignal <= 0 || !showBrowser) return; - queueMicrotask(() => handleCloseBrowser()); - }, [closeSignal, handleCloseBrowser, showBrowser]); - - const handleRetry = useCallback(() => { - reset(); - setShowBrowser(true); - void startLogin(); - }, [reset, startLogin]); + useWebviewPositionSync( + containerRef, + capture.isWebviewOpen, + capture.updatePosition + ); - const hasToken = tokenDetected || isSignedIn || Boolean(accessToken); - const displayError = error ?? tokenError; - const currentStep = hasToken ? 2 : 1; + const hasToken = + tokenDetected || capture.isSignedIn || Boolean(capture.accessToken); return ( -
- {!showBrowser ? ( - - - - - - ) : ( -
-
-
- {currentUrl || authUrl || t("keyVault.codexReadyToSignIn")} + +
+ Access Token:{" "} + {capture.accessToken + ? `${capture.accessToken.slice(0, 24)}...` + : "null"}
-
- -
-
- - - +
+ Refresh Token:{" "} + {capture.refreshToken + ? `${capture.refreshToken.slice(0, 24)}...` + : "null"}
- {!hasToken && ( - - {t("keyVault.codexBrowserHint")} - - )} -
- -
- {(isSigningIn || isWebviewLoading) && ( -
- - - {t("keyVault.loadingText")} - -
- )} - {displayError && ( -
- -
- {t("keyVault.failedToLoadBrowser")} -
-
- {displayError} -
- -
- )} - {!isWebviewOpen && !isSigningIn && !displayError && ( -
- {hasToken ? ( - - ) : ( - - )} -
- {hasToken - ? t("keyVault.codexSignedIn") - : t("keyVault.codexReadyToSignIn")} -
-
- {t("keyVault.codexOAuthHint")} -
-
- )} -
-
- )} - - {hasToken && !showBrowser && ( - {t("keyVault.codexSignedIn")} - )} - - {displayError && !showBrowser && ( - - {t("keyVault.codexSignInErrorHint")} - - )} - - {debug && ( -
-
- Access Token:{" "} - {accessToken ? `${accessToken.slice(0, 24)}...` : "null"} -
-
- Refresh Token:{" "} - {refreshToken ? `${refreshToken.slice(0, 24)}...` : "null"} -
-
Id Token: {idToken ? `${idToken.slice(0, 24)}...` : "null"}
-
Expires In: {expiresIn ?? "null"}
-
Is Webview Open: {String(isWebviewOpen)}
-
Current URL: {currentUrl || "null"}
-
- )} -
+
+ Id Token:{" "} + {capture.idToken ? `${capture.idToken.slice(0, 24)}...` : "null"} +
+
Expires In: {capture.expiresIn ?? "null"}
+
Is Webview Open: {String(capture.isWebviewOpen)}
+
Current URL: {capture.currentUrl || "null"}
+ + ) : undefined + } + /> ); -}; - -interface StepIndicatorProps { - step: number; - currentStep: number; - label: string; - completed: boolean; } - -const StepIndicator: React.FC = ({ - step, - currentStep, - label, - completed, -}) => { - const isActive = step === currentStep; - const isPast = step < currentStep || completed; - - return ( -
-
- {isPast ? : step} -
- - {label} - -
- ); -}; - -export default CodexSessionSetup; diff --git a/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts b/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts new file mode 100644 index 0000000000..34b012f189 --- /dev/null +++ b/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts @@ -0,0 +1,119 @@ +import { createElement, createRef } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { + type OAuthSessionSetupCopy, + OAuthSessionSetupView, + shouldCollapseOAuthBrowser, + shouldHandleOAuthCloseSignal, + shouldStartOAuthLogin, +} from "./OAuthSessionSetupShell"; + +const copy: OAuthSessionSetupCopy = { + signInTitle: "Sign in", + signInDescription: "Connect your account", + signInButton: "Continue", + signedInTitle: "Connected", + signedInStatus: "Signed in", + loginStep: "Login", + browserHint: "Complete login in the browser", + readyTitle: "Ready to sign in", + oauthHint: "The browser will open here", + loading: "Loading", + failedToLoadBrowser: "Browser failed", + retry: "Retry", + close: "Close", + errorHint: "Try signing in again", +}; + +function renderView( + overrides: Partial[0]> = {} +) { + return renderToStaticMarkup( + createElement(OAuthSessionSetupView, { + providerId: "provider", + containerRef: createRef(), + showBrowser: false, + hasToken: false, + isSigningIn: false, + isWebviewOpen: false, + isWebviewLoading: false, + currentUrl: "", + authUrl: null, + displayError: null, + copy, + onOpenBrowser: vi.fn(), + onCloseBrowser: vi.fn(), + onRetry: vi.fn(), + ...overrides, + }) + ); +} + +describe("OAuthSessionSetupView", () => { + it("renders the idle sign-in action", () => { + const markup = renderView(); + + expect(markup).toContain('data-testid="provider-session-setup"'); + expect(markup).toContain('data-testid="provider-oauth-signin"'); + expect(markup).toContain("Continue"); + expect(markup).not.toContain("provider-oauth-browser-shell"); + }); + + it("keeps the browser shell visible while login is loading", () => { + const markup = renderView({ + showBrowser: true, + isSigningIn: true, + currentUrl: "https://example.com/login", + }); + + expect(markup).toContain('data-testid="provider-oauth-browser-shell"'); + expect(markup).toContain("https://example.com/login"); + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-label="Retry"'); + expect(markup).toContain('aria-label="Close"'); + expect(markup).toContain('aria-current="step"'); + }); + + it("renders an in-place browser error with recovery", () => { + const markup = renderView({ + showBrowser: true, + displayError: "network unavailable", + }); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Browser failed"); + expect(markup).toContain("network unavailable"); + expect(markup).toContain("Retry"); + }); + + it("renders the provider success state after the browser closes", () => { + const markup = renderView({ hasToken: true }); + + expect(markup).toContain("Connected"); + expect(markup).toContain("Signed in"); + expect(markup).not.toContain("provider-oauth-browser-shell"); + }); +}); + +describe("OAuth session setup lifecycle", () => { + it("starts only for a requested browser with no active login or webview", () => { + expect(shouldStartOAuthLogin(true, false, false)).toBe(true); + expect(shouldStartOAuthLogin(false, false, false)).toBe(false); + expect(shouldStartOAuthLogin(true, true, false)).toBe(false); + expect(shouldStartOAuthLogin(true, false, true)).toBe(false); + }); + + it("collapses only after sign-in has completed and the webview closed", () => { + expect(shouldCollapseOAuthBrowser(false, true)).toBe(true); + expect(shouldCollapseOAuthBrowser(true, true)).toBe(false); + expect(shouldCollapseOAuthBrowser(false, false)).toBe(false); + }); + + it("honors an external close signal only while the browser is visible", () => { + expect(shouldHandleOAuthCloseSignal(1, true)).toBe(true); + expect(shouldHandleOAuthCloseSignal(0, true)).toBe(false); + expect(shouldHandleOAuthCloseSignal(1, false)).toBe(false); + }); +}); diff --git a/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx b/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx new file mode 100644 index 0000000000..43c4257275 --- /dev/null +++ b/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx @@ -0,0 +1,421 @@ +import { + AlertCircle, + CheckCircle, + ChevronRight, + Loader2, + LogIn, + RefreshCw, + X, +} from "lucide-react"; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import Button from "@src/components/Button"; +import InlineAlert from "@src/components/InlineAlert"; +import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; +import { + SectionContainer, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; + +export interface OAuthSessionSetupCopy { + signInTitle: string; + signInDescription: string; + signInButton: string; + signedInTitle: string; + signedInStatus: string; + loginStep: string; + browserHint: string; + readyTitle: string; + oauthHint: string; + loading: string; + failedToLoadBrowser: string; + retry: string; + close: string; + errorHint: string; +} + +interface OAuthSessionSetupViewProps { + providerId: string; + containerRef: RefObject; + showBrowser: boolean; + hasToken: boolean; + isSigningIn: boolean; + isWebviewOpen: boolean; + isWebviewLoading: boolean; + currentUrl: string; + authUrl: string | null; + displayError: string | null; + copy: OAuthSessionSetupCopy; + onOpenBrowser: () => void; + onCloseBrowser: () => void; + onRetry: () => void; + onDismissError?: () => void; + debugContent?: ReactNode; +} + +export function OAuthSessionSetupView({ + providerId, + containerRef, + showBrowser, + hasToken, + isSigningIn, + isWebviewOpen, + isWebviewLoading, + currentUrl, + authUrl, + displayError, + copy, + onOpenBrowser, + onCloseBrowser, + onRetry, + onDismissError, + debugContent, +}: OAuthSessionSetupViewProps) { + const currentStep = hasToken ? 2 : 1; + + return ( +
+ {!showBrowser ? ( + + + + + + ) : ( +
+
+
+ {currentUrl || authUrl || copy.readyTitle} +
+
+ +
+
+ + + +
+ {!hasToken && ( + + {copy.browserHint} + + )} +
+ +
+ {(isSigningIn || isWebviewLoading) && ( +
+ + {copy.loading} +
+ )} + {displayError && ( +
+ +
+ {copy.failedToLoadBrowser} +
+
+ {displayError} +
+ +
+ )} + {!isWebviewOpen && !isSigningIn && !displayError && ( +
+ {hasToken ? ( + + ) : ( + + )} +
+ {hasToken ? copy.signedInTitle : copy.readyTitle} +
+
+ {copy.oauthHint} +
+
+ )} +
+
+ )} + + {hasToken && !showBrowser && ( + {copy.signedInTitle} + )} + + {displayError && !showBrowser && ( + + {copy.errorHint} + + )} + + {debugContent && ( +
+ {debugContent} +
+ )} +
+ ); +} + +interface OAuthSessionSetupShellProps extends Omit< + OAuthSessionSetupViewProps, + | "showBrowser" + | "displayError" + | "onOpenBrowser" + | "onCloseBrowser" + | "onRetry" + | "onDismissError" +> { + isSignedIn: boolean; + captureError: string | null; + tokenError?: string | null; + onClearTokenError?: () => void; + onBrowserStateChange?: (isOpen: boolean) => void; + closeSignal?: number; + initiallyOpen?: boolean; + startLogin: () => Promise; + closeWebview: () => Promise; + reset: () => void; +} + +export function shouldCollapseOAuthBrowser( + isWebviewOpen: boolean, + isSignedIn: boolean +): boolean { + return !isWebviewOpen && isSignedIn; +} + +export function shouldStartOAuthLogin( + showBrowser: boolean, + isWebviewOpen: boolean, + isSigningIn: boolean +): boolean { + return showBrowser && !isWebviewOpen && !isSigningIn; +} + +export function shouldHandleOAuthCloseSignal( + closeSignal: number, + showBrowser: boolean +): boolean { + return closeSignal > 0 && showBrowser; +} + +export function OAuthSessionSetupShell({ + isSignedIn, + captureError, + tokenError = null, + onClearTokenError, + onBrowserStateChange, + closeSignal = 0, + initiallyOpen = false, + startLogin, + closeWebview, + reset, + ...viewProps +}: OAuthSessionSetupShellProps) { + const [showBrowser, setShowBrowser] = useState(initiallyOpen); + const showBrowserRef = useRef(initiallyOpen); + const retryInFlightRef = useRef(false); + + const setBrowserVisibility = useCallback( + (isOpen: boolean) => { + if (showBrowserRef.current === isOpen) return; + showBrowserRef.current = isOpen; + setShowBrowser(isOpen); + onBrowserStateChange?.(isOpen); + }, + [onBrowserStateChange] + ); + + // Synchronize the owning wizard layout on initial auto-start and whenever + // its callback identity changes. All later visibility transitions notify at + // their originating event or native WebView transition. + useEffect(() => { + onBrowserStateChange?.(showBrowserRef.current); + }, [onBrowserStateChange]); + + useEffect(() => { + if (shouldCollapseOAuthBrowser(viewProps.isWebviewOpen, isSignedIn)) { + queueMicrotask(() => setBrowserVisibility(false)); + } + }, [isSignedIn, setBrowserVisibility, viewProps.isWebviewOpen]); + + useEffect(() => { + if ( + !shouldStartOAuthLogin( + showBrowser, + viewProps.isWebviewOpen, + viewProps.isSigningIn + ) + ) { + return; + } + + const timer = setTimeout(() => { + void startLogin(); + }, 100); + + return () => clearTimeout(timer); + }, [startLogin, showBrowser, viewProps.isSigningIn, viewProps.isWebviewOpen]); + + const handleCloseBrowser = useCallback(() => { + if (!showBrowserRef.current) return; + void closeWebview(); + setBrowserVisibility(false); + }, [closeWebview, setBrowserVisibility]); + + useEffect(() => { + if (!shouldHandleOAuthCloseSignal(closeSignal, showBrowser)) return; + queueMicrotask(() => handleCloseBrowser()); + }, [closeSignal, handleCloseBrowser, showBrowser]); + + const handleRetry = useCallback(() => { + if (retryInFlightRef.current) return; + retryInFlightRef.current = true; + reset(); + setBrowserVisibility(true); + void startLogin().finally(() => { + retryInFlightRef.current = false; + }); + }, [reset, setBrowserVisibility, startLogin]); + + const displayError = captureError ?? tokenError; + + return ( + setBrowserVisibility(true)} + onCloseBrowser={handleCloseBrowser} + onRetry={handleRetry} + onDismissError={captureError ? reset : onClearTokenError} + /> + ); +} + +interface StepIndicatorProps { + step: number; + currentStep: number; + label: string; + completed: boolean; +} + +function StepIndicator({ + step, + currentStep, + label, + completed, +}: StepIndicatorProps) { + const isActive = step === currentStep; + const isPast = step < currentStep || completed; + + return ( +
+
+ {isPast ? : step} +
+ + {label} + +
+ ); +} From fb518d20a17f30e0679d9396d3e52b71a3f2ccbb Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 16:26:47 +0800 Subject: [PATCH 8/9] refactor(spotlight): reuse fuzzy search utilities --- .../EditorPalette/hooks/useSymbolMode.ts | 65 +------------------ 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/src/scaffold/GlobalSpotlight/palettes/EditorPalette/hooks/useSymbolMode.ts b/src/scaffold/GlobalSpotlight/palettes/EditorPalette/hooks/useSymbolMode.ts index 3e407b195e..852b5c9ad3 100644 --- a/src/scaffold/GlobalSpotlight/palettes/EditorPalette/hooks/useSymbolMode.ts +++ b/src/scaffold/GlobalSpotlight/palettes/EditorPalette/hooks/useSymbolMode.ts @@ -28,6 +28,7 @@ import { SYMBOL_LABELS, } from "@src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/OutlineContent/config"; import type { SymbolKind } from "@src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/OutlineContent/types"; +import { fuzzyMatch, fuzzyScore } from "@src/util/search/fuzzy"; import type { SpotlightItem } from "../../../shared"; @@ -87,70 +88,6 @@ function normalizeSymbolKind(kind: string): SymbolKind { : "function"; } -/** Simple fuzzy match - checks if all characters in query appear in name in order */ -function fuzzyMatch(query: string, name: string): boolean { - if (!query) return true; - const lowerQuery = query.toLowerCase(); - const lowerName = name.toLowerCase(); - - let queryIdx = 0; - for (let nameIdx = 0; nameIdx < lowerName.length; nameIdx++) { - if (lowerName[nameIdx] === lowerQuery[queryIdx]) { - queryIdx++; - if (queryIdx === lowerQuery.length) return true; - } - } - return false; -} - -/** Score a fuzzy match - higher is better */ -function fuzzyScore(query: string, name: string): number { - if (!query) return 0; - const lowerQuery = query.toLowerCase(); - const lowerName = name.toLowerCase(); - - // Exact match gets highest score - if (lowerName === lowerQuery) return 1000; - - // Starts with gets high score - if (lowerName.startsWith(lowerQuery)) return 500; - - // Contains gets medium score - if (lowerName.includes(lowerQuery)) return 200; - - // Fuzzy match scoring based on character positions - let score = 0; - let queryIdx = 0; - let prevMatchIdx = -2; - - for (let nameIdx = 0; nameIdx < lowerName.length; nameIdx++) { - if ( - queryIdx < lowerQuery.length && - lowerName[nameIdx] === lowerQuery[queryIdx] - ) { - score += 10; - // Consecutive matches get bonus - if (nameIdx === prevMatchIdx + 1) { - score += 5; - } - // Start-of-word matches get bonus (after _, -, or uppercase boundary) - if ( - nameIdx === 0 || - name[nameIdx - 1] === "_" || - name[nameIdx - 1] === "-" || - (name[nameIdx] === name[nameIdx].toUpperCase() && - name[nameIdx - 1] === name[nameIdx - 1].toLowerCase()) - ) { - score += 3; - } - prevMatchIdx = nameIdx; - queryIdx++; - } - } - - return score; -} - // ============================================ // Hook // ============================================ From c00a9c294a9f4e0805e3f32cfaf5e3a3073f9f82 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 11 Aug 2026 16:33:43 +0800 Subject: [PATCH 9/9] refactor(database): share tauri sql provider lifecycle --- .../DatabaseCore/providers/MySQLProvider.ts | 366 ++--------------- .../providers/PostgresProvider.ts | 368 ++---------------- .../providers/TauriSqlProvider.ts | 321 +++++++++++++++ .../__tests__/TauriSqlProvider.test.ts | 226 +++++++++++ 4 files changed, 598 insertions(+), 683 deletions(-) create mode 100644 src/engines/DatabaseCore/providers/TauriSqlProvider.ts create mode 100644 src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts diff --git a/src/engines/DatabaseCore/providers/MySQLProvider.ts b/src/engines/DatabaseCore/providers/MySQLProvider.ts index dcbc6cb77e..671482d602 100644 --- a/src/engines/DatabaseCore/providers/MySQLProvider.ts +++ b/src/engines/DatabaseCore/providers/MySQLProvider.ts @@ -1,348 +1,11 @@ /** * MySQL Database Provider * - * Implements IDatabaseService for direct MySQL/MariaDB connections. - * Delegates to Tauri backend commands (sqlx) for TCP connection handling. + * Defines MySQL-specific connection and SQL syntax while the shared + * TauriSqlProvider owns the sqlx command lifecycle. */ -import { invoke } from "@tauri-apps/api/core"; - -import type { - ColumnInfo, - ConnectionStatus, - ExecuteResult, - IDatabaseService, - MySQLConnectionConfig, - QueryOptions, - QueryResult, - TableInfo, -} from "../types"; - -interface TauriQueryResult { - columns: string[]; - rows: unknown[][]; - row_count: number; -} - -interface TauriExecuteResult { - rows_affected: number; -} - -interface TauriTableInfo { - name: string; - table_type: string; - row_count: number | null; -} - -interface TauriColumnInfo { - name: string; - data_type: string; - nullable: boolean; - primary_key: boolean; - default_value: string | null; - auto_increment: boolean; -} - -function buildConnectionString(config: MySQLConnectionConfig): string { - const userPart = config.password - ? `${config.user}:${config.password}` - : config.user; - const sslMode = config.ssl ? "REQUIRED" : "PREFERRED"; - return `mysql://${userPart}@${config.host}:${config.port}/${config.database}?ssl-mode=${sslMode}`; -} - -export class MySQLProvider implements IDatabaseService { - readonly type = "mysql" as const; - readonly config: MySQLConnectionConfig; - - private _status: ConnectionStatus = { state: "disconnected" }; - private _connected = false; - - constructor(config: MySQLConnectionConfig) { - this.config = config; - } - - get status(): ConnectionStatus { - return this._status; - } - - async connect(): Promise { - if (this._connected) return; - - this._status = { state: "connecting" }; - - try { - await invoke("db_sql_connect", { - connectionId: this.config.id, - dbType: "mysql", - connectionString: buildConnectionString(this.config), - }); - this._connected = true; - this._status = { state: "connected", connectedAt: Date.now() }; - } catch (error) { - this._connected = false; - const message = error instanceof Error ? error.message : String(error); - this._status = { state: "error", error: message }; - throw new Error(message); - } - } - - async disconnect(): Promise { - if (this._connected) { - try { - await invoke("db_sql_disconnect", { - connectionId: this.config.id, - }); - } catch { - // Best-effort disconnect - } - } - this._connected = false; - this._status = { state: "disconnected" }; - } - - isConnected(): boolean { - return this._connected && this._status.state === "connected"; - } - - async getTables(): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_tables", { - connectionId: this.config.id, - }); - - return result.map((table) => ({ - name: table.name, - type: - table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - rowCount: table.row_count ?? undefined, - })); - } - - async getTableSchema(tableName: string): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_table_schema", { - connectionId: this.config.id, - tableName, - }); - - return result.map((col) => ({ - name: col.name, - type: col.data_type, - nullable: col.nullable, - primaryKey: col.primary_key, - defaultValue: col.default_value, - autoIncrement: col.auto_increment, - })); - } - - async getTableData( - tableName: string, - options: QueryOptions = {} - ): Promise { - this.ensureConnected(); - - const { - page = 1, - pageSize = 100, - orderBy, - orderDirection = "asc", - } = options; - const offset = (page - 1) * pageSize; - const startTime = performance.now(); - - let sql = `SELECT * FROM \`${tableName}\``; - if (orderBy) { - sql += ` ORDER BY \`${orderBy}\` ${orderDirection.toUpperCase()}`; - } - sql += ` LIMIT ${pageSize} OFFSET ${offset}`; - - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - let totalCount: number | undefined; - try { - const countResult = await invoke("db_sql_query", { - connectionId: this.config.id, - sql: `SELECT COUNT(*) as count FROM \`${tableName}\``, - }); - if (countResult.rows.length > 0) { - totalCount = Number(countResult.rows[0][0]); - } - } catch { - // Ignore count errors - } - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - totalCount, - duration, - }; - } - - async query(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - duration, - }; - } - - async execute(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async insert( - tableName: string, - data: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const columns = Object.keys(data); - const values = columns.map((col) => formatMySqlValue(data[col])); - - const sql = ` - INSERT INTO \`${tableName}\` (${columns.map((col) => `\`${col}\``).join(", ")}) - VALUES (${values.join(", ")}) - `; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async update( - tableName: string, - data: Record, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const setClause = Object.entries(data) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(", "); - const whereClause = Object.entries(where) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(" AND "); - - const sql = `UPDATE \`${tableName}\` SET ${setClause} WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async delete( - tableName: string, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const whereClause = Object.entries(where) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(" AND "); - - const sql = `DELETE FROM \`${tableName}\` WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async save(): Promise { - // No-op for remote databases - } - - private ensureConnected(): void { - if (!this._connected) { - throw new Error("Database not connected. Call connect() first."); - } - } -} +import type { MySQLConnectionConfig } from "../types"; +import { type TauriSqlDialect, TauriSqlProvider } from "./TauriSqlProvider"; function formatMySqlValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; @@ -355,4 +18,25 @@ function formatMySqlValue(value: unknown): string { return `'${String(value).replace(/'/g, "''")}'`; } +const MYSQL_DIALECT: TauriSqlDialect = { + type: "mysql", + buildConnectionString(config) { + const userPart = config.password + ? `${config.user}:${config.password}` + : config.user; + const sslMode = config.ssl ? "REQUIRED" : "PREFERRED"; + return `mysql://${userPart}@${config.host}:${config.port}/${config.database}?ssl-mode=${sslMode}`; + }, + quoteIdentifier(identifier) { + return `\`${identifier}\``; + }, + formatValue: formatMySqlValue, +}; + +export class MySQLProvider extends TauriSqlProvider { + constructor(config: MySQLConnectionConfig) { + super(config, MYSQL_DIALECT); + } +} + export default MySQLProvider; diff --git a/src/engines/DatabaseCore/providers/PostgresProvider.ts b/src/engines/DatabaseCore/providers/PostgresProvider.ts index 1087c12c94..e6f4cda718 100644 --- a/src/engines/DatabaseCore/providers/PostgresProvider.ts +++ b/src/engines/DatabaseCore/providers/PostgresProvider.ts @@ -1,350 +1,13 @@ /** * PostgreSQL Database Provider * - * Implements IDatabaseService for direct PostgreSQL connections. - * Delegates to Tauri backend commands (sqlx) for TCP connection handling. + * Defines PostgreSQL-specific connection and SQL syntax while the shared + * TauriSqlProvider owns the sqlx command lifecycle. */ -import { invoke } from "@tauri-apps/api/core"; +import type { PostgresConnectionConfig } from "../types"; +import { type TauriSqlDialect, TauriSqlProvider } from "./TauriSqlProvider"; -import type { - ColumnInfo, - ConnectionStatus, - ExecuteResult, - IDatabaseService, - PostgresConnectionConfig, - QueryOptions, - QueryResult, - TableInfo, -} from "../types"; - -interface TauriQueryResult { - columns: string[]; - rows: unknown[][]; - row_count: number; -} - -interface TauriExecuteResult { - rows_affected: number; -} - -interface TauriTableInfo { - name: string; - table_type: string; - row_count: number | null; -} - -interface TauriColumnInfo { - name: string; - data_type: string; - nullable: boolean; - primary_key: boolean; - default_value: string | null; - auto_increment: boolean; -} - -function buildConnectionString(config: PostgresConnectionConfig): string { - const userPart = config.password - ? `${config.user}:${config.password}` - : config.user; - const sslMode = config.ssl ? "require" : "prefer"; - return `postgres://${userPart}@${config.host}:${config.port}/${config.database}?sslmode=${sslMode}`; -} - -export class PostgresProvider implements IDatabaseService { - readonly type = "postgres" as const; - readonly config: PostgresConnectionConfig; - - private _status: ConnectionStatus = { state: "disconnected" }; - private _connected = false; - - constructor(config: PostgresConnectionConfig) { - this.config = config; - } - - get status(): ConnectionStatus { - return this._status; - } - - async connect(): Promise { - if (this._connected) return; - - this._status = { state: "connecting" }; - - try { - await invoke("db_sql_connect", { - connectionId: this.config.id, - dbType: "postgres", - connectionString: buildConnectionString(this.config), - }); - this._connected = true; - this._status = { state: "connected", connectedAt: Date.now() }; - } catch (error) { - this._connected = false; - const message = error instanceof Error ? error.message : String(error); - this._status = { state: "error", error: message }; - throw new Error(message); - } - } - - async disconnect(): Promise { - if (this._connected) { - try { - await invoke("db_sql_disconnect", { - connectionId: this.config.id, - }); - } catch { - // Best-effort disconnect - } - } - this._connected = false; - this._status = { state: "disconnected" }; - } - - isConnected(): boolean { - return this._connected && this._status.state === "connected"; - } - - async getTables(): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_tables", { - connectionId: this.config.id, - }); - - return result.map((table) => ({ - name: table.name, - type: - table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - rowCount: table.row_count ?? undefined, - })); - } - - async getTableSchema(tableName: string): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_table_schema", { - connectionId: this.config.id, - tableName, - }); - - return result.map((col) => ({ - name: col.name, - type: col.data_type, - nullable: col.nullable, - primaryKey: col.primary_key, - defaultValue: col.default_value, - autoIncrement: col.auto_increment, - })); - } - - async getTableData( - tableName: string, - options: QueryOptions = {} - ): Promise { - this.ensureConnected(); - - const { - page = 1, - pageSize = 100, - orderBy, - orderDirection = "asc", - } = options; - const offset = (page - 1) * pageSize; - const startTime = performance.now(); - - let sql = `SELECT * FROM "${tableName}"`; - if (orderBy) { - sql += ` ORDER BY "${orderBy}" ${orderDirection.toUpperCase()}`; - } - sql += ` LIMIT ${pageSize} OFFSET ${offset}`; - - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - let totalCount: number | undefined; - try { - const countResult = await invoke("db_sql_query", { - connectionId: this.config.id, - sql: `SELECT COUNT(*) as count FROM "${tableName}"`, - }); - if (countResult.rows.length > 0) { - totalCount = Number(countResult.rows[0][0]); - } - } catch { - // Ignore count errors - } - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - totalCount, - duration, - }; - } - - async query(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - duration, - }; - } - - async execute(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async insert( - tableName: string, - data: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const columns = Object.keys(data); - const values = columns.map((col) => formatSqlValue(data[col])); - - const sql = ` - INSERT INTO "${tableName}" (${columns.map((col) => `"${col}"`).join(", ")}) - VALUES (${values.join(", ")}) - `; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async update( - tableName: string, - data: Record, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const setClause = Object.entries(data) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(", "); - const whereClause = Object.entries(where) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(" AND "); - - const sql = `UPDATE "${tableName}" SET ${setClause} WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async delete( - tableName: string, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const whereClause = Object.entries(where) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(" AND "); - - const sql = `DELETE FROM "${tableName}" WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async save(): Promise { - // No-op for remote databases - } - - private ensureConnected(): void { - if (!this._connected) { - throw new Error("Database not connected. Call connect() first."); - } - } -} - -function formatSqlValue(value: unknown): string { +function formatPostgresValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; @@ -355,4 +18,25 @@ function formatSqlValue(value: unknown): string { return `'${String(value).replace(/'/g, "''")}'`; } +const POSTGRES_DIALECT: TauriSqlDialect = { + type: "postgres", + buildConnectionString(config) { + const userPart = config.password + ? `${config.user}:${config.password}` + : config.user; + const sslMode = config.ssl ? "require" : "prefer"; + return `postgres://${userPart}@${config.host}:${config.port}/${config.database}?sslmode=${sslMode}`; + }, + quoteIdentifier(identifier) { + return `"${identifier}"`; + }, + formatValue: formatPostgresValue, +}; + +export class PostgresProvider extends TauriSqlProvider { + constructor(config: PostgresConnectionConfig) { + super(config, POSTGRES_DIALECT); + } +} + export default PostgresProvider; diff --git a/src/engines/DatabaseCore/providers/TauriSqlProvider.ts b/src/engines/DatabaseCore/providers/TauriSqlProvider.ts new file mode 100644 index 0000000000..3522b92570 --- /dev/null +++ b/src/engines/DatabaseCore/providers/TauriSqlProvider.ts @@ -0,0 +1,321 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + ColumnInfo, + ConnectionStatus, + ExecuteResult, + IDatabaseService, + MySQLConnectionConfig, + PostgresConnectionConfig, + QueryOptions, + QueryResult, + TableInfo, +} from "../types"; + +type TauriSqlConnectionConfig = + | PostgresConnectionConfig + | MySQLConnectionConfig; + +export interface TauriSqlDialect { + readonly type: Config["type"]; + buildConnectionString(config: Config): string; + quoteIdentifier(identifier: string): string; + formatValue(value: unknown): string; +} + +interface TauriQueryResult { + columns: string[]; + rows: unknown[][]; + row_count: number; +} + +interface TauriExecuteResult { + rows_affected: number; +} + +interface TauriTableInfo { + name: string; + table_type: string; + row_count: number | null; +} + +interface TauriColumnInfo { + name: string; + data_type: string; + nullable: boolean; + primary_key: boolean; + default_value: string | null; + auto_increment: boolean; +} + +/** + * Shared lifecycle and command adapter for the sqlx-backed database providers. + * Provider-specific connection strings and SQL syntax stay in a dialect object. + */ +export abstract class TauriSqlProvider< + Config extends TauriSqlConnectionConfig, +> implements IDatabaseService { + readonly type: Config["type"]; + readonly config: Config; + + private _status: ConnectionStatus = { state: "disconnected" }; + private _connected = false; + + protected constructor( + config: Config, + private readonly dialect: TauriSqlDialect + ) { + this.config = config; + this.type = dialect.type; + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + if (this._connected) return; + + this._status = { state: "connecting" }; + + try { + await invoke("db_sql_connect", { + connectionId: this.config.id, + dbType: this.type, + connectionString: this.dialect.buildConnectionString(this.config), + }); + this._connected = true; + this._status = { state: "connected", connectedAt: Date.now() }; + } catch (error) { + this._connected = false; + const message = error instanceof Error ? error.message : String(error); + this._status = { state: "error", error: message }; + throw new Error(message); + } + } + + async disconnect(): Promise { + if (this._connected) { + try { + await invoke("db_sql_disconnect", { + connectionId: this.config.id, + }); + } catch { + // Best-effort disconnect + } + } + this._connected = false; + this._status = { state: "disconnected" }; + } + + isConnected(): boolean { + return this._connected && this._status.state === "connected"; + } + + async getTables(): Promise { + this.ensureConnected(); + + const result = await invoke("db_sql_get_tables", { + connectionId: this.config.id, + }); + + return result.map((table) => ({ + name: table.name, + type: + table.table_type === "VIEW" ? ("view" as const) : ("table" as const), + rowCount: table.row_count ?? undefined, + })); + } + + async getTableSchema(tableName: string): Promise { + this.ensureConnected(); + + const result = await invoke("db_sql_get_table_schema", { + connectionId: this.config.id, + tableName, + }); + + return result.map((column) => ({ + name: column.name, + type: column.data_type, + nullable: column.nullable, + primaryKey: column.primary_key, + defaultValue: column.default_value, + autoIncrement: column.auto_increment, + })); + } + + async getTableData( + tableName: string, + options: QueryOptions = {} + ): Promise { + this.ensureConnected(); + + const { + page = 1, + pageSize = 100, + orderBy, + orderDirection = "asc", + } = options; + const offset = (page - 1) * pageSize; + const startTime = performance.now(); + const table = this.dialect.quoteIdentifier(tableName); + const orderColumn = orderBy + ? this.dialect.quoteIdentifier(orderBy) + : undefined; + + let sql = `SELECT * FROM ${table}`; + if (orderColumn) { + sql += ` ORDER BY ${orderColumn} ${orderDirection.toUpperCase()}`; + } + sql += ` LIMIT ${pageSize} OFFSET ${offset}`; + + const result = await invoke("db_sql_query", { + connectionId: this.config.id, + sql, + }); + const duration = performance.now() - startTime; + + let totalCount: number | undefined; + try { + const countResult = await invoke("db_sql_query", { + connectionId: this.config.id, + sql: `SELECT COUNT(*) as count FROM ${table}`, + }); + if (countResult.rows.length > 0) { + totalCount = Number(countResult.rows[0][0]); + } + } catch { + // Count metadata is optional; return the requested page when it fails. + } + + return { + columns: result.columns, + values: result.rows, + rowCount: result.row_count, + totalCount, + duration, + }; + } + + async query(sql: string): Promise { + this.ensureConnected(); + + const startTime = performance.now(); + const result = await invoke("db_sql_query", { + connectionId: this.config.id, + sql, + }); + + return { + columns: result.columns, + values: result.rows, + rowCount: result.row_count, + duration: performance.now() - startTime, + }; + } + + async execute(sql: string): Promise { + this.ensureConnected(); + return this.executeMutation(sql); + } + + async insert( + tableName: string, + data: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const columns = Object.keys(data); + const quotedColumns = columns.map((column) => + this.dialect.quoteIdentifier(column) + ); + const values = columns.map((column) => + this.dialect.formatValue(data[column]) + ); + const sql = ` + INSERT INTO ${table} (${quotedColumns.join(", ")}) + VALUES (${values.join(", ")}) + `; + + return this.executeMutation(sql, startTime); + } + + async update( + tableName: string, + data: Record, + where: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const setClause = this.formatAssignments(data, ", "); + const whereClause = this.formatAssignments(where, " AND "); + const sql = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`; + + return this.executeMutation(sql, startTime); + } + + async delete( + tableName: string, + where: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const whereClause = this.formatAssignments(where, " AND "); + const sql = `DELETE FROM ${table} WHERE ${whereClause}`; + + return this.executeMutation(sql, startTime); + } + + async save(): Promise { + // No-op for remote databases + } + + private formatAssignments( + values: Record, + separator: string + ): string { + return Object.entries(values) + .map( + ([column, value]) => + `${this.dialect.quoteIdentifier(column)} = ${this.dialect.formatValue(value)}` + ) + .join(separator); + } + + private async executeMutation( + sql: string, + startTime = performance.now() + ): Promise { + try { + const result = await invoke("db_sql_execute", { + connectionId: this.config.id, + sql, + }); + return { + success: true, + rowsAffected: result.rows_affected, + duration: performance.now() - startTime, + }; + } catch (error) { + return { + success: false, + rowsAffected: 0, + duration: performance.now() - startTime, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private ensureConnected(): void { + if (!this._connected) { + throw new Error("Database not connected. Call connect() first."); + } + } +} diff --git a/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts b/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts new file mode 100644 index 0000000000..56a93d7efb --- /dev/null +++ b/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts @@ -0,0 +1,226 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + MySQLConnectionConfig, + PostgresConnectionConfig, +} from "../../types"; +import { MySQLProvider } from "../MySQLProvider"; +import { PostgresProvider } from "../PostgresProvider"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const invokeMock = vi.mocked(invoke); + +const baseConfig = { + name: "Test database", + createdAt: 1, + updatedAt: 1, +} as const; + +const postgresConfig: PostgresConnectionConfig = { + ...baseConfig, + id: "postgres-1", + type: "postgres", + host: "postgres.example.com", + port: 5432, + database: "app", + user: "developer", + password: "secret", + ssl: true, +}; + +const mysqlConfig: MySQLConnectionConfig = { + ...baseConfig, + id: "mysql-1", + type: "mysql", + host: "mysql.example.com", + port: 3306, + database: "app", + user: "root", + ssl: false, +}; + +function sqlCalls(): string[] { + return invokeMock.mock.calls + .filter( + ([command]) => command === "db_sql_query" || command === "db_sql_execute" + ) + .map(([, args]) => (args as { sql: string }).sql); +} + +describe("TauriSqlProvider", () => { + beforeEach(() => { + invokeMock.mockReset().mockImplementation(async (command) => { + switch (command) { + case "db_sql_query": + return { columns: ["id"], rows: [[1]], row_count: 1 }; + case "db_sql_execute": + return { rows_affected: 2 }; + case "db_sql_get_tables": + return [ + { name: "users", table_type: "BASE TABLE", row_count: 4 }, + { name: "active_users", table_type: "VIEW", row_count: null }, + ]; + case "db_sql_get_table_schema": + return [ + { + name: "id", + data_type: "integer", + nullable: false, + primary_key: true, + default_value: null, + auto_increment: true, + }, + ]; + default: + return undefined; + } + }); + }); + + it("connects each provider with its unchanged database type and connection string", async () => { + const postgres = new PostgresProvider(postgresConfig); + const mysql = new MySQLProvider(mysqlConfig); + + await postgres.connect(); + await mysql.connect(); + + expect(invokeMock).toHaveBeenNthCalledWith(1, "db_sql_connect", { + connectionId: "postgres-1", + dbType: "postgres", + connectionString: + "postgres://developer:secret@postgres.example.com:5432/app?sslmode=require", + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, "db_sql_connect", { + connectionId: "mysql-1", + dbType: "mysql", + connectionString: + "mysql://root@mysql.example.com:3306/app?ssl-mode=PREFERRED", + }); + expect(postgres.status.state).toBe("connected"); + expect(mysql.isConnected()).toBe(true); + }); + + it.each([ + { + name: "PostgreSQL", + provider: () => new PostgresProvider(postgresConfig), + expected: [ + 'SELECT * FROM "users" ORDER BY "created_at" DESC LIMIT 25 OFFSET 25', + 'SELECT COUNT(*) as count FROM "users"', + ], + }, + { + name: "MySQL", + provider: () => new MySQLProvider(mysqlConfig), + expected: [ + "SELECT * FROM `users` ORDER BY `created_at` DESC LIMIT 25 OFFSET 25", + "SELECT COUNT(*) as count FROM `users`", + ], + }, + ])( + "keeps $name pagination and identifier syntax", + async ({ provider, expected }) => { + const service = provider(); + await service.connect(); + + const result = await service.getTableData("users", { + page: 2, + pageSize: 25, + orderBy: "created_at", + orderDirection: "desc", + }); + + expect(sqlCalls()).toEqual(expected); + expect(result).toMatchObject({ + columns: ["id"], + values: [[1]], + rowCount: 1, + totalCount: 1, + }); + } + ); + + it("keeps PostgreSQL value formatting in shared CRUD commands", async () => { + const provider = new PostgresProvider(postgresConfig); + await provider.connect(); + + await provider.insert("events", { + enabled: true, + payload: { label: "it's ready" }, + }); + await provider.update("events", { enabled: false }, { id: 3 }); + await provider.delete("events", { id: 3 }); + + expect(sqlCalls()).toEqual([ + expect.stringContaining( + 'INSERT INTO "events" ("enabled", "payload")\n VALUES (TRUE, \'{"label":"it\'\'s ready"}\'::jsonb)' + ), + 'UPDATE "events" SET "enabled" = FALSE WHERE "id" = 3', + 'DELETE FROM "events" WHERE "id" = 3', + ]); + }); + + it("keeps MySQL value formatting in shared CRUD commands", async () => { + const provider = new MySQLProvider(mysqlConfig); + await provider.connect(); + + await provider.insert("events", { + enabled: true, + payload: { label: "it's ready" }, + }); + await provider.update("events", { enabled: false }, { id: 3 }); + await provider.delete("events", { id: 3 }); + + expect(sqlCalls()).toEqual([ + expect.stringContaining( + "INSERT INTO `events` (`enabled`, `payload`)\n VALUES (1, '{\"label\":\"it''s ready\"}')" + ), + "UPDATE `events` SET `enabled` = 0 WHERE `id` = 3", + "DELETE FROM `events` WHERE `id` = 3", + ]); + }); + + it("maps shared table metadata and resets state after a failed disconnect", async () => { + const provider = new PostgresProvider(postgresConfig); + await provider.connect(); + + await expect(provider.getTables()).resolves.toEqual([ + { name: "users", type: "table", rowCount: 4 }, + { name: "active_users", type: "view", rowCount: undefined }, + ]); + await expect(provider.getTableSchema("users")).resolves.toEqual([ + { + name: "id", + type: "integer", + nullable: false, + primaryKey: true, + defaultValue: null, + autoIncrement: true, + }, + ]); + + invokeMock.mockRejectedValueOnce(new Error("already closed")); + await provider.disconnect(); + + expect(provider.status).toEqual({ state: "disconnected" }); + expect(provider.isConnected()).toBe(false); + }); + + it("rejects commands before connection and records connect failures", async () => { + const provider = new MySQLProvider(mysqlConfig); + + await expect(provider.query("SELECT 1")).rejects.toThrow( + "Database not connected" + ); + invokeMock.mockRejectedValueOnce(new Error("connection refused")); + + await expect(provider.connect()).rejects.toThrow("connection refused"); + expect(provider.status).toEqual({ + state: "error", + error: "connection refused", + }); + }); +});