diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a363b2d --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=@chevrotain/* diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7fad6d6..fbbb00c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -357,6 +357,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -795,6 +850,7 @@ name = "codeg" version = "0.3.0" dependencies = [ "agent-client-protocol-schema", + "axum", "base64 0.22.1", "bzip2", "chrono", @@ -827,6 +883,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 0.8.2", + "tower-http", "urlencoding", "uuid", "walkdir", @@ -1154,6 +1211,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "dbus" version = "0.9.10" @@ -2268,12 +2331,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -2288,6 +2363,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -3014,6 +3090,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -3054,6 +3136,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5247,6 +5339,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6628,6 +6731,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6751,6 +6866,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6761,14 +6877,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6858,6 +6984,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6922,6 +7065,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed68f03..419e5f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ dirs = "6" walkdir = "2" sacp = "11.0.0-alpha.1" sacp-tokio = "11.0.0-alpha.1" -tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] } +tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt", "net"] } uuid = { version = "1", features = ["v4"] } futures = "0.3" reqwest = { version = "0.12", features = ["stream", "json"] } @@ -50,6 +50,8 @@ agent-client-protocol-schema = { version = "0.10", features = ["unstable_session kill_tree = { version = "0.2", features = ["tokio"] } which = "7" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +axum = { version = "0.8", features = ["ws"] } +tower-http = { version = "0.6", features = ["fs", "cors"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = "2" diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index ca477f7..4616ba1 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -24,7 +24,6 @@ use sacp::{ on_receive_request, Agent, Client, ConnectionTo, Responder, SessionMessage, UntypedMessage, }; use sacp_tokio::AcpAgent; -use tauri::Emitter; use tokio::sync::mpsc; use crate::acp::error::AcpError; @@ -195,7 +194,8 @@ async fn build_agent( .flatten() .is_some(); if !has_cached_binary { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::StatusChanged { connection_id: connection_id.into(), @@ -237,7 +237,8 @@ pub async fn spawn_agent_connection( owner_window_label: String, app_handle: tauri::AppHandle, ) -> Result { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + &app_handle, "acp://event", AcpEvent::StatusChanged { connection_id: connection_id.clone(), @@ -263,7 +264,8 @@ pub async fn spawn_agent_connection( .await; if let Err(e) = result { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::Error { connection_id: conn_id.clone(), @@ -272,7 +274,8 @@ pub async fn spawn_agent_connection( ); } - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::StatusChanged { connection_id: conn_id, @@ -315,7 +318,8 @@ fn emit_session_modes( modes: &Option, ) { if let Some(mode_state) = modes { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::SessionModes { connection_id: connection_id.into(), @@ -416,7 +420,8 @@ fn emit_session_config_options_values( config_options: Vec, ) { let mapped = map_session_config_options(&config_options); - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::SessionConfigOptions { connection_id: connection_id.into(), @@ -439,7 +444,8 @@ fn emit_session_config_options( } fn emit_selectors_ready(connection_id: &str, app_handle: &tauri::AppHandle) { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::SelectorsReady { connection_id: connection_id.into(), @@ -452,7 +458,8 @@ fn emit_prompt_capabilities( app_handle: &tauri::AppHandle, capabilities: &sacp::schema::PromptCapabilities, ) { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::PromptCapabilities { connection_id: connection_id.into(), @@ -627,7 +634,8 @@ async fn run_connection( ); // Emit fork support capability - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::ForkSupported { connection_id: conn_id.clone(), @@ -636,7 +644,8 @@ async fn run_connection( ); // Emit connected status - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::StatusChanged { connection_id: conn_id.clone(), @@ -689,7 +698,8 @@ async fn run_connection( eprintln!("[ACP] Drained {drained} historical replay notifications"); } - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::SessionStarted { connection_id: conn_id.clone(), @@ -732,7 +742,8 @@ async fn run_connection( "[ACP] session/load failed ({}), falling back to session/new", e ); - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::Error { connection_id: conn_id.clone(), @@ -747,7 +758,8 @@ async fn run_connection( let initial_config_options = new_resp.config_options.clone(); let mut session = cx.attach_session(new_resp, Default::default())?; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::SessionStarted { connection_id: conn_id.clone(), @@ -799,7 +811,8 @@ async fn run_connection( let sid = new_resp.session_id.0.to_string(); let initial_config_options = new_resp.config_options.clone(); let mut session = cx.attach_session(new_resp, Default::default())?; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + &handle, "acp://event", AcpEvent::SessionStarted { connection_id: conn_id.clone(), @@ -870,7 +883,8 @@ async fn handle_permission_request( perms.lock().await.insert(request_id.clone(), responder); - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::PermissionRequest { connection_id: conn_id.into(), @@ -914,7 +928,8 @@ async fn set_session_mode( .block_task() .await?; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::ModeChanged { connection_id: conn_id.into(), @@ -1166,7 +1181,8 @@ fn emit_terminal_output_update( output: String, append: bool, ) { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::ToolCallUpdate { connection_id: connection_id.into(), @@ -1350,7 +1366,8 @@ async fn handle_fork_or_exit( .meta(fork_resp.meta); let mut session = cx.attach_session(new_resp, Default::default())?; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::SessionStarted { connection_id: conn_id.to_string(), @@ -1430,7 +1447,8 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::Prompt { blocks }) => { let prompt_blocks = map_prompt_blocks(blocks); if prompt_blocks.is_empty() { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::Error { connection_id: conn_id.into(), @@ -1440,7 +1458,8 @@ async fn run_conversation_loop<'a>( continue; } - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::StatusChanged { connection_id: conn_id.into(), @@ -1532,7 +1551,8 @@ async fn run_conversation_loop<'a>( StopReason::Cancelled => "cancelled", _ => "unknown", }; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::TurnComplete { connection_id: conn_id.into(), @@ -1562,7 +1582,8 @@ async fn run_conversation_loop<'a>( StopReason::Cancelled => "cancelled", _ => "unknown", }; - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::TurnComplete { connection_id: conn_id.into(), @@ -1599,7 +1620,8 @@ async fn run_conversation_loop<'a>( let req = SetSessionModeRequest::new(sid.clone(), mode_id.clone()); match cx.send_request_to(Agent, req).block_task().await { Ok(_) => { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::ModeChanged { connection_id: conn_id.into(), @@ -1608,7 +1630,8 @@ async fn run_conversation_loop<'a>( ); } Err(e) => { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::Error { connection_id: conn_id.into(), @@ -1632,7 +1655,8 @@ async fn run_conversation_loop<'a>( ) .await { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::Error { connection_id: conn_id.into(), @@ -1665,7 +1689,8 @@ async fn run_conversation_loop<'a>( // transitions out of "prompting" and the user can // send new messages. Don't wait for the agent — // it may be slow to respond or not respond at all. - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::TurnComplete { connection_id: conn_id.into(), @@ -1715,7 +1740,8 @@ async fn run_conversation_loop<'a>( break; } - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::StatusChanged { connection_id: conn_id.into(), @@ -1736,7 +1762,8 @@ async fn run_conversation_loop<'a>( } Some(ConnectionCommand::SetMode { mode_id }) => { if let Err(e) = set_session_mode(session, conn_id, handle, mode_id).await { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::Error { connection_id: conn_id.into(), @@ -1754,7 +1781,8 @@ async fn run_conversation_loop<'a>( if let Err(e) = set_session_config_option(&cx, &sid, conn_id, handle, config_id, value_id).await { - let _ = handle.emit( + crate::web::event_bridge::emit_event( + handle, "acp://event", AcpEvent::Error { connection_id: conn_id.into(), @@ -1908,7 +1936,8 @@ fn emit_conversation_update( content: ContentBlock::Text(text), .. }) => { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::ContentDelta { connection_id: connection_id.into(), @@ -1923,7 +1952,8 @@ fn emit_conversation_update( content: ContentBlock::Text(text), .. }) => { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::Thinking { connection_id: connection_id.into(), @@ -1938,7 +1968,8 @@ fn emit_conversation_update( let content = serialize_tool_call_content(&tc.content); let raw_input = json_value_to_text(&tc.raw_input); let raw_output = json_value_to_text(&tc.raw_output); - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::ToolCall { connection_id: connection_id.into(), @@ -1960,7 +1991,8 @@ fn emit_conversation_update( .and_then(serialize_tool_call_content); let raw_input = json_value_to_text(&tcu.fields.raw_input); let raw_output = json_value_to_text(&tcu.fields.raw_output); - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::ToolCallUpdate { connection_id: connection_id.into(), @@ -1975,7 +2007,8 @@ fn emit_conversation_update( ); } SessionUpdate::CurrentModeUpdate(update) => { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::ModeChanged { connection_id: connection_id.into(), @@ -1984,7 +2017,8 @@ fn emit_conversation_update( ); } SessionUpdate::Plan(plan) => { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::PlanUpdate { connection_id: connection_id.into(), @@ -2011,7 +2045,8 @@ fn emit_conversation_update( } }) .collect(); - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::AvailableCommands { connection_id: connection_id.into(), @@ -2020,7 +2055,8 @@ fn emit_conversation_update( ); } SessionUpdate::UsageUpdate(update) => { - let _ = app_handle.emit( + crate::web::event_bridge::emit_event( + app_handle, "acp://event", AcpEvent::UsageUpdate { connection_id: connection_id.into(), diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 145b9d9..df54e48 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use tauri::{Emitter, State}; +use tauri::State; use crate::acp::binary_cache; use crate::acp::error::AcpError; @@ -32,7 +32,8 @@ fn emit_acp_agents_updated( reason: &'static str, agent_type: Option, ) { - let _ = app.emit( + crate::web::event_bridge::emit_event( + app, ACP_AGENTS_UPDATED_EVENT, AcpAgentsUpdatedEventPayload { reason, agent_type }, ); diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 8eeac7e..8f26c44 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -459,6 +459,60 @@ fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats { } } +// ── Public helpers for the embedded web server ── + +pub async fn list_conversations_for_web( + agent_type: Option, + search: Option, + sort_by: Option, + folder_path: Option, +) -> Result, AppCommandError> { + tokio::task::spawn_blocking(move || list_conversations_sync(agent_type, search, sort_by, folder_path)) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to list conversations") + .with_detail(e.to_string()) + }) +} + +pub async fn list_folders_for_web() -> Result, AppCommandError> { + tokio::task::spawn_blocking(move || { + let all = list_conversations_sync(None, None, None, None); + compute_folders(&all) + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to list folders").with_detail(e.to_string()) + }) +} + +pub async fn get_stats_for_web() -> Result { + tokio::task::spawn_blocking(move || { + let all = list_conversations_sync(None, None, None, None); + compute_stats(&all) + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to compute stats") + .with_detail(e.to_string()) + }) +} + +pub async fn get_sidebar_data_for_web() -> Result { + tokio::task::spawn_blocking(move || { + let all = list_conversations_sync(None, None, None, None); + SidebarData { + folders: compute_folders(&all), + stats: compute_stats(&all), + } + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to build sidebar data") + .with_detail(e.to_string()) + }) +} + fn parse_error_to_app_error(error: ParseError) -> AppCommandError { match error { ParseError::ConversationNotFound(id) => { diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index c0b810a..dfebfe4 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use base64::Engine as _; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; -use tauri::Emitter; + use tokio::sync::Semaphore; use walkdir::WalkDir; @@ -985,7 +985,8 @@ pub async fn git_push( .strip_prefix("push-") .and_then(|value| value.parse::().ok()) { - let _ = app.emit( + crate::web::event_bridge::emit_event( + &app, "folder://git-push-succeeded", GitPushSucceededEvent { folder_id, @@ -1552,7 +1553,8 @@ pub async fn git_commit( .strip_prefix("commit-") .and_then(|value| value.parse::().ok()) { - let _ = app.emit( + crate::web::event_bridge::emit_event( + &app, "folder://git-commit-succeeded", GitCommitSucceededEvent { folder_id, @@ -2384,7 +2386,7 @@ impl WatchEventBatch { full_reload: self.overflowed, }; - let _ = app.emit("folder://file-tree-changed", payload); + crate::web::event_bridge::emit_event(app, "folder://file-tree-changed", payload); } } diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index 370dd0c..00903e2 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -1,5 +1,5 @@ use sea_orm::DatabaseConnection; -use tauri::{Emitter, State}; +use tauri::State; use crate::app_error::AppCommandError; use crate::db::service::app_metadata_service; @@ -130,7 +130,7 @@ pub async fn update_system_language_settings( .await .map_err(AppCommandError::from)?; - let _ = app.emit(LANGUAGE_SETTINGS_UPDATED_EVENT, &settings); + crate::web::event_bridge::emit_event(&app, LANGUAGE_SETTINGS_UPDATED_EVENT, settings.clone()); Ok(settings) } diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index bb8b028..721a461 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::app_error::AppCommandError; use crate::db::AppDatabase; @@ -534,7 +534,8 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) { .output() .await; - let _ = app.emit( + crate::web::event_bridge::emit_event( + app, "folder://merge-aborted", serde_json::json!({ "folder_id": folder_id }), ); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06f538b..9badf38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod network; mod parsers; mod process; mod terminal; +mod web; use std::sync::atomic::{AtomicBool, Ordering}; @@ -46,6 +47,8 @@ pub fn run() { .manage(windows::SettingsWindowState::new()) .manage(windows::CommitWindowState::new()) .manage(windows::MergeWindowState::new()) + .manage(web::WebServerState::new()) + .manage(web::event_bridge::WebEventBroadcaster::new()) .setup(|app| { let app_data_dir = app.path().app_data_dir()?; let app_version = env!("CARGO_PKG_VERSION"); @@ -322,12 +325,19 @@ pub fn run() { mcp_commands::mcp_set_server_apps, mcp_commands::mcp_remove_server, notification::send_notification, + web::start_web_server, + web::stop_web_server, + web::get_web_server_status, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { if let tauri::RunEvent::ExitRequested { .. } = event { APP_QUITTING.store(true, Ordering::Relaxed); + // Stop the embedded web server if running. + if let Some(ws) = app.try_state::() { + let _ = tauri::async_runtime::block_on(web::stop_web_server(ws)); + } // Kill all terminal sessions to prevent orphaned processes. if let Some(tm) = app.try_state::() { tm.kill_all(); diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 08e6821..4efce53 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -6,7 +6,6 @@ use std::sync::mpsc; use std::sync::{Arc, Mutex}; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use tauri::Emitter; use super::error::TerminalError; use super::types::{TerminalEvent, TerminalInfo}; @@ -408,7 +407,7 @@ fn read_loop( terminal_id: terminal_id.clone(), data, }; - let _ = app_handle.emit(&output_event, &event); + crate::web::event_bridge::emit_event(app_handle, &output_event, event.clone()); } Err(_) => break, } @@ -428,5 +427,5 @@ fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) { terminal_id: terminal_id.to_string(), data: String::new(), }; - let _ = app_handle.emit(&exit_event, &event); + crate::web::event_bridge::emit_event(app_handle, &exit_event, event.clone()); } diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs new file mode 100644 index 0000000..89dc030 --- /dev/null +++ b/src-tauri/src/web/auth.rs @@ -0,0 +1,33 @@ +use axum::{ + extract::Request, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; + +#[derive(Clone)] +pub struct AuthToken(pub String); + +pub async fn require_token( + request: Request, + next: Next, + token: String, +) -> Response { + // Allow WebSocket upgrade requests to authenticate via query param + if let Some(query) = request.uri().query() { + if query.contains(&format!("token={}", token)) { + return next.run(request).await; + } + } + + // Check Authorization header + if let Some(auth_header) = request.headers().get("authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if auth_str.strip_prefix("Bearer ").map_or(false, |t| t == token) { + return next.run(request).await; + } + } + } + + (StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response() +} diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs new file mode 100644 index 0000000..a538096 --- /dev/null +++ b/src-tauri/src/web/event_bridge.rs @@ -0,0 +1,52 @@ +use serde::Serialize; +use tokio::sync::broadcast; + +#[derive(Clone, Debug, Serialize)] +pub struct WebEvent { + pub channel: String, + pub payload: serde_json::Value, +} + +pub struct WebEventBroadcaster { + sender: broadcast::Sender, +} + +impl WebEventBroadcaster { + pub fn new() -> Self { + let (sender, _) = broadcast::channel(4096); + Self { sender } + } + + pub fn send(&self, channel: &str, payload: &impl Serialize) { + if self.sender.receiver_count() == 0 { + return; + } + if let Ok(value) = serde_json::to_value(payload) { + let _ = self.sender.send(WebEvent { + channel: channel.to_string(), + payload: value, + }); + } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + pub fn has_subscribers(&self) -> bool { + self.sender.receiver_count() > 0 + } +} + +/// Unified event emission: sends to both Tauri webview and Web clients. +pub fn emit_event( + app: &tauri::AppHandle, + event: &str, + payload: impl Serialize + Clone, +) { + use tauri::{Emitter, Manager}; + let _ = app.emit(event, payload.clone()); + if let Some(web) = app.try_state::() { + web.send(event, &payload); + } +} diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs new file mode 100644 index 0000000..5488bd3 --- /dev/null +++ b/src-tauri/src/web/handlers/acp.rs @@ -0,0 +1,3 @@ +// ACP (Agent Communication Protocol) web handlers. +// TODO: Implement ACP handlers for web mode. +// These require special handling for connection lifecycle and streaming events. diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs new file mode 100644 index 0000000..cbeca80 --- /dev/null +++ b/src-tauri/src/web/handlers/conversations.rs @@ -0,0 +1,264 @@ +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use tauri::Manager; + +use crate::app_error::AppCommandError; +use crate::db::service::{conversation_service, folder_service, import_service}; +use crate::db::AppDatabase; +use crate::models::*; +use crate::parsers::claude::ClaudeParser; +use crate::parsers::codex::CodexParser; +use crate::parsers::gemini::GeminiParser; +use crate::parsers::openclaw::OpenClawParser; +use crate::parsers::opencode::OpenCodeParser; +use crate::parsers::AgentParser; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFolderConversationsParams { + pub folder_id: i32, + pub agent_type: Option, + pub search: Option, + pub sort_by: Option, + pub status: Option, +} + +pub async fn list_folder_conversations( + Extension(app): Extension, + Json(params): Json, +) -> Result>, AppCommandError> { + let db = app.state::(); + let result = conversation_service::list_by_folder( + &db.conn, + params.folder_id, + params.agent_type, + params.search, + params.sort_by, + params.status, + ) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListConversationsParams { + pub agent_type: Option, + pub search: Option, + pub sort_by: Option, + pub folder_path: Option, +} + +pub async fn list_conversations( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = crate::commands::conversations::list_conversations_for_web( + params.agent_type, + params.search, + params.sort_by, + params.folder_path, + ) + .await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetConversationParams { + pub agent_type: AgentType, + pub conversation_id: String, +} + +pub async fn get_conversation( + Json(params): Json, +) -> Result, AppCommandError> { + let at = params.agent_type; + let cid = params.conversation_id; + let result = tokio::task::spawn_blocking(move || -> Result { + let parser: Box = match at { + AgentType::ClaudeCode => Box::new(ClaudeParser::new()), + AgentType::Codex => Box::new(CodexParser::new()), + AgentType::OpenCode => Box::new(OpenCodeParser::new()), + AgentType::Gemini => Box::new(GeminiParser::new()), + AgentType::OpenClaw => Box::new(OpenClawParser::new()), + }; + parser + .get_conversation(&cid) + .map_err(|e| AppCommandError::not_found("Conversation not found").with_detail(e.to_string())) + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to load conversation") + .with_detail(e.to_string()) + })??; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetFolderConversationParams { + pub conversation_id: i32, +} + +pub async fn get_folder_conversation( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let summary = conversation_service::get_by_id(&db.conn, params.conversation_id) + .await + .map_err(AppCommandError::from)?; + + let (turns, session_stats, _resolved_ext_id) = if let Some(ref ext_id) = summary.external_id { + let at = summary.agent_type; + let eid = ext_id.clone(); + tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> { + let parser: Box = match at { + AgentType::ClaudeCode => Box::new(ClaudeParser::new()), + AgentType::Codex => Box::new(CodexParser::new()), + AgentType::OpenCode => Box::new(OpenCodeParser::new()), + AgentType::Gemini => Box::new(GeminiParser::new()), + AgentType::OpenClaw => Box::new(OpenClawParser::new()), + }; + match parser.get_conversation(&eid) { + Ok(d) => Ok((d.turns, d.session_stats, None::)), + Err(_) => Ok((vec![], None, None)), + } + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Failed to read conversation turns") + .with_detail(e.to_string()) + })?? + } else { + (vec![], None, None) + }; + + let mut summary = summary; + summary.message_count = turns.len() as u32; + + Ok(Json(DbConversationDetail { + summary, + turns, + session_stats, + })) +} + +pub async fn list_folders() -> Result>, AppCommandError> { + let result = crate::commands::conversations::list_folders_for_web().await?; + Ok(Json(result)) +} + +pub async fn get_stats() -> Result, AppCommandError> { + let result = crate::commands::conversations::get_stats_for_web().await?; + Ok(Json(result)) +} + +pub async fn get_sidebar_data() -> Result, AppCommandError> { + let result = crate::commands::conversations::get_sidebar_data_for_web().await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportLocalConversationsParams { + pub folder_id: i32, +} + +pub async fn import_local_conversations( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found("Folder not found"))?; + let result = import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateConversationParams { + pub folder_id: i32, + pub agent_type: AgentType, + pub title: Option, +} + +pub async fn create_conversation( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let model = conversation_service::create( + &db.conn, + params.folder_id, + params.agent_type, + params.title, + None, + ) + .await + .map_err(AppCommandError::from)?; + Ok(Json(model.id)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateConversationStatusParams { + pub conversation_id: i32, + pub status: String, +} + +pub async fn update_conversation_status( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let status_enum: crate::db::entities::conversation::ConversationStatus = + serde_json::from_value(serde_json::Value::String(params.status)).map_err(|e| { + AppCommandError::invalid_input("Invalid conversation status").with_detail(e.to_string()) + })?; + conversation_service::update_status(&db.conn, params.conversation_id, status_enum) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateConversationTitleParams { + pub conversation_id: i32, + pub title: String, +} + +pub async fn update_conversation_title( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + conversation_service::update_title(&db.conn, params.conversation_id, params.title) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteConversationParams { + pub conversation_id: i32, +} + +pub async fn delete_conversation( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + conversation_service::soft_delete(&db.conn, params.conversation_id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/error.rs b/src-tauri/src/web/handlers/error.rs new file mode 100644 index 0000000..a6436b1 --- /dev/null +++ b/src-tauri/src/web/handlers/error.rs @@ -0,0 +1,29 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +impl IntoResponse for AppCommandError { + fn into_response(self) -> Response { + let status = match self.code { + AppErrorCode::InvalidInput => StatusCode::BAD_REQUEST, + AppErrorCode::NotFound => StatusCode::NOT_FOUND, + AppErrorCode::AlreadyExists => StatusCode::CONFLICT, + AppErrorCode::PermissionDenied => StatusCode::FORBIDDEN, + AppErrorCode::AuthenticationFailed => StatusCode::UNAUTHORIZED, + AppErrorCode::ConfigurationMissing + | AppErrorCode::ConfigurationInvalid + | AppErrorCode::DependencyMissing => StatusCode::UNPROCESSABLE_ENTITY, + AppErrorCode::NetworkError + | AppErrorCode::DatabaseError + | AppErrorCode::IoError + | AppErrorCode::ExternalCommandFailed + | AppErrorCode::WindowOperationFailed + | AppErrorCode::TaskExecutionFailed => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(self)).into_response() + } +} diff --git a/src-tauri/src/web/handlers/folder_commands.rs b/src-tauri/src/web/handlers/folder_commands.rs new file mode 100644 index 0000000..eded33e --- /dev/null +++ b/src-tauri/src/web/handlers/folder_commands.rs @@ -0,0 +1,2 @@ +// Folder commands web handlers. +// TODO: Implement folder command CRUD handlers for web mode. diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs new file mode 100644 index 0000000..a7d43ad --- /dev/null +++ b/src-tauri/src/web/handlers/folders.rs @@ -0,0 +1,58 @@ +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use tauri::Manager; + +use crate::app_error::AppCommandError; +use crate::db::service::folder_service; +use crate::db::AppDatabase; +use crate::models::*; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderIdParams { + pub folder_id: i32, +} + +pub async fn load_folder_history( + Extension(app): Extension, +) -> Result>, AppCommandError> { + let db = app.state::(); + let result = folder_service::list_folders(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn get_folder( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found("Folder not found"))?; + Ok(Json(folder)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddFolderParams { + pub path: String, +} + +/// Web equivalent of `open_folder_window`: adds the folder to DB and returns its ID. +/// The web client then navigates to `/folder?id=N` itself. +pub async fn open_folder_window( + Extension(app): Extension, + Json(params): Json, +) -> Result, AppCommandError> { + let db = app.state::(); + let entry = folder_service::add_folder(&db.conn, ¶ms.path) + .await + .map_err(AppCommandError::from)?; + Ok(Json(entry)) +} + +// TODO: Add remaining folder handlers (git operations, file operations, etc.) +// These will be added incrementally as needed. diff --git a/src-tauri/src/web/handlers/mcp.rs b/src-tauri/src/web/handlers/mcp.rs new file mode 100644 index 0000000..bd65434 --- /dev/null +++ b/src-tauri/src/web/handlers/mcp.rs @@ -0,0 +1,2 @@ +// MCP (Model Context Protocol) web handlers. +// TODO: Implement MCP marketplace and server management handlers for web mode. diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs new file mode 100644 index 0000000..19d7a2f --- /dev/null +++ b/src-tauri/src/web/handlers/mod.rs @@ -0,0 +1,9 @@ +mod error; +pub mod conversations; +pub mod folders; +pub mod acp; +pub mod terminal; +pub mod system_settings; +pub mod version_control; +pub mod folder_commands; +pub mod mcp; diff --git a/src-tauri/src/web/handlers/system_settings.rs b/src-tauri/src/web/handlers/system_settings.rs new file mode 100644 index 0000000..dc0d317 --- /dev/null +++ b/src-tauri/src/web/handlers/system_settings.rs @@ -0,0 +1,38 @@ +use axum::{extract::Extension, Json}; +use tauri::Manager; + +use crate::app_error::AppCommandError; +use crate::db::service::app_metadata_service; +use crate::db::AppDatabase; +use crate::models::*; + +const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings"; +const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings"; + +pub async fn get_system_proxy_settings( + Extension(app): Extension, +) -> Result, AppCommandError> { + let db = app.state::(); + let raw = app_metadata_service::get_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY) + .await + .map_err(AppCommandError::from)?; + + let settings = raw + .and_then(|v| serde_json::from_str::(&v).ok()) + .unwrap_or_default(); + Ok(Json(settings)) +} + +pub async fn get_system_language_settings( + Extension(app): Extension, +) -> Result, AppCommandError> { + let db = app.state::(); + let raw = app_metadata_service::get_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY) + .await + .map_err(AppCommandError::from)?; + + let settings = raw + .and_then(|v| serde_json::from_str::(&v).ok()) + .unwrap_or_default(); + Ok(Json(settings)) +} diff --git a/src-tauri/src/web/handlers/terminal.rs b/src-tauri/src/web/handlers/terminal.rs new file mode 100644 index 0000000..cb82ac2 --- /dev/null +++ b/src-tauri/src/web/handlers/terminal.rs @@ -0,0 +1,3 @@ +// Terminal web handlers. +// TODO: Implement terminal handlers for web mode. +// Terminal I/O streams over WebSocket instead of Tauri events. diff --git a/src-tauri/src/web/handlers/version_control.rs b/src-tauri/src/web/handlers/version_control.rs new file mode 100644 index 0000000..f64fe32 --- /dev/null +++ b/src-tauri/src/web/handlers/version_control.rs @@ -0,0 +1,2 @@ +// Version control web handlers. +// TODO: Implement git settings and GitHub account handlers for web mode. diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs new file mode 100644 index 0000000..357a6f9 --- /dev/null +++ b/src-tauri/src/web/mod.rs @@ -0,0 +1,189 @@ +pub mod auth; +pub mod event_bridge; +pub mod handlers; +pub mod router; +pub mod ws; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::Manager; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +pub struct WebServerState { + handle: Mutex>>, + port: AtomicU16, + token: Mutex, + running: std::sync::atomic::AtomicBool, +} + +impl WebServerState { + pub fn new() -> Self { + Self { + handle: Mutex::new(None), + port: AtomicU16::new(0), + token: Mutex::new(String::new()), + running: std::sync::atomic::AtomicBool::new(false), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebServerInfo { + pub port: u16, + pub token: String, + pub addresses: Vec, +} + +fn generate_random_token() -> String { + uuid::Uuid::new_v4().to_string().replace('-', "") +} + +fn find_static_dir(app: &tauri::AppHandle) -> PathBuf { + // 1. Production: Tauri bundles frontendDist into the resource directory. + let resource = app.path().resource_dir().ok(); + if let Some(ref dir) = resource { + // In production builds, the HTML files are at the resource root. + if dir.join("index.html").exists() { + eprintln!("[WEB] Serving static files from resource dir: {}", dir.display()); + return dir.clone(); + } + // Or possibly in an "out" subdirectory. + let out = dir.join("out"); + if out.join("index.html").exists() { + eprintln!("[WEB] Serving static files from resource/out: {}", out.display()); + return out; + } + } + + // 2. Dev mode: "out/" is at the project root, which is one level above src-tauri/. + // The Cargo manifest dir at compile time gives us the src-tauri/ path. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let project_out = manifest_dir.parent().map(|p| p.join("out")); + if let Some(ref out) = project_out { + if out.join("index.html").exists() { + eprintln!("[WEB] Serving static files from project out/: {}", out.display()); + return out.clone(); + } + } + + // 3. Fallback: current working directory / out + let cwd_out = std::env::current_dir() + .map(|d| d.join("out")) + .unwrap_or_else(|_| PathBuf::from("out")); + eprintln!( + "[WEB] Fallback static dir (may not exist): {}", + cwd_out.display() + ); + cwd_out +} + +fn get_local_addresses(port: u16) -> Vec { + let mut addrs = vec![format!("http://127.0.0.1:{}", port)]; + // Try to get LAN IPs + if let Ok(interfaces) = std::net::UdpSocket::bind("0.0.0.0:0") { + // Connect to a public DNS to determine local IP + if interfaces.connect("8.8.8.8:80").is_ok() { + if let Ok(local_addr) = interfaces.local_addr() { + addrs.push(format!("http://{}:{}", local_addr.ip(), port)); + } + } + } + addrs +} + +#[tauri::command] +pub async fn start_web_server( + app: tauri::AppHandle, + state: tauri::State<'_, WebServerState>, + port: Option, + host: Option, +) -> Result { + // Check if already running + if state.running.load(Ordering::Relaxed) { + return Err(AppCommandError::new( + AppErrorCode::AlreadyExists, + "Web server is already running", + )); + } + + let port = port.unwrap_or(3080); + let host = host.unwrap_or_else(|| "0.0.0.0".to_string()); + let token = generate_random_token(); + + // Determine static directory for serving the frontend. + // In production: files are bundled into the resource directory. + // In dev: the "out/" directory is at the project root (one level above src-tauri/). + let static_dir = find_static_dir(&app); + + let router = router::build_router(app.clone(), token.clone(), static_dir); + + let addr: SocketAddr = format!("{}:{}", host, port) + .parse() + .map_err(|e: std::net::AddrParseError| { + AppCommandError::invalid_input("Invalid host/port").with_detail(e.to_string()) + })?; + + let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { + AppCommandError::io_error("Failed to bind address").with_detail(e.to_string()) + })?; + + let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port); + eprintln!("[WEB] Starting web server on {}", addr); + + let handle = tauri::async_runtime::spawn(async move { + if let Err(e) = axum::serve(listener, router).await { + eprintln!("[WEB] Server error: {}", e); + } + }); + + // Store state + *state.handle.lock().unwrap() = Some(handle); + state.port.store(actual_port, Ordering::Relaxed); + *state.token.lock().unwrap() = token.clone(); + state.running.store(true, Ordering::Relaxed); + + let addresses = get_local_addresses(actual_port); + + Ok(WebServerInfo { + port: actual_port, + token, + addresses, + }) +} + +#[tauri::command] +pub async fn stop_web_server( + state: tauri::State<'_, WebServerState>, +) -> Result<(), AppCommandError> { + if let Some(handle) = state.handle.lock().unwrap().take() { + handle.abort(); + } + state.running.store(false, Ordering::Relaxed); + state.port.store(0, Ordering::Relaxed); + *state.token.lock().unwrap() = String::new(); + eprintln!("[WEB] Web server stopped"); + Ok(()) +} + +#[tauri::command] +pub async fn get_web_server_status( + state: tauri::State<'_, WebServerState>, +) -> Result, AppCommandError> { + if !state.running.load(Ordering::Relaxed) { + return Ok(None); + } + let port = state.port.load(Ordering::Relaxed); + let token = state.token.lock().unwrap().clone(); + let addresses = get_local_addresses(port); + Ok(Some(WebServerInfo { + port, + token, + addresses, + })) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs new file mode 100644 index 0000000..9ed7d79 --- /dev/null +++ b/src-tauri/src/web/router.rs @@ -0,0 +1,116 @@ +use axum::{ + extract::Extension, + http::{StatusCode, Uri}, + middleware::{self, Next}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; + +use super::{auth, handlers, ws}; + +pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path::PathBuf) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let token_for_ws = token.clone(); + + let api = Router::new() + // Health check (lightweight, used for token validation) + .route("/health", post(health_check)) + // Conversations + .route("/list_conversations", post(handlers::conversations::list_conversations)) + .route("/get_conversation", post(handlers::conversations::get_conversation)) + .route("/list_folder_conversations", post(handlers::conversations::list_folder_conversations)) + .route("/get_folder_conversation", post(handlers::conversations::get_folder_conversation)) + .route("/import_local_conversations", post(handlers::conversations::import_local_conversations)) + .route("/list_folders", post(handlers::conversations::list_folders)) + .route("/get_stats", post(handlers::conversations::get_stats)) + .route("/get_sidebar_data", post(handlers::conversations::get_sidebar_data)) + .route("/create_conversation", post(handlers::conversations::create_conversation)) + .route("/update_conversation_status", post(handlers::conversations::update_conversation_status)) + .route("/update_conversation_title", post(handlers::conversations::update_conversation_title)) + .route("/delete_conversation", post(handlers::conversations::delete_conversation)) + // Folders + .route("/load_folder_history", post(handlers::folders::load_folder_history)) + .route("/get_folder", post(handlers::folders::get_folder)) + .route("/open_folder_window", post(handlers::folders::open_folder_window)) + // System settings + .route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings)) + .route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings)) + // Catch-all: return proper JSON 404 for unimplemented API endpoints + .fallback(api_not_found) + // Auth middleware for API routes + .layer(middleware::from_fn(move |req, next| { + auth::require_token(req, next, token.clone()) + })); + + // WebSocket route (auth via query param) + let ws_route = Router::new() + .route("/ws/events", get(ws::ws_handler)) + .layer(middleware::from_fn(move |req, next| { + auth::require_token(req, next, token_for_ws.clone()) + })); + + // Static file serving. + // Next.js static export produces "folder.html" for "/folder" route. + // We use a middleware to rewrite "/folder" → "/folder.html" before ServeDir. + let fallback = ServeDir::new(&static_dir) + .fallback(ServeFile::new(static_dir.join("index.html"))); + + let static_dir_for_mw = static_dir.clone(); + let html_rewrite = middleware::from_fn(move |req: axum::extract::Request, next: Next| { + let dir = static_dir_for_mw.clone(); + async move { + let path = req.uri().path(); + // If path has no extension (not a file) and a .html version exists, rewrite + if path != "/" && !path.contains('.') && !path.starts_with("/api") && !path.starts_with("/ws") { + let html_path = format!("{}.html", path.trim_end_matches('/')); + let html_file = dir.join(html_path.trim_start_matches('/')); + if html_file.exists() { + // Rebuild URI with .html suffix preserving query string + let new_path = if let Some(q) = req.uri().query() { + format!("{}?{}", html_path, q) + } else { + html_path + }; + if let Ok(new_uri) = new_path.parse::() { + let (mut parts, body) = req.into_parts(); + parts.uri = new_uri; + let req = axum::extract::Request::from_parts(parts, body); + return next.run(req).await; + } + } + } + next.run(req).await + } + }); + + Router::new() + .nest("/api", api) + .merge(ws_route) + .fallback_service(fallback) + .layer(html_rewrite) + .layer(cors) + .layer(Extension(app)) +} + +async fn health_check() -> impl IntoResponse { + Json(serde_json::json!({ "status": "ok" })) +} + +async fn api_not_found(uri: axum::http::Uri) -> impl IntoResponse { + let command = uri.path().trim_start_matches('/'); + eprintln!("[WEB] Unimplemented API endpoint: {}", command); + ( + StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({ + "code": "not_implemented", + "message": format!("API endpoint '{}' is not available in web mode", command), + })), + ) +} diff --git a/src-tauri/src/web/ws.rs b/src-tauri/src/web/ws.rs new file mode 100644 index 0000000..817953e --- /dev/null +++ b/src-tauri/src/web/ws.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{Extension, WebSocketUpgrade}, + response::IntoResponse, +}; +use axum::extract::ws::{Message, WebSocket}; +use tauri::Manager; + +use super::event_bridge::WebEventBroadcaster; + +pub async fn ws_handler( + ws: WebSocketUpgrade, + Extension(app): Extension, +) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_ws_connection(socket, app)) +} + +async fn handle_ws_connection(mut socket: WebSocket, app: tauri::AppHandle) { + let broadcaster = app.state::(); + let mut rx = broadcaster.subscribe(); + + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(event) => { + if let Ok(msg) = serde_json::to_string(&event) { + if socket.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!("[WS] receiver lagged, skipped {n} events"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + msg = socket.recv() => { + match msg { + Some(Ok(_)) => { + // Client messages currently unused; reserved for future use + } + _ => break, + } + } + } + } +} diff --git a/src/app/commit/page.tsx b/src/app/commit/page.tsx index e31e0cb..6b0bac2 100644 --- a/src/app/commit/page.tsx +++ b/src/app/commit/page.tsx @@ -3,12 +3,12 @@ import { Suspense, useCallback, useEffect, useState } from "react" import { useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" -import { getCurrentWindow } from "@tauri-apps/api/window" +const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() } import { Loader2 } from "lucide-react" import { CommitWorkspace } from "@/components/layout/commit-dialog" import { AppTitleBar } from "@/components/layout/app-title-bar" import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/tauri" +import { getFolder } from "@/lib/api" import type { FolderDetail } from "@/lib/types" const TOAST_DURATION_MS = 6000 @@ -35,12 +35,13 @@ function CommitPageInner() { const folder = state.loadedId === normalizedFolderId ? state.folder : null const error = state.loadedId === normalizedFolderId ? state.error : null - const closeWindow = useCallback(() => { - getCurrentWindow() - .close() - .catch((err) => { - console.error("[CommitPage] failed to close window:", err) - }) + const closeWindow = useCallback(async () => { + try { + const win = await getCurrentWindow() + await win.close() + } catch (err) { + console.error("[CommitPage] failed to close window:", err) + } }, []) useEffect(() => { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..e4a8e91 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,91 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { isDesktop } from "@/lib/platform" + +export default function LoginPage() { + const router = useRouter() + const [token, setToken] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + // Desktop users skip login entirely + if (isDesktop()) { + router.replace("/welcome") + return null + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError("") + setLoading(true) + + try { + // Validate token by calling a lightweight API endpoint + const res = await fetch("/api/health", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: "{}", + }) + + if (res.ok) { + localStorage.setItem("codeg_token", token) + router.replace("/welcome") + } else if (res.status === 401) { + setError("Token 无效,请检查后重试") + } else { + setError(`连接失败 (HTTP ${res.status})`) + } + } catch { + setError("无法连接到服务器") + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

Codeg

+

+ 输入访问 Token 以连接到桌面端 +

+
+ +
+
+ setToken(e.target.value)} + placeholder="Access Token" + autoFocus + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Token 可在桌面端 设置 → Web 服务 中获取 +

+
+
+ ) +} diff --git a/src/app/merge/page.tsx b/src/app/merge/page.tsx index 1d386cb..cd24298 100644 --- a/src/app/merge/page.tsx +++ b/src/app/merge/page.tsx @@ -3,12 +3,12 @@ import { Suspense, useCallback, useEffect, useState } from "react" import { useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" -import { getCurrentWindow } from "@tauri-apps/api/window" +const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() } import { Loader2 } from "lucide-react" import { MergeWorkspace } from "@/components/merge/merge-workspace" import { AppTitleBar } from "@/components/layout/app-title-bar" import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/tauri" +import { getFolder } from "@/lib/api" import type { FolderDetail } from "@/lib/types" const TOAST_DURATION_MS = 6000 @@ -37,12 +37,13 @@ function MergePageInner() { const folder = state.loadedId === normalizedFolderId ? state.folder : null const error = state.loadedId === normalizedFolderId ? state.error : null - const closeWindow = useCallback(() => { - getCurrentWindow() - .close() - .catch((err) => { - console.error("[MergePage] failed to close window:", err) - }) + const closeWindow = useCallback(async () => { + try { + const win = await getCurrentWindow() + await win.close() + } catch (err) { + console.error("[MergePage] failed to close window:", err) + } }, []) useEffect(() => { diff --git a/src/app/page.tsx b/src/app/page.tsx index d6e36b5..95e775e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,11 +2,43 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" +import { isDesktop } from "@/lib/platform" export default function Page() { const router = useRouter() useEffect(() => { - router.replace("/welcome") + if (isDesktop()) { + router.replace("/welcome") + return + } + // Web mode: validate token before entering app + const token = localStorage.getItem("codeg_token") + if (!token) { + router.replace("/login") + return + } + // Verify token is still valid + fetch("/api/health", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: "{}", + }) + .then((res) => { + if (res.ok) { + router.replace("/welcome") + } else { + localStorage.removeItem("codeg_token") + router.replace("/login") + } + }) + .catch(() => { + // Server unreachable + localStorage.removeItem("codeg_token") + router.replace("/login") + }) }, [router]) return null } diff --git a/src/app/push/page.tsx b/src/app/push/page.tsx index a47183a..7ccfd50 100644 --- a/src/app/push/page.tsx +++ b/src/app/push/page.tsx @@ -3,12 +3,12 @@ import { Suspense, useCallback, useEffect, useState } from "react" import { useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" -import { getCurrentWindow } from "@tauri-apps/api/window" +const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() } import { Loader2 } from "lucide-react" import { PushWorkspace } from "@/components/layout/push-workspace" import { AppTitleBar } from "@/components/layout/app-title-bar" import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/tauri" +import { getFolder } from "@/lib/api" import type { FolderDetail } from "@/lib/types" const TOAST_DURATION_MS = 6000 @@ -28,12 +28,13 @@ function PushPageInner() { error: null, }) - const closeWindow = useCallback(() => { - getCurrentWindow() - .close() - .catch((err) => { - console.error("[PushPage] failed to close window:", err) - }) + const closeWindow = useCallback(async () => { + try { + const win = await getCurrentWindow() + await win.close() + } catch (err) { + console.error("[PushPage] failed to close window:", err) + } }, []) const folderId = Number(searchParams.get("folderId") ?? "0") diff --git a/src/app/settings/web-service/page.tsx b/src/app/settings/web-service/page.tsx new file mode 100644 index 0000000..dddcc54 --- /dev/null +++ b/src/app/settings/web-service/page.tsx @@ -0,0 +1,5 @@ +import { WebServiceSettings } from "@/components/settings/web-service-settings" + +export default function SettingsWebServicePage() { + return +} diff --git a/src/app/stash/page.tsx b/src/app/stash/page.tsx index 08f1aaf..6293424 100644 --- a/src/app/stash/page.tsx +++ b/src/app/stash/page.tsx @@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react" import { StashWorkspace } from "@/components/layout/unstash-dialog" import { AppTitleBar } from "@/components/layout/app-title-bar" import { AppToaster } from "@/components/ui/app-toaster" -import { getFolder } from "@/lib/tauri" +import { getFolder } from "@/lib/api" import type { FolderDetail } from "@/lib/types" const TOAST_DURATION_MS = 6000 diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 1c1f7ea..02a10c3 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useMemo, useState } from "react" -import { openUrl } from "@tauri-apps/plugin-opener" +import { openUrl } from "@/lib/platform" import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" diff --git a/src/components/chat/agent-selector.tsx b/src/components/chat/agent-selector.tsx index ea32e8f..20d6923 100644 --- a/src/components/chat/agent-selector.tsx +++ b/src/components/chat/agent-selector.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useTranslations } from "next-intl" -import { acpListAgents } from "@/lib/tauri" +import { acpListAgents } from "@/lib/api" import { disposeTauriListener } from "@/lib/tauri-listener" import type { AgentType, AcpAgentInfo } from "@/lib/types" import { AGENT_LABELS } from "@/lib/types" diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 80637e9..2b83639 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1,9 +1,7 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { TauriEvent } from "@tauri-apps/api/event" -import { getCurrentWebview } from "@tauri-apps/api/webview" -import { open } from "@tauri-apps/plugin-dialog" +import { isDesktop } from "@/lib/platform" import Image from "next/image" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" @@ -34,7 +32,8 @@ import { import { cn } from "@/lib/utils" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { readFileBase64 } from "@/lib/tauri" +import { readFileBase64 } from "@/lib/api" +import { openFileDialog } from "@/lib/platform" import { disposeTauriListener } from "@/lib/tauri-listener" import type { AvailableCommandInfo, @@ -751,10 +750,9 @@ export function MessageInput({ const handlePickFiles = useCallback(async () => { if (disabled) return try { - const selected = await open({ + const selected = await openFileDialog({ multiple: true, directory: false, - defaultPath: defaultPath || undefined, }) if (!selected) return const picked = Array.isArray(selected) ? selected : [selected] @@ -846,6 +844,9 @@ export function MessageInput({ } const setup = async () => { + if (!isDesktop()) return + const { getCurrentWebview } = await import("@tauri-apps/api/webview") + const { TauriEvent } = await import("@tauri-apps/api/event") const webview = getCurrentWebview() try { const unlistenEnter = await webview.listen<{ diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 4c0056d..d06cce9 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -31,7 +31,7 @@ import { updateConversationExternalId, updateConversationStatus, updateConversationTitle, -} from "@/lib/tauri" +} from "@/lib/api" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationDetail } from "@/hooks/use-conversation-detail" import { diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index e3a01e5..3a3a40f 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -14,7 +14,7 @@ import { getFileTree, listFolderConversations, readFilePreview, -} from "@/lib/tauri" +} from "@/lib/api" import type { AgentType, ConversationStatus, diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 055c787..873fafb 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -22,7 +22,7 @@ import { updateConversationTitle, updateConversationStatus, deleteConversation, -} from "@/lib/tauri" +} from "@/lib/api" import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types" import { SidebarConversationCard } from "./sidebar-conversation-card" diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 2609f61..768112c 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -21,7 +21,7 @@ import { code } from "@streamdown/code" import { math } from "@streamdown/math" import { mermaid } from "@streamdown/mermaid" import { Streamdown } from "streamdown" -import { readFileBase64 } from "@/lib/tauri" +import { readFileBase64 } from "@/lib/api" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import "@/lib/monaco-local" diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index f9d658b..73ac1a5 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import { Reorder } from "motion/react" import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react" import { useTranslations } from "next-intl" -import { openPath } from "@tauri-apps/plugin-opener" +import { openPath } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" diff --git a/src/components/i18n-provider.tsx b/src/components/i18n-provider.tsx index a521fcb..c6dab87 100644 --- a/src/components/i18n-provider.tsx +++ b/src/components/i18n-provider.tsx @@ -22,7 +22,7 @@ import { toIntlLocale, type IntlLocale, } from "@/lib/i18n" -import { getSystemLanguageSettings } from "@/lib/tauri" +import { getSystemLanguageSettings } from "@/lib/api" import { disposeTauriListener } from "@/lib/tauri-listener" import { AppBootLoading } from "@/components/layout/app-boot-loading" import type { AppLocale, SystemLanguageSettings } from "@/lib/types" diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 7ced47f..042d051 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -8,8 +8,7 @@ import { useState, type ReactNode, } from "react" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" -import { revealItemInDir } from "@tauri-apps/plugin-opener" +import { revealItemInDir, subscribe } from "@/lib/platform" import ignore from "ignore" import { Check, ChevronRight } from "lucide-react" import { useTranslations } from "next-intl" @@ -36,8 +35,7 @@ import { saveFileCopy, startFileTreeWatch, stopFileTreeWatch, -} from "@/lib/tauri" -import { disposeTauriListener } from "@/lib/tauri-listener" +} from "@/lib/api" import { emitAttachFileToSession, emitAppendTextToSession, @@ -1907,7 +1905,7 @@ export function FileTreeTab() { const rootPath = folder?.path if (!rootPath) return - let unlisten: UnlistenFn | null = null + let unlisten: (() => void) | null = null const normalizedRootPath = normalizeComparePath(rootPath) const scheduleTreeRefresh = (refreshGitStatus: boolean) => { @@ -2046,20 +2044,17 @@ export function FileTreeTab() { } try { - unlisten = await listen( + unlisten = await subscribe( "folder://file-tree-changed", - (event) => { + (payload) => { if ( - normalizeComparePath(event.payload.root_path) !== - normalizedRootPath + normalizeComparePath(payload.root_path) !== normalizedRootPath ) { return } - const changedPaths = - event.payload.changed_paths.map(normalizeComparePath) - const shouldRefreshGitStatus = - event.payload.refresh_git_status ?? true + const changedPaths = payload.changed_paths.map(normalizeComparePath) + const shouldRefreshGitStatus = payload.refresh_git_status ?? true const nonGitChangedPaths = changedPaths.filter( (path) => !isGitMetadataPath(path) ) @@ -2069,13 +2064,13 @@ export function FileTreeTab() { (path) => !filePathSetRef.current.has(path) ) const needsTreeRefresh = - event.payload.full_reload || + payload.full_reload || (!onlyGitMetadataChanges && - (event.payload.kind !== "modify" || + (payload.kind !== "modify" || nonGitChangedPaths.length === 0 || hasUnknownPath)) - if (onlyGitMetadataChanges && !event.payload.full_reload) { + if (onlyGitMetadataChanges && !payload.full_reload) { if (shouldRefreshGitStatus) { scheduleStatusRefresh() } @@ -2085,13 +2080,13 @@ export function FileTreeTab() { scheduleStatusRefresh() } - if (onlyGitMetadataChanges && !event.payload.full_reload) { + if (onlyGitMetadataChanges && !payload.full_reload) { return } const changedActivePath = getActiveChangedFilePath( nonGitChangedPaths, - event.payload.full_reload + payload.full_reload ) if (!changedActivePath) return @@ -2145,7 +2140,7 @@ export function FileTreeTab() { pendingTreeRefreshRef.current = false pendingTreeRefreshNeedsStatusRef.current = false pendingStatusRefreshRef.current = false - disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged") + unlisten?.() void stopFileTreeWatch(rootPath) } }, [fetchTree, folder?.path, openFilePreview, t]) diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index fa5d76a..7dc6ddb 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { subscribe } from "@/lib/platform" import { ChevronsDownUp, ChevronsUpDown } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -46,8 +46,7 @@ import { openCommitWindow, startFileTreeWatch, stopFileTreeWatch, -} from "@/lib/tauri" -import { disposeTauriListener } from "@/lib/tauri-listener" +} from "@/lib/api" import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types" import { AlertDialog, @@ -611,7 +610,7 @@ export function GitChangesTab() { const rootPath = folder?.path if (!rootPath || !isChangesTabActive) return - let unlisten: UnlistenFn | null = null + let unlisten: (() => void) | null = null const normalizedRootPath = normalizeComparePath(rootPath) const scheduleRefresh = () => { @@ -631,16 +630,15 @@ export function GitChangesTab() { } try { - unlisten = await listen( + unlisten = await subscribe( "folder://file-tree-changed", - (event) => { + (payload) => { if ( - normalizeComparePath(event.payload.root_path) !== - normalizedRootPath + normalizeComparePath(payload.root_path) !== normalizedRootPath ) { return } - if (!shouldRefreshFromEvent(event.payload)) return + if (!shouldRefreshFromEvent(payload)) return scheduleRefresh() } ) @@ -656,7 +654,7 @@ export function GitChangesTab() { clearTimeout(refreshTimerRef.current) refreshTimerRef.current = null } - disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged") + unlisten?.() void stopFileTreeWatch(rootPath) } }, [fetchChanges, folder?.path, isChangesTabActive]) diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index 6eddf32..cbcaa9c 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -75,8 +75,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible" import { Skeleton } from "@/components/ui/skeleton" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" -import { disposeTauriListener } from "@/lib/tauri-listener" +import { subscribe } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { @@ -86,7 +85,7 @@ import { gitLog, gitNewBranch, openPushWindow, -} from "@/lib/tauri" +} from "@/lib/api" import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types" import { toast } from "sonner" import { toErrorMessage } from "@/lib/app-error" @@ -874,11 +873,11 @@ export function GitLogTab() { "folder://git-push-succeeded", ] as const - const unlistens: (UnlistenFn | null)[] = events.map(() => null) + const unlistens: ((() => void) | null)[] = events.map(() => null) events.forEach((eventName, i) => { - listen<{ folder_id: number }>(eventName, (event) => { - if (event.payload.folder_id !== folder.id) return + subscribe<{ folder_id: number }>(eventName, (payload) => { + if (payload.folder_id !== folder.id) return void refreshBranches() void fetchLog({ inline: true }) }) @@ -891,8 +890,8 @@ export function GitLogTab() { }) return () => { - events.forEach((eventName, i) => { - disposeTauriListener(unlistens[i], `GitLogTab.${eventName}`) + events.forEach((_eventName, i) => { + unlistens[i]?.() }) } }, [folder, refreshBranches, fetchLog]) diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 347521b..a304622 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -1,7 +1,13 @@ "use client" import { useState, useRef, useCallback, useMemo, useEffect } from "react" -import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event" +const emitEvent = async (event: string, payload?: unknown) => { + try { + const { emit } = await import("@tauri-apps/api/event") + await emit(event, payload) + } catch { /* not in Tauri */ } +} +import { openFileDialog, subscribe } from "@/lib/platform" import { GitBranch, ChevronDown, @@ -62,7 +68,6 @@ import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { open } from "@tauri-apps/plugin-dialog" import { gitInit, gitPull, @@ -79,11 +84,10 @@ import { setFolderParentBranch, openStashWindow, openPushWindow, -} from "@/lib/tauri" +} from "@/lib/api" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" import { ConflictDialog } from "@/components/layout/conflict-dialog" import { StashDialog } from "@/components/layout/stash-dialog" -import { disposeTauriListener } from "@/lib/tauri-listener" import { toErrorMessage } from "@/lib/app-error" import type { GitBranchList, GitConflictInfo } from "@/lib/types" import { toast } from "sonner" @@ -167,15 +171,15 @@ export function BranchDropdown({ useEffect(() => { if (!folder) return - let unlisten: UnlistenFn | null = null + let unlisten: (() => void) | null = null - listen( + subscribe( "folder://git-commit-succeeded", - (event) => { - if (event.payload.folder_id !== folder.id) return + (payload) => { + if (payload.folder_id !== folder.id) return toast.success(t("toasts.commitCodeCompleted"), { description: t("toasts.committedFiles", { - count: event.payload.committed_files, + count: payload.committed_files, }), }) onBranchChange() @@ -189,20 +193,20 @@ export function BranchDropdown({ }) return () => { - disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded") + unlisten?.() } }, [folder, onBranchChange, t]) useEffect(() => { if (!folder) return - let unlisten: UnlistenFn | null = null + let unlisten: (() => void) | null = null - listen( + subscribe( "folder://git-push-succeeded", - (event) => { - if (event.payload.folder_id !== folder.id) return - const { pushed_commits, upstream_set } = event.payload + (payload) => { + if (payload.folder_id !== folder.id) return + const { pushed_commits, upstream_set } = payload let description: string if (upstream_set) { description = @@ -226,7 +230,7 @@ export function BranchDropdown({ }) return () => { - disposeTauriListener(unlisten, "BranchDropdown.gitPushSucceeded") + unlisten?.() } }, [folder, onBranchChange, t]) @@ -245,7 +249,7 @@ export function BranchDropdown({ const successDescription = getSuccessDescription?.(result) updateTask(taskId, { status: "completed" }) onBranchChange() - void emit("folder://git-branch-changed", { + void emitEvent("folder://git-branch-changed", { folder_id: folder?.id, }) if (successDescription !== false) { @@ -326,9 +330,11 @@ export function BranchDropdown({ } async function handleBrowseWorktreePath() { - const selected = await open({ directory: true, multiple: false }) + const selected = await openFileDialog({ directory: true, multiple: false }) if (selected) { - setWorktreePath(selected) + setWorktreePath( + Array.isArray(selected) ? selected[0] : selected, + ) } } diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index 8e873e3..f610772 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -1,6 +1,6 @@ "use client" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { subscribe } from "@/lib/platform" import { useState, useEffect, useCallback, useMemo, useRef } from "react" import { ChevronDown, Play, Plus, Square } from "lucide-react" import { useTranslations } from "next-intl" @@ -19,8 +19,7 @@ import { listFolderCommands, terminalKill, terminalList, -} from "@/lib/tauri" -import { disposeTauriListener } from "@/lib/tauri-listener" +} from "@/lib/api" import type { FolderCommand, TerminalEvent } from "@/lib/types" import { CommandManageDialog } from "./command-manage-dialog" @@ -54,7 +53,7 @@ export function CommandDropdown() { const [runningCommandTerminals, setRunningCommandTerminals] = useState< Record >({}) - const exitUnlistenersRef = useRef>(new Map()) + const exitUnlistenersRef = useRef void>>(new Map()) const runningCommandTerminalsRef = useRef>({}) const folderId = folder?.id ?? 0 @@ -67,7 +66,7 @@ export function CommandDropdown() { const clearRunningByTerminalId = useCallback((terminalId: string) => { const unlisten = exitUnlistenersRef.current.get(terminalId) if (unlisten) { - disposeTauriListener(unlisten, "CommandDropdown.terminalExit") + unlisten() exitUnlistenersRef.current.delete(terminalId) } @@ -86,7 +85,7 @@ export function CommandDropdown() { const clearAllRunningStates = useCallback(() => { for (const unlisten of exitUnlistenersRef.current.values()) { - disposeTauriListener(unlisten, "CommandDropdown.terminalExit") + unlisten() } exitUnlistenersRef.current.clear() setRunningCommandTerminals({}) @@ -165,7 +164,7 @@ export function CommandDropdown() { async (terminalId: string) => { if (exitUnlistenersRef.current.has(terminalId)) return try { - const unlisten = await listen( + const unlisten = await subscribe( `terminal://exit/${terminalId}`, () => { clearRunningByTerminalId(terminalId) diff --git a/src/components/layout/command-manage-dialog.tsx b/src/components/layout/command-manage-dialog.tsx index b622d89..9c2b9cb 100644 --- a/src/components/layout/command-manage-dialog.tsx +++ b/src/components/layout/command-manage-dialog.tsx @@ -18,7 +18,7 @@ import { createFolderCommand, updateFolderCommand, deleteFolderCommand, -} from "@/lib/tauri" +} from "@/lib/api" interface CommandDraft { id: number | null diff --git a/src/components/layout/commit-dialog.tsx b/src/components/layout/commit-dialog.tsx index f26c2cb..fa49078 100644 --- a/src/components/layout/commit-dialog.tsx +++ b/src/components/layout/commit-dialog.tsx @@ -41,7 +41,7 @@ import { gitStatus, deleteFileTreeEntry, readFilePreview, -} from "@/lib/tauri" +} from "@/lib/api" import type { GitStatusEntry } from "@/lib/types" import { cn } from "@/lib/utils" import { toast } from "sonner" diff --git a/src/components/layout/conflict-dialog.tsx b/src/components/layout/conflict-dialog.tsx index 47268a3..f6ff086 100644 --- a/src/components/layout/conflict-dialog.tsx +++ b/src/components/layout/conflict-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { subscribe } from "@/lib/platform" import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -20,8 +20,7 @@ import { gitAbortOperation, gitContinueOperation, openMergeWindow, -} from "@/lib/tauri" -import { disposeTauriListener } from "@/lib/tauri-listener" +} from "@/lib/api" import type { GitConflictInfo } from "@/lib/types" interface ConflictDialogProps { @@ -76,15 +75,15 @@ export function ConflictDialog({ useEffect(() => { if (!open) return - let unlistenResolved: UnlistenFn | null = null - let unlistenCompleted: UnlistenFn | null = null - let unlistenAborted: UnlistenFn | null = null + let unlistenResolved: (() => void) | null = null + let unlistenCompleted: (() => void) | null = null + let unlistenAborted: (() => void) | null = null - listen<{ folder_id: number; file: string }>( + subscribe<{ folder_id: number; file: string }>( "folder://merge-conflict-resolved", - (event) => { - if (event.payload.folder_id !== folderId) return - setResolvedFiles((prev) => new Set([...prev, event.payload.file])) + (payload) => { + if (payload.folder_id !== folderId) return + setResolvedFiles((prev) => new Set([...prev, payload.file])) } ) .then((fn) => { @@ -92,8 +91,8 @@ export function ConflictDialog({ }) .catch(() => {}) - listen<{ folder_id: number }>("folder://merge-completed", (event) => { - if (event.payload.folder_id !== folderId) return + subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => { + if (payload.folder_id !== folderId) return setDone(true) onResolved() onClose() @@ -105,8 +104,8 @@ export function ConflictDialog({ // Merge was aborted (user clicked abort in merge window, or window closed) // Reset resolved state since abort reverts all changes - listen<{ folder_id: number }>("folder://merge-aborted", (event) => { - if (event.payload.folder_id !== folderId) return + subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => { + if (payload.folder_id !== folderId) return setDone(true) setResolvedFiles(new Set()) onClose() @@ -117,12 +116,9 @@ export function ConflictDialog({ .catch(() => {}) return () => { - disposeTauriListener( - unlistenResolved, - "ConflictDialog.mergeConflictResolved" - ) - disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted") - disposeTauriListener(unlistenAborted, "ConflictDialog.mergeAborted") + unlistenResolved?.() + unlistenCompleted?.() + unlistenAborted?.() } }, [open, folderId, onResolved, onClose]) diff --git a/src/components/layout/folder-name-dropdown.tsx b/src/components/layout/folder-name-dropdown.tsx index 14abc8e..374a646 100644 --- a/src/components/layout/folder-name-dropdown.tsx +++ b/src/components/layout/folder-name-dropdown.tsx @@ -2,7 +2,6 @@ import { useState } from "react" import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react" -import { open } from "@tauri-apps/plugin-dialog" import { useTranslations } from "next-intl" import { DropdownMenu, @@ -17,7 +16,8 @@ import { listOpenFolders, loadFolderHistory, openFolderWindow, -} from "@/lib/tauri" +} from "@/lib/api" +import { openFileDialog } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { CloneDialog } from "@/components/welcome/clone-dialog" import type { FolderHistoryEntry } from "@/lib/types" @@ -50,9 +50,11 @@ export function FolderNameDropdown() { } async function handleOpenFolder() { - const selected = await open({ directory: true, multiple: false }) + const selected = await openFileDialog({ directory: true, multiple: false }) if (selected) { - await openFolderWindow(selected) + await openFolderWindow( + Array.isArray(selected) ? selected[0] : selected, + ) } } diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 0db292e..73971f9 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -7,7 +7,6 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from "react" -import { open } from "@tauri-apps/plugin-dialog" import { Columns2, FileCode2, @@ -19,7 +18,8 @@ import { Settings, } from "lucide-react" import { useTranslations } from "next-intl" -import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/tauri" +import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api" +import { openFileDialog } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { Button } from "@/components/ui/button" import { useSidebarContext } from "@/contexts/sidebar-context" @@ -79,8 +79,9 @@ export function FolderTitleBar() { const handleOpenFolder = useCallback(async () => { try { - const selected = await open({ directory: true, multiple: false }) - if (!selected) return + const result = await openFileDialog({ directory: true, multiple: false }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result await openFolderWindow(selected) } catch (err) { console.error("[FolderTitleBar] failed to open folder:", err) diff --git a/src/components/layout/push-workspace.tsx b/src/components/layout/push-workspace.tsx index 8263957..734cec7 100644 --- a/src/components/layout/push-workspace.tsx +++ b/src/components/layout/push-workspace.tsx @@ -51,7 +51,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/tauri" +import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" import { languageFromPath } from "@/lib/language-detect" import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types" diff --git a/src/components/layout/remote-manage-dialog.tsx b/src/components/layout/remote-manage-dialog.tsx index 3b0420f..5fda61b 100644 --- a/src/components/layout/remote-manage-dialog.tsx +++ b/src/components/layout/remote-manage-dialog.tsx @@ -19,7 +19,7 @@ import { gitAddRemote, gitRemoveRemote, gitSetRemoteUrl, -} from "@/lib/tauri" +} from "@/lib/api" interface RemoteDraft { originalName: string | null diff --git a/src/components/layout/stash-dialog.tsx b/src/components/layout/stash-dialog.tsx index 7c07045..9f22d13 100644 --- a/src/components/layout/stash-dialog.tsx +++ b/src/components/layout/stash-dialog.tsx @@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" -import { gitStashPush } from "@/lib/tauri" +import { gitStashPush } from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" interface StashDialogProps { diff --git a/src/components/layout/status-bar-alerts.tsx b/src/components/layout/status-bar-alerts.tsx index 94f96f7..6fbd350 100644 --- a/src/components/layout/status-bar-alerts.tsx +++ b/src/components/layout/status-bar-alerts.tsx @@ -13,8 +13,8 @@ import { PopoverTrigger, } from "@/components/ui/popover" import { useAcpActions } from "@/contexts/acp-connections-context" -import { openUrl } from "@tauri-apps/plugin-opener" -import { openSettingsWindow } from "@/lib/tauri" +import { openUrl } from "@/lib/platform" +import { openSettingsWindow } from "@/lib/api" import { AGENT_LABELS, type AgentType } from "@/lib/types" const KNOWN_AGENT_TYPES = new Set( diff --git a/src/components/layout/unstash-dialog.tsx b/src/components/layout/unstash-dialog.tsx index 6464810..419a129 100644 --- a/src/components/layout/unstash-dialog.tsx +++ b/src/components/layout/unstash-dialog.tsx @@ -43,7 +43,7 @@ import { gitStashApply, gitStashDrop, gitShowFile, -} from "@/lib/tauri" +} from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" import { languageFromPath } from "@/lib/language-detect" import type { GitStashEntry, GitStatusEntry } from "@/lib/types" diff --git a/src/components/layout/window-controls.tsx b/src/components/layout/window-controls.tsx index 9778b09..d0fbf52 100644 --- a/src/components/layout/window-controls.tsx +++ b/src/components/layout/window-controls.tsx @@ -1,72 +1,74 @@ "use client" -import { useEffect, useState } from "react" -import { getCurrentWindow } from "@tauri-apps/api/window" +import { useEffect, useRef, useState } from "react" +import { isDesktop } from "@/lib/platform" import { useTranslations } from "next-intl" import { usePlatform } from "@/hooks/use-platform" -import { disposeTauriListener } from "@/lib/tauri-listener" import { cn } from "@/lib/utils" +async function getTauriWindow() { + const { getCurrentWindow } = await import("@tauri-apps/api/window") + return getCurrentWindow() +} + export function WindowControls() { const t = useTranslations("Folder.windowControls") const { isWindows } = usePlatform() const [isMaximized, setIsMaximized] = useState(false) + const appWindowRef = useRef + > | null>(null) useEffect(() => { - if (!isWindows) return + if (!isWindows || !isDesktop()) return let disposed = false let unlistenResize: (() => void) | null = null let resizeFrame: number | null = null - const appWindow = getCurrentWindow() - const syncMaximized = async () => { - try { - const maximized = await appWindow.isMaximized() - if (!disposed) { - setIsMaximized(maximized) - } - } catch { - if (!disposed) { - setIsMaximized(false) + getTauriWindow().then((appWindow) => { + if (disposed) return + appWindowRef.current = appWindow + + const syncMaximized = async () => { + try { + const maximized = await appWindow.isMaximized() + if (!disposed) setIsMaximized(maximized) + } catch { + if (!disposed) setIsMaximized(false) } } - } - const scheduleSync = () => { - if (resizeFrame !== null) return + const scheduleSync = () => { + if (resizeFrame !== null) return + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = null + void syncMaximized() + }) + } - resizeFrame = window.requestAnimationFrame(() => { - resizeFrame = null - void syncMaximized() - }) - } + void syncMaximized() - void syncMaximized() - - appWindow - .onResized(() => { - scheduleSync() - }) - .then((unlisten) => { - unlistenResize = unlisten - }) - .catch(() => { - unlistenResize = null - }) + appWindow + .onResized(() => scheduleSync()) + .then((unlisten) => { + unlistenResize = unlisten + }) + .catch(() => { + unlistenResize = null + }) + }) return () => { disposed = true if (resizeFrame !== null) { window.cancelAnimationFrame(resizeFrame) } - disposeTauriListener(unlistenResize, "WindowControls.resize") + unlistenResize?.() } }, [isWindows]) - if (!isWindows) return null - - const appWindow = getCurrentWindow() + if (!isWindows || !isDesktop()) return null return (
@@ -74,7 +76,7 @@ export function WindowControls() { type="button" className={buttonClass} onClick={() => { - appWindow.minimize().catch((err) => { + appWindowRef.current?.minimize().catch((err: unknown) => { console.error("[WindowControls] failed to minimize:", err) }) }} @@ -87,7 +89,7 @@ export function WindowControls() { type="button" className={buttonClass} onClick={() => { - appWindow.toggleMaximize().catch((err) => { + appWindowRef.current?.toggleMaximize().catch((err: unknown) => { console.error("[WindowControls] failed to toggle maximize:", err) }) }} @@ -103,7 +105,7 @@ export function WindowControls() { "hover:bg-[#e81123] hover:text-white active:bg-[#c50f1f] active:text-white" )} onClick={() => { - appWindow.close().catch((err) => { + appWindowRef.current?.close().catch((err: unknown) => { console.error("[WindowControls] failed to close:", err) }) }} diff --git a/src/components/merge/merge-workspace.tsx b/src/components/merge/merge-workspace.tsx index 2e51c74..2c023a7 100644 --- a/src/components/merge/merge-workspace.tsx +++ b/src/components/merge/merge-workspace.tsx @@ -1,7 +1,12 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { emit } from "@tauri-apps/api/event" +async function emitEvent(event: string, payload?: unknown) { + try { + const { emit } = await import("@tauri-apps/api/event") + await emit(event, payload) + } catch { /* not in Tauri */ } +} import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -19,7 +24,7 @@ import { gitAbortOperation, gitContinueOperation, gitStartPullMerge, -} from "@/lib/tauri" +} from "@/lib/api" import { languageFromPath } from "@/lib/language-detect" import { toErrorMessage } from "@/lib/app-error" import type { GitConflictFileVersions } from "@/lib/types" @@ -121,7 +126,7 @@ export function MergeWorkspace({ setResolvedFiles((prev) => new Set([...prev, selectedFile])) // Notify parent window - await emit("folder://merge-conflict-resolved", { + await emitEvent("folder://merge-conflict-resolved", { folder_id: folderId, file: selectedFile, }) @@ -145,7 +150,7 @@ export function MergeWorkspace({ try { await gitAbortOperation(folderPath, operation) toast.success(t("abortSuccess")) - await emit("folder://merge-aborted", { folder_id: folderId }) + await emitEvent("folder://merge-aborted", { folder_id: folderId }) onAborted() } catch (err) { toast.error(toErrorMessage(err)) @@ -159,7 +164,7 @@ export function MergeWorkspace({ try { await gitContinueOperation(folderPath, operation) toast.success(t("allResolved")) - await emit("folder://merge-completed", { folder_id: folderId }) + await emitEvent("folder://merge-completed", { folder_id: folderId }) onCompleted() } catch (err) { toast.error(toErrorMessage(err)) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 644c4ac..d446011 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -28,7 +28,7 @@ import { Trash2, Wrench, } from "lucide-react" -import { openUrl } from "@tauri-apps/plugin-opener" +import { openUrl } from "@/lib/platform" import { toast } from "sonner" import { AgentIcon } from "@/components/agent-icon" import { @@ -64,7 +64,7 @@ import { acpReorderAgents, acpUninstallAgent, acpUpdateAgentPreferences, -} from "@/lib/tauri" +} from "@/lib/api" import type { AcpAgentInfo, AgentType, diff --git a/src/components/settings/add-git-account-dialog.tsx b/src/components/settings/add-git-account-dialog.tsx index b9d56b7..cb6374d 100644 --- a/src/components/settings/add-git-account-dialog.tsx +++ b/src/components/settings/add-git-account-dialog.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { saveAccountToken } from "@/lib/tauri" +import { saveAccountToken } from "@/lib/api" import type { GitHubAccount } from "@/lib/types" interface AddGitAccountDialogProps { diff --git a/src/components/settings/add-github-account-dialog.tsx b/src/components/settings/add-github-account-dialog.tsx index 6c432e9..1348299 100644 --- a/src/components/settings/add-github-account-dialog.tsx +++ b/src/components/settings/add-github-account-dialog.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react" import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-react" -import { openUrl } from "@tauri-apps/plugin-opener" +import { openUrl } from "@/lib/platform" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -14,7 +14,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { validateGitHubToken, saveAccountToken } from "@/lib/tauri" +import { validateGitHubToken, saveAccountToken } from "@/lib/api" import type { GitHubAccount } from "@/lib/types" interface AddGitHubAccountDialogProps { diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 7de4ea1..81d96c6 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -45,7 +45,7 @@ import { mcpScanLocal, mcpSearchMarketplace, mcpUpsertLocalServer, -} from "@/lib/tauri" +} from "@/lib/api" import { cn } from "@/lib/utils" import type { LocalMcpServer, diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 26eac5d..5267350 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -5,6 +5,7 @@ import { Bot, BookOpenText, GitBranch, + Globe, Keyboard, Palette, PlugZap, @@ -28,6 +29,7 @@ interface SettingsNavItem { | "shortcuts" | "version_control" | "system" + | "web_service" icon: ComponentType<{ className?: string }> } @@ -67,6 +69,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ labelKey: "system", icon: Settings, }, + { + href: "/settings/web-service", + labelKey: "web_service", + icon: Globe, + }, ] interface SettingsShellProps { diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 48528a6..8711334 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -55,7 +55,7 @@ import { openFolderWindow, acpReadAgentSkill, acpSaveAgentSkill, -} from "@/lib/tauri" +} from "@/lib/api" import type { AcpAgentInfo, AgentSkillItem, diff --git a/src/components/settings/system-network-settings.tsx b/src/components/settings/system-network-settings.tsx index b2f85a2..7e49fdd 100644 --- a/src/components/settings/system-network-settings.tsx +++ b/src/components/settings/system-network-settings.tsx @@ -9,7 +9,8 @@ import { RefreshCw, Wifi, } from "lucide-react" -import type { Update } from "@tauri-apps/plugin-updater" +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Update = any import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" import { useAppI18n } from "@/components/i18n-provider" @@ -26,7 +27,7 @@ import { getSystemProxySettings, updateSystemLanguageSettings, updateSystemProxySettings, -} from "@/lib/tauri" +} from "@/lib/api" import type { AppLocale } from "@/lib/types" import { checkAppUpdate, diff --git a/src/components/settings/version-control-settings.tsx b/src/components/settings/version-control-settings.tsx index 1061f76..ffeb3b2 100644 --- a/src/components/settings/version-control-settings.tsx +++ b/src/components/settings/version-control-settings.tsx @@ -35,7 +35,7 @@ import { validateGitHubToken, getAccountToken, deleteAccountToken, -} from "@/lib/tauri" +} from "@/lib/api" import type { GitDetectResult, GitHubAccount, diff --git a/src/components/settings/web-service-settings.tsx b/src/components/settings/web-service-settings.tsx new file mode 100644 index 0000000..f010a9f --- /dev/null +++ b/src/components/settings/web-service-settings.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { + startWebServer, + stopWebServer, + getWebServerStatus, + type WebServerInfo, +} from "@/lib/api" + +export function WebServiceSettings() { + const [status, setStatus] = useState(null) + const [port, setPort] = useState("3080") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + + const fetchStatus = useCallback(async () => { + try { + const info = await getWebServerStatus() + setStatus(info) + if (info) { + setPort(String(info.port)) + } + } catch { + // Server status unavailable + } + }, []) + + useEffect(() => { + fetchStatus() + }, [fetchStatus]) + + async function handleStart() { + setError("") + setLoading(true) + try { + const info = await startWebServer({ + port: parseInt(port, 10) || 3080, + }) + setStatus(info) + } catch (e: unknown) { + const msg = + e && typeof e === "object" && "message" in e + ? (e as { message: string }).message + : "启动失败" + setError(msg) + } finally { + setLoading(false) + } + } + + async function handleStop() { + setLoading(true) + try { + await stopWebServer() + setStatus(null) + } catch { + setError("停止失败") + } finally { + setLoading(false) + } + } + + function copyToken() { + if (status?.token) { + navigator.clipboard.writeText(status.token) + } + } + + function copyUrl() { + if (status?.addresses?.[1]) { + navigator.clipboard.writeText(status.addresses[1]) + } else if (status?.addresses?.[0]) { + navigator.clipboard.writeText(status.addresses[0]) + } + } + + const isRunning = status !== null + + return ( +
+
+

Web 服务

+

+ 启用后可通过浏览器远程访问 Codeg +

+
+ +
+ {/* Port config */} +
+ + setPort(e.target.value)} + disabled={isRunning} + min={1024} + max={65535} + className="flex h-9 w-32 rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" + /> +
+ + {/* Start/Stop button */} +
+ +
+ + + {isRunning ? "运行中" : "已停止"} + + +
+
+ + {error && ( +

{error}

+ )} + + {/* Connection info */} + {isRunning && ( +
+
+
+ 访问地址 +
+ {status.addresses.map((addr) => ( +
+ {addr} +
+ ))} + +
+ +
+
+ 访问 Token +
+
+ + {status.token} + + +
+
+ +

+ Web 客户端首次访问时需输入此 Token +

+
+ )} +
+
+ ) +} diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 8b9130c..690f513 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -1,9 +1,8 @@ "use client" import { useEffect, useRef } from "react" -import { listen } from "@tauri-apps/api/event" -import { terminalWrite, terminalResize } from "@/lib/tauri" -import { disposeTauriListener } from "@/lib/tauri-listener" +import { subscribe } from "@/lib/platform" +import { terminalWrite, terminalResize } from "@/lib/api" import type { TerminalEvent } from "@/lib/types" import type { ITheme } from "@xterm/xterm" @@ -169,14 +168,14 @@ export function TerminalView({ ) // Set up event listeners BEFORE fit so initial output is captured - const unlisten = await listen( + const unlisten = await subscribe( `terminal://output/${terminalId}`, - (event) => { - term.write(event.payload.data) + (payload) => { + term.write(payload.data) } ) - const unlistenExit = await listen( + const unlistenExit = await subscribe( `terminal://exit/${terminalId}`, () => { term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n") @@ -187,8 +186,8 @@ export function TerminalView({ themeObserver.disconnect() onDataDisposable.dispose() onResizeDisposable.dispose() - disposeTauriListener(unlisten, "TerminalView.output") - disposeTauriListener(unlistenExit, "TerminalView.exit") + unlisten() + unlistenExit() term.dispose() return } @@ -222,8 +221,8 @@ export function TerminalView({ themeObserver.disconnect() onDataDisposable.dispose() onResizeDisposable.dispose() - disposeTauriListener(unlisten, "TerminalView.output") - disposeTauriListener(unlistenExit, "TerminalView.exit") + unlisten() + unlistenExit() resizeObserver.disconnect() term.dispose() fitAddonRef.current = null diff --git a/src/components/welcome/clone-dialog.tsx b/src/components/welcome/clone-dialog.tsx index ccf3738..8cf8bf1 100644 --- a/src/components/welcome/clone-dialog.tsx +++ b/src/components/welcome/clone-dialog.tsx @@ -1,10 +1,10 @@ "use client" import { useState } from "react" -import { open } from "@tauri-apps/plugin-dialog" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { cloneRepository, openFolderWindow } from "@/lib/tauri" +import { cloneRepository, openFolderWindow } from "@/lib/api" +import { openFileDialog } from "@/lib/platform" import { useGitCredential } from "@/contexts/git-credential-context" import { Dialog, @@ -36,9 +36,9 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { } | null>(null) const handleBrowse = async () => { - const selected = await open({ directory: true, multiple: false }) + const selected = await openFileDialog({ directory: true, multiple: false }) if (selected) { - setTargetDir(selected) + setTargetDir(Array.isArray(selected) ? selected[0] : selected) } } diff --git a/src/components/welcome/folder-actions.tsx b/src/components/welcome/folder-actions.tsx index 17efb84..603075a 100644 --- a/src/components/welcome/folder-actions.tsx +++ b/src/components/welcome/folder-actions.tsx @@ -4,8 +4,8 @@ import { useState } from "react" import { FolderOpen, GitBranch } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { open } from "@tauri-apps/plugin-dialog" -import { openFolderWindow } from "@/lib/tauri" +import { openFolderWindow } from "@/lib/api" +import { openFileDialog } from "@/lib/platform" import { Button } from "@/components/ui/button" import { CloneDialog } from "./clone-dialog" import { resolveWelcomeError } from "@/components/welcome/error-utils" @@ -15,8 +15,9 @@ export function FolderActions() { const [cloneOpen, setCloneOpen] = useState(false) const handleOpen = async () => { - const selected = await open({ directory: true, multiple: false }) - if (!selected) return + const result = await openFileDialog({ directory: true, multiple: false }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result try { await openFolderWindow(selected) diff --git a/src/components/welcome/folder-list.tsx b/src/components/welcome/folder-list.tsx index b78337a..974746b 100644 --- a/src/components/welcome/folder-list.tsx +++ b/src/components/welcome/folder-list.tsx @@ -6,7 +6,7 @@ import { formatDistanceToNow } from "date-fns" import { enUS, zhCN, zhTW } from "date-fns/locale" import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" -import { openFolderWindow, removeFolderFromHistory } from "@/lib/tauri" +import { openFolderWindow, removeFolderFromHistory } from "@/lib/api" import type { FolderHistoryEntry } from "@/lib/types" import { Input } from "@/components/ui/input" import { resolveWelcomeError } from "@/components/welcome/error-utils" diff --git a/src/components/welcome/welcome-screen.tsx b/src/components/welcome/welcome-screen.tsx index 4abb5c6..0949382 100644 --- a/src/components/welcome/welcome-screen.tsx +++ b/src/components/welcome/welcome-screen.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react" import { Settings } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { loadFolderHistory, openSettingsWindow } from "@/lib/tauri" +import { loadFolderHistory, openSettingsWindow } from "@/lib/api" import type { FolderHistoryEntry } from "@/lib/types" import { FolderList } from "@/components/welcome/folder-list" import { FolderActions } from "@/components/welcome/folder-actions" diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index f6216d9..d3497f7 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -10,8 +10,7 @@ import { type ReactNode, } from "react" import { useTranslations } from "next-intl" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" -import { disposeTauriListener } from "@/lib/tauri-listener" +import { subscribe } from "@/lib/platform" import { inferLiveToolName } from "@/lib/tool-call-normalization" import { acpConnect, @@ -22,7 +21,7 @@ import { acpCancel, acpRespondPermission, acpDisconnect, -} from "@/lib/tauri" +} from "@/lib/api" import type { AgentType, AcpAgentStatus, @@ -1609,25 +1608,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // Single global event listener useEffect(() => { let cancelled = false - let unlisten: UnlistenFn | null = null + let unlisten: (() => void) | null = null listenerReadyRef.current = false - listen("acp://event", (event) => { - const e = event.payload - const contextKey = reverseMapRef.current.get(e.connection_id) + subscribe("acp://event", (payload) => { + const contextKey = reverseMapRef.current.get(payload.connection_id) if (!contextKey) { - bufferUnmappedEvent(e) + bufferUnmappedEvent(payload) return } // Touch activity on every incoming event lastActivityRef.current.set(contextKey, Date.now()) - handleMappedEvent(contextKey, e) + handleMappedEvent(contextKey, payload) }) .then((fn) => { if (cancelled) { - disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent") + fn() } else { unlisten = fn listenerReadyRef.current = true @@ -1647,7 +1645,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { clearTimeout(flushTimerRef.current) flushTimerRef.current = null } - disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent") + unlisten?.() } }, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters]) diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 504d7b2..5fc40ba 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -10,7 +10,7 @@ import { type ReactNode, } from "react" import type { LiveMessage } from "@/contexts/acp-connections-context" -import { getFolderConversation } from "@/lib/tauri" +import { getFolderConversation } from "@/lib/api" import type { DbConversationDetail, MessageTurn, diff --git a/src/contexts/folder-context.tsx b/src/contexts/folder-context.tsx index 7bdf0e7..282cb31 100644 --- a/src/contexts/folder-context.tsx +++ b/src/contexts/folder-context.tsx @@ -11,7 +11,7 @@ import { type ReactNode, } from "react" import { toErrorMessage } from "@/lib/app-error" -import { getFolder, listFolderConversations } from "@/lib/tauri" +import { getFolder, listFolderConversations } from "@/lib/api" import type { AgentType, AgentStats, diff --git a/src/contexts/git-credential-context.tsx b/src/contexts/git-credential-context.tsx index 97949f4..fdff395 100644 --- a/src/contexts/git-credential-context.tsx +++ b/src/contexts/git-credential-context.tsx @@ -17,7 +17,7 @@ import { KeyRound, Loader2, } from "lucide-react" -import { openUrl } from "@tauri-apps/plugin-opener" +import { openUrl } from "@/lib/platform" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -37,7 +37,7 @@ import { getGitHubAccounts, updateGitHubAccounts, saveAccountToken, -} from "@/lib/tauri" +} from "@/lib/api" // --------------------------------------------------------------------------- // Context diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index 3915df4..6903bf8 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -13,7 +13,7 @@ import { import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" -import { saveFolderOpenedConversations } from "@/lib/tauri" +import { saveFolderOpenedConversations } from "@/lib/api" import type { AgentType, ConversationStatus, diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 53c57fa..983e347 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -10,7 +10,7 @@ import { useState, type ReactNode, } from "react" -import { terminalSpawn, terminalKill } from "@/lib/tauri" +import { terminalSpawn, terminalKill } from "@/lib/api" import { useFolderContext } from "@/contexts/folder-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 32cb8bb..0c41ccc 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -22,7 +22,7 @@ import { readFileForEdit, readFilePreview, saveFileContent, -} from "@/lib/tauri" +} from "@/lib/api" import { languageFromPath } from "@/lib/language-detect" import { loadPersistedWorkspaceMode, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 271d160..888b751 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "الاختصارات", "version_control": "التحكم بالإصدارات", - "system": "النظام" + "system": "النظام", + "web_service": "خدمة الويب" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c114375..206b40d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "Kurzbefehle", "version_control": "Versionskontrolle", - "system": "Systemeinstellungen" + "system": "Systemeinstellungen", + "web_service": "Webdienst" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 942843e..afb1054 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "Shortcuts", "version_control": "Version Control", - "system": "System" + "system": "System", + "web_service": "Web Service" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f786038..8c0626b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "Atajos", "version_control": "Control de versiones", - "system": "Sistema" + "system": "Sistema", + "web_service": "Servicio Web" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 22e2ef1..35ce3b2 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "Raccourcis", "version_control": "Contrôle de version", - "system": "Système" + "system": "Système", + "web_service": "Service Web" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c6c797f..cd55905 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "ショートカット", "version_control": "バージョン管理", - "system": "システム" + "system": "システム", + "web_service": "Webサービス" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 27525b7..3494a2b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "단축키", "version_control": "버전 관리", - "system": "시스템" + "system": "시스템", + "web_service": "웹 서비스" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 36821af..0b7d998 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "Atalhos", "version_control": "Controle de versão", - "system": "Sistema" + "system": "Sistema", + "web_service": "Serviço Web" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 260421c..a7021ef 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "快捷键", "version_control": "版本控制", - "system": "系统" + "system": "系统", + "web_service": "Web 服务" } }, "AppearanceSettings": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d16b372..a2e40a1 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -88,7 +88,8 @@ "skills": "Skills", "shortcuts": "快捷鍵", "version_control": "版本控制", - "system": "系統" + "system": "系統", + "web_service": "Web 服務" } }, "AppearanceSettings": { diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..6afbd97 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,1151 @@ +import { getTransport } from "./transport" +import type { + AgentType, + ConversationSummary, + ConversationDetail, + DbConversationDetail, + FolderInfo, + AgentStats, + SidebarData, + ConnectionInfo, + AcpAgentInfo, + AcpAgentStatus, + AgentSkillScope, + AgentSkillLayout, + AgentSkillItem, + AgentSkillsListResult, + AgentSkillContent, + FolderHistoryEntry, + FolderDetail, + DbConversationSummary, + ImportResult, + OpenedConversation, + GitStatusEntry, + GitBranchList, + GitPullResult, + GitPushResult, + GitPushInfo, + GitMergeResult, + GitRebaseResult, + GitConflictFileVersions, + GitCommitResult, + GitRemote, + GitStashEntry, + PreflightResult, + FolderCommand, + TerminalInfo, + PromptInputBlock, + FileTreeNode, + FilePreviewContent, + FileEditContent, + FileSaveResult, + GitLogResult, + SystemLanguageSettings, + SystemProxySettings, + GitCredentials, + GitDetectResult, + GitSettings, + GitHubAccountsSettings, + GitHubTokenValidation, + McpAppType, + LocalMcpServer, + McpMarketplaceProvider, + McpMarketplaceItem, + McpMarketplaceServerDetail, +} from "./types" + +export async function listConversations(params?: { + agent_type?: AgentType | null + search?: string | null + sort_by?: string | null + folder_path?: string | null +}): Promise { + return getTransport().call("list_conversations", { + agentType: params?.agent_type ?? null, + search: params?.search ?? null, + sortBy: params?.sort_by ?? null, + folderPath: params?.folder_path ?? null, + }) +} + +export async function getConversation( + agentType: AgentType, + conversationId: string +): Promise { + return getTransport().call("get_conversation", { agentType, conversationId }) +} + +export async function listFolders(): Promise { + return getTransport().call("list_folders") +} + +export async function getStats(): Promise { + return getTransport().call("get_stats") +} + +export async function getSidebarData(): Promise { + return getTransport().call("get_sidebar_data") +} + +// ACP commands + +export async function acpConnect( + agentType: AgentType, + workingDir?: string, + sessionId?: string +): Promise { + return getTransport().call("acp_connect", { + agentType, + workingDir: workingDir ?? null, + sessionId: sessionId ?? null, + }) +} + +export async function acpPrompt( + connectionId: string, + blocks: PromptInputBlock[] +): Promise { + return getTransport().call("acp_prompt", { connectionId, blocks }) +} + +export async function acpSetMode( + connectionId: string, + modeId: string +): Promise { + return getTransport().call("acp_set_mode", { connectionId, modeId }) +} + +export async function acpSetConfigOption( + connectionId: string, + configId: string, + valueId: string +): Promise { + return getTransport().call("acp_set_config_option", { connectionId, configId, valueId }) +} + +export async function acpCancel(connectionId: string): Promise { + return getTransport().call("acp_cancel", { connectionId }) +} + +export interface ForkResult { + forkedSessionId: string + originalSessionId: string +} + +export async function acpFork(connectionId: string): Promise { + return getTransport().call("acp_fork", { connectionId }) +} + +export async function acpRespondPermission( + connectionId: string, + requestId: string, + optionId: string +): Promise { + return getTransport().call("acp_respond_permission", { + connectionId, + requestId, + optionId, + }) +} + +export async function acpDisconnect(connectionId: string): Promise { + return getTransport().call("acp_disconnect", { connectionId }) +} + +export async function acpListConnections(): Promise { + return getTransport().call("acp_list_connections") +} + +export async function acpListAgents(): Promise { + return getTransport().call("acp_list_agents") +} + +export async function acpGetAgentStatus( + agentType: AgentType +): Promise { + return getTransport().call("acp_get_agent_status", { agentType }) +} + +export async function acpClearBinaryCache(agentType: AgentType): Promise { + return getTransport().call("acp_clear_binary_cache", { agentType }) +} + +export async function acpDownloadAgentBinary( + agentType: AgentType +): Promise { + return getTransport().call("acp_download_agent_binary", { agentType }) +} + +export async function acpDetectAgentLocalVersion( + agentType: AgentType +): Promise { + return getTransport().call("acp_detect_agent_local_version", { agentType }) +} + +export async function acpPrepareNpxAgent( + agentType: AgentType, + registryVersion?: string | null +): Promise { + return getTransport().call("acp_prepare_npx_agent", { + agentType, + registryVersion: registryVersion ?? null, + }) +} + +export async function acpUninstallAgent(agentType: AgentType): Promise { + return getTransport().call("acp_uninstall_agent", { agentType }) +} + +export async function acpUpdateAgentPreferences( + agentType: AgentType, + params: { + enabled: boolean + env: Record + config_json?: string | null + opencode_auth_json?: string | null + codex_auth_json?: string | null + codex_config_toml?: string | null + } +): Promise { + return getTransport().call("acp_update_agent_preferences", { + agentType, + enabled: params.enabled, + env: params.env, + configJson: params.config_json ?? null, + opencodeAuthJson: params.opencode_auth_json ?? null, + codexAuthJson: params.codex_auth_json ?? null, + codexConfigToml: params.codex_config_toml ?? null, + }) +} + +export async function acpReorderAgents(agentTypes: AgentType[]): Promise { + return getTransport().call("acp_reorder_agents", { agentTypes }) +} + +export async function acpPreflight( + agentType: AgentType, + forceRefresh?: boolean +): Promise { + return getTransport().call("acp_preflight", { + agentType, + forceRefresh: forceRefresh ?? null, + }) +} + +export async function acpListAgentSkills(params: { + agentType: AgentType + workspacePath?: string | null +}): Promise { + return getTransport().call("acp_list_agent_skills", { + agentType: params.agentType, + workspacePath: params.workspacePath ?? null, + }) +} + +export async function acpReadAgentSkill(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + workspacePath?: string | null +}): Promise { + return getTransport().call("acp_read_agent_skill", { + agentType: params.agentType, + scope: params.scope, + skillId: params.skillId, + workspacePath: params.workspacePath ?? null, + }) +} + +export async function acpSaveAgentSkill(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + content: string + workspacePath?: string | null + layout?: AgentSkillLayout | null +}): Promise { + return getTransport().call("acp_save_agent_skill", { + agentType: params.agentType, + scope: params.scope, + skillId: params.skillId, + content: params.content, + workspacePath: params.workspacePath ?? null, + layout: params.layout ?? null, + }) +} + +export async function acpDeleteAgentSkill(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + workspacePath?: string | null +}): Promise { + return getTransport().call("acp_delete_agent_skill", { + agentType: params.agentType, + scope: params.scope, + skillId: params.skillId, + workspacePath: params.workspacePath ?? null, + }) +} + +export async function getSystemProxySettings(): Promise { + return getTransport().call("get_system_proxy_settings") +} + +export async function updateSystemProxySettings( + settings: SystemProxySettings +): Promise { + return getTransport().call("update_system_proxy_settings", { settings }) +} + +export async function getSystemLanguageSettings(): Promise { + return getTransport().call("get_system_language_settings") +} + +export async function updateSystemLanguageSettings( + settings: SystemLanguageSettings +): Promise { + return getTransport().call("update_system_language_settings", { settings }) +} + +// --- Version Control --- + +export async function detectGit(): Promise { + return getTransport().call("detect_git") +} + +export async function testGitPath(path: string): Promise { + return getTransport().call("test_git_path", { path }) +} + +export async function getGitSettings(): Promise { + return getTransport().call("get_git_settings") +} + +export async function updateGitSettings( + settings: GitSettings +): Promise { + return getTransport().call("update_git_settings", { settings }) +} + +export async function getGitHubAccounts(): Promise { + return getTransport().call("get_github_accounts") +} + +export async function validateGitHubToken( + serverUrl: string, + token: string +): Promise { + return getTransport().call("validate_github_token", { serverUrl, token }) +} + +export async function updateGitHubAccounts( + settings: GitHubAccountsSettings +): Promise { + return getTransport().call("update_github_accounts", { settings }) +} + +export async function saveAccountToken( + accountId: string, + token: string +): Promise { + return getTransport().call("save_account_token", { accountId, token }) +} + +export async function getAccountToken( + accountId: string +): Promise { + return getTransport().call("get_account_token", { accountId }) +} + +export async function deleteAccountToken(accountId: string): Promise { + return getTransport().call("delete_account_token", { accountId }) +} + +export async function mcpScanLocal(): Promise { + return getTransport().call("mcp_scan_local") +} + +export async function mcpListMarketplaces(): Promise { + return getTransport().call("mcp_list_marketplaces") +} + +export async function mcpSearchMarketplace(params: { + providerId: string + query?: string | null + limit?: number | null +}): Promise { + return getTransport().call("mcp_search_marketplace", { + providerId: params.providerId, + query: params.query ?? null, + limit: params.limit ?? null, + }) +} + +export async function mcpGetMarketplaceServerDetail(params: { + providerId: string + serverId: string +}): Promise { + return getTransport().call("mcp_get_marketplace_server_detail", { + providerId: params.providerId, + serverId: params.serverId, + }) +} + +export async function mcpInstallFromMarketplace(params: { + providerId: string + serverId: string + apps: McpAppType[] + specOverride?: Record | null + optionId?: string | null + protocol?: string | null + parameterValues?: Record | null +}): Promise { + return getTransport().call("mcp_install_from_marketplace", { + providerId: params.providerId, + serverId: params.serverId, + apps: params.apps, + specOverride: params.specOverride ?? null, + optionId: params.optionId ?? null, + protocol: params.protocol ?? null, + parameterValues: params.parameterValues ?? null, + }) +} + +export async function mcpUpsertLocalServer(params: { + serverId: string + spec: Record + apps: McpAppType[] +}): Promise { + return getTransport().call("mcp_upsert_local_server", { + serverId: params.serverId, + spec: params.spec, + apps: params.apps, + }) +} + +export async function mcpSetServerApps( + serverId: string, + apps: McpAppType[] +): Promise { + return getTransport().call("mcp_set_server_apps", { serverId, apps }) +} + +export async function mcpRemoveServer( + serverId: string, + apps?: McpAppType[] | null +): Promise { + return getTransport().call("mcp_remove_server", { + serverId, + apps: apps ?? null, + }) +} + +// Folder history commands + +export async function loadFolderHistory(): Promise { + return getTransport().call("load_folder_history") +} + +export async function getFolder(folderId: number): Promise { + return getTransport().call("get_folder", { folderId }) +} + +export async function listFolderConversations(params: { + folder_id: number + agent_type?: AgentType | null + search?: string | null + sort_by?: string | null + status?: string | null +}): Promise { + return getTransport().call("list_folder_conversations", { + folderId: params.folder_id, + agentType: params.agent_type ?? null, + search: params.search ?? null, + sortBy: params.sort_by ?? null, + status: params.status ?? null, + }) +} + +export async function importLocalConversations( + folderId: number +): Promise { + return getTransport().call("import_local_conversations", { folderId }) +} + +export async function getFolderConversation( + conversationId: number +): Promise { + return getTransport().call("get_folder_conversation", { conversationId }) +} + +export async function saveFolderOpenedConversations( + folderId: number, + items: OpenedConversation[] +): Promise { + return getTransport().call("save_folder_opened_conversations", { folderId, items }) +} + +export async function setFolderParentBranch( + path: string, + parentBranch: string | null +): Promise { + return getTransport().call("set_folder_parent_branch", { + path, + parentBranch, + }) +} + +export async function removeFolderFromHistory(path: string): Promise { + return getTransport().call("remove_folder_from_history", { path }) +} + +export async function createFolderDirectory(path: string): Promise { + return getTransport().call("create_folder_directory", { path }) +} + +export async function cloneRepository( + url: string, + targetDir: string, + credentials?: GitCredentials | null +): Promise { + return getTransport().call("clone_repository", { + url, + targetDir, + credentials: credentials ?? null, + }) +} + +export async function getGitBranch(path: string): Promise { + return getTransport().call("get_git_branch", { path }) +} + +export async function gitInit(path: string): Promise { + return getTransport().call("git_init", { path }) +} + +export async function gitPull( + path: string, + credentials?: GitCredentials | null +): Promise { + return getTransport().call("git_pull", { path, credentials: credentials ?? null }) +} + +export async function gitStartPullMerge( + path: string, + upstreamCommit?: string | null +): Promise { + return getTransport().call("git_start_pull_merge", { path, upstreamCommit }) +} + +export async function gitHasMergeHead(path: string): Promise { + return getTransport().call("git_has_merge_head", { path }) +} + +export async function gitFetch( + path: string, + credentials?: GitCredentials | null +): Promise { + return getTransport().call("git_fetch", { path, credentials: credentials ?? null }) +} + +export async function gitPushInfo(path: string): Promise { + return getTransport().call("git_push_info", { path }) +} + +export async function gitPush( + path: string, + remote?: string | null, + credentials?: GitCredentials | null +): Promise { + return getTransport().call("git_push", { + path, + remote: remote ?? null, + credentials: credentials ?? null, + }) +} + +export async function gitNewBranch( + path: string, + branchName: string, + startPoint?: string +): Promise { + return getTransport().call("git_new_branch", { + path, + branchName, + startPoint: startPoint ?? null, + }) +} + +export async function gitWorktreeAdd( + path: string, + branchName: string, + worktreePath: string +): Promise { + return getTransport().call("git_worktree_add", { path, branchName, worktreePath }) +} + +export async function gitCheckout( + path: string, + branchName: string +): Promise { + return getTransport().call("git_checkout", { path, branchName }) +} + +export async function gitListBranches(path: string): Promise { + return getTransport().call("git_list_branches", { path }) +} + +export async function gitListAllBranches(path: string): Promise { + return getTransport().call("git_list_all_branches", { path }) +} + +export async function gitMerge( + path: string, + branchName: string +): Promise { + return getTransport().call("git_merge", { path, branchName }) +} + +export async function gitRebase( + path: string, + branchName: string +): Promise { + return getTransport().call("git_rebase", { path, branchName }) +} + +export async function gitDeleteBranch( + path: string, + branchName: string, + force = false +): Promise { + return getTransport().call("git_delete_branch", { path, branchName, force }) +} + +export async function gitListConflicts(path: string): Promise { + return getTransport().call("git_list_conflicts", { path }) +} + +export async function gitConflictFileVersions( + path: string, + file: string +): Promise { + return getTransport().call("git_conflict_file_versions", { path, file }) +} + +export async function gitResolveConflict( + path: string, + file: string, + content: string +): Promise { + return getTransport().call("git_resolve_conflict", { path, file, content }) +} + +export async function gitAbortOperation( + path: string, + operation: string +): Promise { + return getTransport().call("git_abort_operation", { path, operation }) +} + +export async function gitContinueOperation( + path: string, + operation: string +): Promise { + return getTransport().call("git_continue_operation", { path, operation }) +} + +export async function openMergeWindow( + folderId: number, + operation: string, + upstreamCommit?: string | null +): Promise { + return getTransport().call("open_merge_window", { + folderId, + operation, + upstreamCommit: upstreamCommit ?? null, + }) +} + +export async function openStashWindow(folderId: number): Promise { + return getTransport().call("open_stash_window", { folderId }) +} + +export async function openPushWindow(folderId: number): Promise { + return getTransport().call("open_push_window", { folderId }) +} + +export async function gitStashPush( + path: string, + message?: string, + keepIndex?: boolean +): Promise { + return getTransport().call("git_stash_push", { + path, + message: message ?? null, + keepIndex: keepIndex ?? false, + }) +} + +export async function gitStashPop( + path: string, + stashRef?: string +): Promise { + return getTransport().call("git_stash_pop", { path, stashRef: stashRef ?? null }) +} + +export async function gitStashList(path: string): Promise { + return getTransport().call("git_stash_list", { path }) +} + +export async function gitStashApply( + path: string, + stashRef: string +): Promise { + return getTransport().call("git_stash_apply", { path, stashRef }) +} + +export async function gitStashDrop( + path: string, + stashRef: string +): Promise { + return getTransport().call("git_stash_drop", { path, stashRef }) +} + +export async function gitStashClear(path: string): Promise { + return getTransport().call("git_stash_clear", { path }) +} + +export async function gitStashShow( + path: string, + stashRef: string +): Promise { + return getTransport().call("git_stash_show", { path, stashRef }) +} + +export async function gitListRemotes(path: string): Promise { + return getTransport().call("git_list_remotes", { path }) +} + +export async function gitFetchRemote( + path: string, + name: string, + credentials?: GitCredentials | null +): Promise { + return getTransport().call("git_fetch_remote", { + path, + name, + credentials: credentials ?? null, + }) +} + +export async function gitAddRemote( + path: string, + name: string, + url: string +): Promise { + return getTransport().call("git_add_remote", { path, name, url }) +} + +export async function gitRemoveRemote( + path: string, + name: string +): Promise { + return getTransport().call("git_remove_remote", { path, name }) +} + +export async function gitSetRemoteUrl( + path: string, + name: string, + url: string +): Promise { + return getTransport().call("git_set_remote_url", { path, name, url }) +} + +export async function gitStatus( + path: string, + showAllUntracked?: boolean +): Promise { + return getTransport().call("git_status", { + path, + showAllUntracked: showAllUntracked ?? null, + }) +} + +export async function gitDiff(path: string, file?: string): Promise { + return getTransport().call("git_diff", { path, file: file ?? null }) +} + +export async function gitDiffWithBranch( + path: string, + branch: string, + file?: string +): Promise { + return getTransport().call("git_diff_with_branch", { + path, + branch, + file: file ?? null, + }) +} + +export async function gitShowDiff( + path: string, + commit: string, + file?: string +): Promise { + return getTransport().call("git_show_diff", { path, commit, file: file ?? null }) +} + +export async function gitShowFile( + path: string, + file: string, + refName?: string +): Promise { + return getTransport().call("git_show_file", { + path, + file, + refName: refName ?? null, + }) +} + +export async function gitIsTracked( + path: string, + file: string +): Promise { + return getTransport().call("git_is_tracked", { path, file }) +} + +export async function gitCommit( + path: string, + message: string, + files: string[] +): Promise { + return getTransport().call("git_commit", { path, message, files }) +} + +export async function gitRollbackFile( + path: string, + file: string +): Promise { + return getTransport().call("git_rollback_file", { path, file }) +} + +export async function gitAddFiles( + path: string, + files: string[] +): Promise { + return getTransport().call("git_add_files", { path, files }) +} + +// Window management commands + +export async function openFolderWindow(path: string): Promise { + if (getTransport().isDesktop()) { + return getTransport().call("open_folder_window", { path }) + } + // Web mode: add folder to DB and navigate to folder page + const entry = await getTransport().call<{ id: number }>( + "open_folder_window", + { path } + ) + window.location.href = `/folder?id=${entry.id}` +} + +export async function openCommitWindow(folderId: number): Promise { + return getTransport().call("open_commit_window", { folderId }) +} + +export type SettingsSection = + | "appearance" + | "agents" + | "mcp" + | "skills" + | "shortcuts" + | "system" + +interface OpenSettingsWindowOptions { + agentType?: AgentType | null +} + +export async function openSettingsWindow( + section?: SettingsSection, + options?: OpenSettingsWindowOptions +): Promise { + return getTransport().call("open_settings_window", { + section: section ?? null, + agentType: options?.agentType ?? null, + }) +} + +export async function listOpenFolders(): Promise { + return getTransport().call("list_open_folders") +} + +export async function focusFolderWindow(folderId: number): Promise { + return getTransport().call("focus_folder_window", { folderId }) +} + +// Conversation CRUD commands + +export async function createConversation( + folderId: number, + agentType: AgentType, + title?: string +): Promise { + return getTransport().call("create_conversation", { + folderId, + agentType, + title: title ?? null, + }) +} + +export async function updateConversationStatus( + conversationId: number, + status: string +): Promise { + return getTransport().call("update_conversation_status", { conversationId, status }) +} + +export async function updateConversationTitle( + conversationId: number, + title: string +): Promise { + return getTransport().call("update_conversation_title", { conversationId, title }) +} + +export async function updateConversationExternalId( + conversationId: number, + externalId: string +): Promise { + return getTransport().call("update_conversation_external_id", { + conversationId, + externalId, + }) +} + +export async function deleteConversation( + conversationId: number +): Promise { + return getTransport().call("delete_conversation", { conversationId }) +} + +// Folder command management + +export async function listFolderCommands( + folderId: number +): Promise { + return getTransport().call("list_folder_commands", { folderId }) +} + +export async function createFolderCommand( + folderId: number, + name: string, + command: string +): Promise { + return getTransport().call("create_folder_command", { folderId, name, command }) +} + +export async function updateFolderCommand( + id: number, + name?: string, + command?: string, + sortOrder?: number +): Promise { + return getTransport().call("update_folder_command", { + id, + name: name ?? null, + command: command ?? null, + sortOrder: sortOrder ?? null, + }) +} + +export async function deleteFolderCommand(id: number): Promise { + return getTransport().call("delete_folder_command", { id }) +} + +export async function reorderFolderCommands( + folderId: number, + ids: number[] +): Promise { + return getTransport().call("reorder_folder_commands", { folderId, ids }) +} + +export async function bootstrapFolderCommandsFromPackageJson( + folderId: number, + folderPath: string +): Promise { + return getTransport().call("bootstrap_folder_commands_from_package_json", { + folderId, + folderPath, + }) +} + +// File tree and git log commands + +export async function getFileTree( + path: string, + maxDepth?: number +): Promise { + return getTransport().call("get_file_tree", { path, maxDepth: maxDepth ?? null }) +} + +export async function startFileTreeWatch(rootPath: string): Promise { + return getTransport().call("start_file_tree_watch", { rootPath }) +} + +export async function stopFileTreeWatch(rootPath: string): Promise { + return getTransport().call("stop_file_tree_watch", { rootPath }) +} + +export async function readFileBase64( + path: string, + maxBytes?: number +): Promise { + return getTransport().call("read_file_base64", { path, maxBytes: maxBytes ?? null }) +} + +export async function readFilePreview( + rootPath: string, + path: string +): Promise { + return getTransport().call("read_file_preview", { rootPath, path }) +} + +export async function readFileForEdit( + rootPath: string, + path: string +): Promise { + return getTransport().call("read_file_for_edit", { rootPath, path }) +} + +export async function saveFileContent( + rootPath: string, + path: string, + content: string, + expectedEtag?: string | null +): Promise { + return getTransport().call("save_file_content", { + rootPath, + path, + content, + expectedEtag: expectedEtag ?? null, + }) +} + +export async function saveFileCopy( + rootPath: string, + path: string, + content: string +): Promise { + return getTransport().call("save_file_copy", { + rootPath, + path, + content, + }) +} + +export async function renameFileTreeEntry( + rootPath: string, + path: string, + newName: string +): Promise { + return getTransport().call("rename_file_tree_entry", { rootPath, path, newName }) +} + +export async function deleteFileTreeEntry( + rootPath: string, + path: string +): Promise { + return getTransport().call("delete_file_tree_entry", { rootPath, path }) +} + +export async function createFileTreeEntry( + rootPath: string, + path: string, + name: string, + kind: "file" | "dir" +): Promise { + return getTransport().call("create_file_tree_entry", { rootPath, path, name, kind }) +} + +export async function gitLog( + path: string, + limit?: number, + branch?: string, + remote?: string +): Promise { + return getTransport().call("git_log", { + path, + limit: limit ?? null, + branch: branch ?? null, + remote: remote ?? null, + }) +} + +export async function gitCommitBranches( + path: string, + commit: string +): Promise { + return getTransport().call("git_commit_branches", { path, commit }) +} + +// Terminal commands + +export async function terminalSpawn( + workingDir: string, + initialCommand?: string +): Promise { + return getTransport().call("terminal_spawn", { + workingDir, + initialCommand: initialCommand ?? null, + }) +} + +export async function terminalWrite( + terminalId: string, + data: string +): Promise { + return getTransport().call("terminal_write", { terminalId, data }) +} + +export async function terminalResize( + terminalId: string, + cols: number, + rows: number +): Promise { + return getTransport().call("terminal_resize", { terminalId, cols, rows }) +} + +export async function terminalKill(terminalId: string): Promise { + return getTransport().call("terminal_kill", { terminalId }) +} + +export async function terminalList(): Promise { + return getTransport().call("terminal_list") +} + +// ── Web Server Management ── + +export interface WebServerInfo { + port: number + token: string + addresses: string[] +} + +export async function startWebServer(params?: { + port?: number + host?: string +}): Promise { + return getTransport().call("start_web_server", { + port: params?.port ?? null, + host: params?.host ?? null, + }) +} + +export async function stopWebServer(): Promise { + return getTransport().call("stop_web_server") +} + +export async function getWebServerStatus(): Promise { + return getTransport().call("get_web_server_status") +} diff --git a/src/lib/notification.ts b/src/lib/notification.ts index d24c0fa..df5ddc6 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,9 +1,22 @@ -import { invoke } from "@tauri-apps/api/core" +import { getTransport } from "./transport" +import { isDesktop } from "./transport" export async function notifyTurnComplete( title: string, body: string ): Promise { if (!document.hidden) return - await invoke("send_notification", { title, body }) + if (isDesktop()) { + await getTransport().call("send_notification", { title, body }) + } else { + // Web fallback: Browser Notification API + if (Notification.permission === "granted") { + new Notification(title, { body }) + } else if (Notification.permission !== "denied") { + const permission = await Notification.requestPermission() + if (permission === "granted") { + new Notification(title, { body }) + } + } + } } diff --git a/src/lib/platform.ts b/src/lib/platform.ts new file mode 100644 index 0000000..864cb1f --- /dev/null +++ b/src/lib/platform.ts @@ -0,0 +1,123 @@ +import { isDesktop, getTransport } from "./transport" +import type { UnsubscribeFn } from "./transport" + +/** + * Platform-aware API wrappers for features that differ between + * Tauri desktop and web browser environments. + */ + +export { isDesktop } + +/** + * Subscribe to backend events. + * Uses Tauri listen() in desktop mode, WebSocket in web mode. + */ +export async function subscribe( + event: string, + handler: (payload: T) => void +): Promise { + return getTransport().subscribe(event, handler) +} + +/** + * Open a URL in the default browser (desktop) or new tab (web). + */ +export async function openUrl(url: string): Promise { + if (isDesktop()) { + const { openUrl: tauriOpenUrl } = await import( + "@tauri-apps/plugin-opener" + ) + await tauriOpenUrl(url) + } else { + window.open(url, "_blank") + } +} + +/** + * Open a path in the system file manager (desktop only). + * No-op in web mode. + */ +export async function openPath(path: string): Promise { + if (isDesktop()) { + const { openPath: tauriOpenPath } = await import( + "@tauri-apps/plugin-opener" + ) + await tauriOpenPath(path) + } +} + +/** + * Reveal a file/directory in the system file manager (desktop only). + * No-op in web mode. + */ +export async function revealItemInDir(path: string): Promise { + if (isDesktop()) { + const { revealItemInDir: tauriReveal } = await import( + "@tauri-apps/plugin-opener" + ) + await tauriReveal(path) + } +} + +/** + * Open a native file/directory dialog (desktop) or fallback (web). + */ +export async function openFileDialog(options?: { + directory?: boolean + multiple?: boolean + title?: string +}): Promise { + if (isDesktop()) { + const { open } = await import("@tauri-apps/plugin-dialog") + return open(options ?? {}) + } + // Web fallback: for directory selection, prompt for server-side path. + // For file selection, use a hidden file input. + if (options?.directory) { + const path = window.prompt( + options?.title ?? "输入服务端目录路径 (Enter server directory path)" + ) + return path || null + } + return new Promise((resolve) => { + const input = document.createElement("input") + input.type = "file" + if (options?.multiple) input.multiple = true + input.onchange = () => { + if (!input.files?.length) { + resolve(null) + return + } + const paths = Array.from(input.files).map((f) => f.name) + resolve(options?.multiple ? paths : paths[0]) + } + input.click() + }) +} + +/** + * Get the current Tauri window (desktop only). + * Returns null in web mode. + */ +export async function getCurrentWindow() { + if (isDesktop()) { + const { getCurrentWindow: tauriGetCurrentWindow } = await import( + "@tauri-apps/api/window" + ) + return tauriGetCurrentWindow() + } + return null +} + +/** + * Close the current window. + * Desktop: closes Tauri window. Web: navigates back or closes tab. + */ +export async function closeCurrentWindow(): Promise { + if (isDesktop()) { + const win = await getCurrentWindow() + await win?.close() + } else { + window.history.back() + } +} diff --git a/src/lib/transport/detect.ts b/src/lib/transport/detect.ts new file mode 100644 index 0000000..7a883ad --- /dev/null +++ b/src/lib/transport/detect.ts @@ -0,0 +1,11 @@ +export type TransportEnvironment = "tauri" | "web" + +export function detectEnvironment(): TransportEnvironment { + if ( + typeof window !== "undefined" && + "__TAURI_INTERNALS__" in window + ) { + return "tauri" + } + return "web" +} diff --git a/src/lib/transport/index.ts b/src/lib/transport/index.ts new file mode 100644 index 0000000..9b0b9bc --- /dev/null +++ b/src/lib/transport/index.ts @@ -0,0 +1,33 @@ +import { detectEnvironment } from "./detect" +import type { Transport } from "./types" + +export type { Transport, UnsubscribeFn } from "./types" + +let _transport: Transport | null = null + +export function getTransport(): Transport { + if (!_transport) { + const env = detectEnvironment() + if (env === "tauri") { + // Use dynamic require to avoid bundling tauri deps in web mode. + // TauriTransport uses dynamic imports internally. + const { TauriTransport } = require("./tauri-transport") as { + TauriTransport: new () => Transport + } + _transport = new TauriTransport() + } else { + const { WebTransport } = require("./web-transport") as { + WebTransport: new (baseUrl: string) => Transport + } + // In web mode, the API is served from the same origin. + // Token is read from localStorage on each request. + const baseUrl = window.location.origin + _transport = new WebTransport(baseUrl) + } + } + return _transport +} + +export function isDesktop(): boolean { + return getTransport().isDesktop() +} diff --git a/src/lib/transport/tauri-transport.ts b/src/lib/transport/tauri-transport.ts new file mode 100644 index 0000000..b60f19b --- /dev/null +++ b/src/lib/transport/tauri-transport.ts @@ -0,0 +1,23 @@ +import type { Transport, UnsubscribeFn } from "./types" + +export class TauriTransport implements Transport { + async call( + command: string, + args?: Record + ): Promise { + const { invoke } = await import("@tauri-apps/api/core") + return invoke(command, args) + } + + async subscribe( + event: string, + handler: (payload: T) => void + ): Promise { + const { listen } = await import("@tauri-apps/api/event") + return listen(event, (e) => handler(e.payload)) + } + + isDesktop(): boolean { + return true + } +} diff --git a/src/lib/transport/types.ts b/src/lib/transport/types.ts new file mode 100644 index 0000000..d3b91d8 --- /dev/null +++ b/src/lib/transport/types.ts @@ -0,0 +1,22 @@ +export type UnsubscribeFn = () => void + +export interface Transport { + /** + * Invoke a backend command (replaces Tauri's invoke()). + */ + call(command: string, args?: Record): Promise + + /** + * Subscribe to a backend event stream (replaces Tauri's listen()). + * Returns an unsubscribe function. + */ + subscribe( + event: string, + handler: (payload: T) => void + ): Promise + + /** + * Whether the app is running in a desktop Tauri environment. + */ + isDesktop(): boolean +} diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts new file mode 100644 index 0000000..0ea9560 --- /dev/null +++ b/src/lib/transport/web-transport.ts @@ -0,0 +1,130 @@ +import type { Transport, UnsubscribeFn } from "./types" + +interface WebEvent { + channel: string + payload: unknown +} + +function getToken(): string { + return localStorage.getItem("codeg_token") ?? "" +} + +export class WebTransport implements Transport { + private ws: WebSocket | null = null + private handlers = new Map void>>() + private baseUrl: string + private reconnectTimer: ReturnType | null = null + private wsFailCount = 0 + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + async call( + command: string, + args?: Record + ): Promise { + const token = getToken() + const res = await fetch(`${this.baseUrl}/api/${command}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(args ?? {}), + }) + if (res.status === 401) { + WebTransport.redirectToLogin() + throw new Error("Unauthorized") + } + if (!res.ok) { + const error = await res.json().catch(() => ({ + code: "network_error", + message: `HTTP ${res.status}`, + })) + throw error + } + return res.json() + } + + async subscribe( + event: string, + handler: (payload: T) => void + ): Promise { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()) + } + const wrappedHandler = handler as (payload: unknown) => void + this.handlers.get(event)!.add(wrappedHandler) + + // If WS is not connected but we now have a token, connect + if (!this.ws && getToken()) { + this.connectWs() + } + + return () => { + this.handlers.get(event)?.delete(wrappedHandler) + } + } + + isDesktop(): boolean { + return false + } + + private static redirectToLogin() { + if (window.location.pathname.startsWith("/login")) return + localStorage.removeItem("codeg_token") + window.location.href = "/login" + } + + private connectWs() { + const token = getToken() + if (!token) return + + const wsUrl = + this.baseUrl.replace(/^http/, "ws") + + `/ws/events?token=${encodeURIComponent(token)}` + this.ws = new WebSocket(wsUrl) + + this.ws.onopen = () => { + this.wsFailCount = 0 + } + + this.ws.onmessage = (msg) => { + try { + const event = JSON.parse(msg.data) as WebEvent + const handlers = this.handlers.get(event.channel) + if (handlers) { + for (const h of handlers) { + h(event.payload) + } + } + } catch { + // ignore malformed messages + } + } + + this.ws.onclose = () => { + this.ws = null + this.wsFailCount++ + if (this.wsFailCount >= 3) { + WebTransport.redirectToLogin() + return + } + this.reconnectTimer = setTimeout(() => this.connectWs(), 3000) + } + + this.ws.onerror = () => { + this.ws?.close() + } + } + + destroy() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + } + this.ws?.close() + this.ws = null + this.handlers.clear() + } +} diff --git a/src/lib/updater.ts b/src/lib/updater.ts index b31f5db..2016368 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -1,6 +1,6 @@ -import { getVersion } from "@tauri-apps/api/app" -import { relaunch } from "@tauri-apps/plugin-process" -import { check, type Update } from "@tauri-apps/plugin-updater" +// All updater imports are dynamic to avoid crashing in non-Tauri browsers. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Update = any export interface AppUpdateCheckResult { currentVersion: string @@ -20,23 +20,31 @@ export interface AppUpdateErrorInfo { } export async function getCurrentAppVersion(): Promise { - return getVersion() + try { + const { getVersion } = await import("@tauri-apps/api/app") + return await getVersion() + } catch { + return "web" + } } export async function checkAppUpdate(): Promise { + const { getVersion } = await import("@tauri-apps/api/app") + const { check } = await import("@tauri-apps/plugin-updater") const [currentVersion, update] = await Promise.all([getVersion(), check()]) return { currentVersion, update } } -export async function installAppUpdate(update: Update): Promise { +export async function installAppUpdate(update: NonNullable): Promise { await update.downloadAndInstall() } export async function relaunchApp(): Promise { + const { relaunch } = await import("@tauri-apps/plugin-process") await relaunch() } -export async function closeAppUpdate(update: Update): Promise { +export async function closeAppUpdate(update: NonNullable): Promise { await update.close() }