初始化web服务功能
This commit is contained in:
Generated
+149
@@ -357,6 +357,61 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -795,6 +850,7 @@ name = "codeg"
|
|||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol-schema",
|
"agent-client-protocol-schema",
|
||||||
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -827,6 +883,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -1154,6 +1211,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -2268,12 +2331,24 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -2288,6 +2363,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -3014,6 +3090,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3054,6 +3136,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -5247,6 +5339,17 @@ dependencies = [
|
|||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -6628,6 +6731,18 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -6751,6 +6866,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6761,14 +6877,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6858,6 +6984,23 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -6922,6 +7065,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ dirs = "6"
|
|||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
sacp = "11.0.0-alpha.1"
|
sacp = "11.0.0-alpha.1"
|
||||||
sacp-tokio = "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"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
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"] }
|
kill_tree = { version = "0.2", features = ["tokio"] }
|
||||||
which = "7"
|
which = "7"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ use sacp::{
|
|||||||
on_receive_request, Agent, Client, ConnectionTo, Responder, SessionMessage, UntypedMessage,
|
on_receive_request, Agent, Client, ConnectionTo, Responder, SessionMessage, UntypedMessage,
|
||||||
};
|
};
|
||||||
use sacp_tokio::AcpAgent;
|
use sacp_tokio::AcpAgent;
|
||||||
use tauri::Emitter;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::acp::error::AcpError;
|
use crate::acp::error::AcpError;
|
||||||
@@ -195,7 +194,8 @@ async fn build_agent(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.is_some();
|
.is_some();
|
||||||
if !has_cached_binary {
|
if !has_cached_binary {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -237,7 +237,8 @@ pub async fn spawn_agent_connection(
|
|||||||
owner_window_label: String,
|
owner_window_label: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<AgentConnection, AcpError> {
|
) -> Result<AgentConnection, AcpError> {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: connection_id.clone(),
|
connection_id: connection_id.clone(),
|
||||||
@@ -263,7 +264,8 @@ pub async fn spawn_agent_connection(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.clone(),
|
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",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: conn_id,
|
connection_id: conn_id,
|
||||||
@@ -315,7 +318,8 @@ fn emit_session_modes(
|
|||||||
modes: &Option<SessionModeState>,
|
modes: &Option<SessionModeState>,
|
||||||
) {
|
) {
|
||||||
if let Some(mode_state) = modes {
|
if let Some(mode_state) = modes {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionModes {
|
AcpEvent::SessionModes {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -416,7 +420,8 @@ fn emit_session_config_options_values(
|
|||||||
config_options: Vec<SessionConfigOption>,
|
config_options: Vec<SessionConfigOption>,
|
||||||
) {
|
) {
|
||||||
let mapped = map_session_config_options(&config_options);
|
let mapped = map_session_config_options(&config_options);
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionConfigOptions {
|
AcpEvent::SessionConfigOptions {
|
||||||
connection_id: connection_id.into(),
|
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) {
|
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",
|
"acp://event",
|
||||||
AcpEvent::SelectorsReady {
|
AcpEvent::SelectorsReady {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -452,7 +458,8 @@ fn emit_prompt_capabilities(
|
|||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
capabilities: &sacp::schema::PromptCapabilities,
|
capabilities: &sacp::schema::PromptCapabilities,
|
||||||
) {
|
) {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::PromptCapabilities {
|
AcpEvent::PromptCapabilities {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -627,7 +634,8 @@ async fn run_connection(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Emit fork support capability
|
// Emit fork support capability
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ForkSupported {
|
AcpEvent::ForkSupported {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -636,7 +644,8 @@ async fn run_connection(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Emit connected status
|
// Emit connected status
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -689,7 +698,8 @@ async fn run_connection(
|
|||||||
eprintln!("[ACP] Drained {drained} historical replay notifications");
|
eprintln!("[ACP] Drained {drained} historical replay notifications");
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionStarted {
|
AcpEvent::SessionStarted {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -732,7 +742,8 @@ async fn run_connection(
|
|||||||
"[ACP] session/load failed ({}), falling back to session/new",
|
"[ACP] session/load failed ({}), falling back to session/new",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -747,7 +758,8 @@ async fn run_connection(
|
|||||||
let initial_config_options = new_resp.config_options.clone();
|
let initial_config_options = new_resp.config_options.clone();
|
||||||
let mut session =
|
let mut session =
|
||||||
cx.attach_session(new_resp, Default::default())?;
|
cx.attach_session(new_resp, Default::default())?;
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionStarted {
|
AcpEvent::SessionStarted {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -799,7 +811,8 @@ async fn run_connection(
|
|||||||
let sid = new_resp.session_id.0.to_string();
|
let sid = new_resp.session_id.0.to_string();
|
||||||
let initial_config_options = new_resp.config_options.clone();
|
let initial_config_options = new_resp.config_options.clone();
|
||||||
let mut session = cx.attach_session(new_resp, Default::default())?;
|
let mut session = cx.attach_session(new_resp, Default::default())?;
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionStarted {
|
AcpEvent::SessionStarted {
|
||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
@@ -870,7 +883,8 @@ async fn handle_permission_request(
|
|||||||
|
|
||||||
perms.lock().await.insert(request_id.clone(), responder);
|
perms.lock().await.insert(request_id.clone(), responder);
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::PermissionRequest {
|
AcpEvent::PermissionRequest {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -914,7 +928,8 @@ async fn set_session_mode(
|
|||||||
.block_task()
|
.block_task()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ModeChanged {
|
AcpEvent::ModeChanged {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1166,7 +1181,8 @@ fn emit_terminal_output_update(
|
|||||||
output: String,
|
output: String,
|
||||||
append: bool,
|
append: bool,
|
||||||
) {
|
) {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ToolCallUpdate {
|
AcpEvent::ToolCallUpdate {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1350,7 +1366,8 @@ async fn handle_fork_or_exit(
|
|||||||
.meta(fork_resp.meta);
|
.meta(fork_resp.meta);
|
||||||
let mut session = cx.attach_session(new_resp, Default::default())?;
|
let mut session = cx.attach_session(new_resp, Default::default())?;
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::SessionStarted {
|
AcpEvent::SessionStarted {
|
||||||
connection_id: conn_id.to_string(),
|
connection_id: conn_id.to_string(),
|
||||||
@@ -1430,7 +1447,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
Some(ConnectionCommand::Prompt { blocks }) => {
|
Some(ConnectionCommand::Prompt { blocks }) => {
|
||||||
let prompt_blocks = map_prompt_blocks(blocks);
|
let prompt_blocks = map_prompt_blocks(blocks);
|
||||||
if prompt_blocks.is_empty() {
|
if prompt_blocks.is_empty() {
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1440,7 +1458,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1532,7 +1551,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
StopReason::Cancelled => "cancelled",
|
StopReason::Cancelled => "cancelled",
|
||||||
_ => "unknown",
|
_ => "unknown",
|
||||||
};
|
};
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::TurnComplete {
|
AcpEvent::TurnComplete {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1562,7 +1582,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
StopReason::Cancelled => "cancelled",
|
StopReason::Cancelled => "cancelled",
|
||||||
_ => "unknown",
|
_ => "unknown",
|
||||||
};
|
};
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::TurnComplete {
|
AcpEvent::TurnComplete {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1599,7 +1620,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
let req = SetSessionModeRequest::new(sid.clone(), mode_id.clone());
|
let req = SetSessionModeRequest::new(sid.clone(), mode_id.clone());
|
||||||
match cx.send_request_to(Agent, req).block_task().await {
|
match cx.send_request_to(Agent, req).block_task().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ModeChanged {
|
AcpEvent::ModeChanged {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1608,7 +1630,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1632,7 +1655,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1665,7 +1689,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
// transitions out of "prompting" and the user can
|
// transitions out of "prompting" and the user can
|
||||||
// send new messages. Don't wait for the agent —
|
// send new messages. Don't wait for the agent —
|
||||||
// it may be slow to respond or not respond at all.
|
// it may be slow to respond or not respond at all.
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::TurnComplete {
|
AcpEvent::TurnComplete {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1715,7 +1740,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::StatusChanged {
|
AcpEvent::StatusChanged {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1736,7 +1762,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
}
|
}
|
||||||
Some(ConnectionCommand::SetMode { mode_id }) => {
|
Some(ConnectionCommand::SetMode { mode_id }) => {
|
||||||
if let Err(e) = set_session_mode(session, conn_id, handle, mode_id).await {
|
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",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1754,7 +1781,8 @@ async fn run_conversation_loop<'a>(
|
|||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
set_session_config_option(&cx, &sid, conn_id, handle, config_id, value_id).await
|
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",
|
"acp://event",
|
||||||
AcpEvent::Error {
|
AcpEvent::Error {
|
||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
@@ -1908,7 +1936,8 @@ fn emit_conversation_update(
|
|||||||
content: ContentBlock::Text(text),
|
content: ContentBlock::Text(text),
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ContentDelta {
|
AcpEvent::ContentDelta {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1923,7 +1952,8 @@ fn emit_conversation_update(
|
|||||||
content: ContentBlock::Text(text),
|
content: ContentBlock::Text(text),
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::Thinking {
|
AcpEvent::Thinking {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1938,7 +1968,8 @@ fn emit_conversation_update(
|
|||||||
let content = serialize_tool_call_content(&tc.content);
|
let content = serialize_tool_call_content(&tc.content);
|
||||||
let raw_input = json_value_to_text(&tc.raw_input);
|
let raw_input = json_value_to_text(&tc.raw_input);
|
||||||
let raw_output = json_value_to_text(&tc.raw_output);
|
let raw_output = json_value_to_text(&tc.raw_output);
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ToolCall {
|
AcpEvent::ToolCall {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1960,7 +1991,8 @@ fn emit_conversation_update(
|
|||||||
.and_then(serialize_tool_call_content);
|
.and_then(serialize_tool_call_content);
|
||||||
let raw_input = json_value_to_text(&tcu.fields.raw_input);
|
let raw_input = json_value_to_text(&tcu.fields.raw_input);
|
||||||
let raw_output = json_value_to_text(&tcu.fields.raw_output);
|
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",
|
"acp://event",
|
||||||
AcpEvent::ToolCallUpdate {
|
AcpEvent::ToolCallUpdate {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1975,7 +2007,8 @@ fn emit_conversation_update(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
SessionUpdate::CurrentModeUpdate(update) => {
|
SessionUpdate::CurrentModeUpdate(update) => {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::ModeChanged {
|
AcpEvent::ModeChanged {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -1984,7 +2017,8 @@ fn emit_conversation_update(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
SessionUpdate::Plan(plan) => {
|
SessionUpdate::Plan(plan) => {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::PlanUpdate {
|
AcpEvent::PlanUpdate {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -2011,7 +2045,8 @@ fn emit_conversation_update(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::AvailableCommands {
|
AcpEvent::AvailableCommands {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
@@ -2020,7 +2055,8 @@ fn emit_conversation_update(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
SessionUpdate::UsageUpdate(update) => {
|
SessionUpdate::UsageUpdate(update) => {
|
||||||
let _ = app_handle.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app_handle,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
AcpEvent::UsageUpdate {
|
AcpEvent::UsageUpdate {
|
||||||
connection_id: connection_id.into(),
|
connection_id: connection_id.into(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, State};
|
use tauri::State;
|
||||||
|
|
||||||
use crate::acp::binary_cache;
|
use crate::acp::binary_cache;
|
||||||
use crate::acp::error::AcpError;
|
use crate::acp::error::AcpError;
|
||||||
@@ -32,7 +32,8 @@ fn emit_acp_agents_updated(
|
|||||||
reason: &'static str,
|
reason: &'static str,
|
||||||
agent_type: Option<AgentType>,
|
agent_type: Option<AgentType>,
|
||||||
) {
|
) {
|
||||||
let _ = app.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app,
|
||||||
ACP_AGENTS_UPDATED_EVENT,
|
ACP_AGENTS_UPDATED_EVENT,
|
||||||
AcpAgentsUpdatedEventPayload { reason, agent_type },
|
AcpAgentsUpdatedEventPayload { reason, agent_type },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<AgentType>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
folder_path: Option<String>,
|
||||||
|
) -> Result<Vec<ConversationSummary>, 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<Vec<FolderInfo>, 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<AgentStats, AppCommandError> {
|
||||||
|
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<SidebarData, AppCommandError> {
|
||||||
|
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 {
|
fn parse_error_to_app_error(error: ParseError) -> AppCommandError {
|
||||||
match error {
|
match error {
|
||||||
ParseError::ConversationNotFound(id) => {
|
ParseError::ConversationNotFound(id) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
|
|||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Emitter;
|
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -985,7 +985,8 @@ pub async fn git_push(
|
|||||||
.strip_prefix("push-")
|
.strip_prefix("push-")
|
||||||
.and_then(|value| value.parse::<i32>().ok())
|
.and_then(|value| value.parse::<i32>().ok())
|
||||||
{
|
{
|
||||||
let _ = app.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&app,
|
||||||
"folder://git-push-succeeded",
|
"folder://git-push-succeeded",
|
||||||
GitPushSucceededEvent {
|
GitPushSucceededEvent {
|
||||||
folder_id,
|
folder_id,
|
||||||
@@ -1552,7 +1553,8 @@ pub async fn git_commit(
|
|||||||
.strip_prefix("commit-")
|
.strip_prefix("commit-")
|
||||||
.and_then(|value| value.parse::<i32>().ok())
|
.and_then(|value| value.parse::<i32>().ok())
|
||||||
{
|
{
|
||||||
let _ = app.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
&app,
|
||||||
"folder://git-commit-succeeded",
|
"folder://git-commit-succeeded",
|
||||||
GitCommitSucceededEvent {
|
GitCommitSucceededEvent {
|
||||||
folder_id,
|
folder_id,
|
||||||
@@ -2384,7 +2386,7 @@ impl WatchEventBatch {
|
|||||||
full_reload: self.overflowed,
|
full_reload: self.overflowed,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = app.emit("folder://file-tree-changed", payload);
|
crate::web::event_bridge::emit_event(app, "folder://file-tree-changed", payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use tauri::{Emitter, State};
|
use tauri::State;
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
use crate::app_error::AppCommandError;
|
||||||
use crate::db::service::app_metadata_service;
|
use crate::db::service::app_metadata_service;
|
||||||
@@ -130,7 +130,7 @@ pub async fn update_system_language_settings(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppCommandError::from)?;
|
.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)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
use crate::app_error::AppCommandError;
|
||||||
use crate::db::AppDatabase;
|
use crate::db::AppDatabase;
|
||||||
@@ -534,7 +534,8 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) {
|
|||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = app.emit(
|
crate::web::event_bridge::emit_event(
|
||||||
|
app,
|
||||||
"folder://merge-aborted",
|
"folder://merge-aborted",
|
||||||
serde_json::json!({ "folder_id": folder_id }),
|
serde_json::json!({ "folder_id": folder_id }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod network;
|
|||||||
mod parsers;
|
mod parsers;
|
||||||
mod process;
|
mod process;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ pub fn run() {
|
|||||||
.manage(windows::SettingsWindowState::new())
|
.manage(windows::SettingsWindowState::new())
|
||||||
.manage(windows::CommitWindowState::new())
|
.manage(windows::CommitWindowState::new())
|
||||||
.manage(windows::MergeWindowState::new())
|
.manage(windows::MergeWindowState::new())
|
||||||
|
.manage(web::WebServerState::new())
|
||||||
|
.manage(web::event_bridge::WebEventBroadcaster::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_data_dir = app.path().app_data_dir()?;
|
let app_data_dir = app.path().app_data_dir()?;
|
||||||
let app_version = env!("CARGO_PKG_VERSION");
|
let app_version = env!("CARGO_PKG_VERSION");
|
||||||
@@ -322,12 +325,19 @@ pub fn run() {
|
|||||||
mcp_commands::mcp_set_server_apps,
|
mcp_commands::mcp_set_server_apps,
|
||||||
mcp_commands::mcp_remove_server,
|
mcp_commands::mcp_remove_server,
|
||||||
notification::send_notification,
|
notification::send_notification,
|
||||||
|
web::start_web_server,
|
||||||
|
web::stop_web_server,
|
||||||
|
web::get_web_server_status,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app, event| {
|
.run(|app, event| {
|
||||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||||
APP_QUITTING.store(true, Ordering::Relaxed);
|
APP_QUITTING.store(true, Ordering::Relaxed);
|
||||||
|
// Stop the embedded web server if running.
|
||||||
|
if let Some(ws) = app.try_state::<web::WebServerState>() {
|
||||||
|
let _ = tauri::async_runtime::block_on(web::stop_web_server(ws));
|
||||||
|
}
|
||||||
// Kill all terminal sessions to prevent orphaned processes.
|
// Kill all terminal sessions to prevent orphaned processes.
|
||||||
if let Some(tm) = app.try_state::<TerminalManager>() {
|
if let Some(tm) = app.try_state::<TerminalManager>() {
|
||||||
tm.kill_all();
|
tm.kill_all();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use std::sync::mpsc;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||||
use tauri::Emitter;
|
|
||||||
|
|
||||||
use super::error::TerminalError;
|
use super::error::TerminalError;
|
||||||
use super::types::{TerminalEvent, TerminalInfo};
|
use super::types::{TerminalEvent, TerminalInfo};
|
||||||
@@ -408,7 +407,7 @@ fn read_loop(
|
|||||||
terminal_id: terminal_id.clone(),
|
terminal_id: terminal_id.clone(),
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
let _ = app_handle.emit(&output_event, &event);
|
crate::web::event_bridge::emit_event(app_handle, &output_event, event.clone());
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
@@ -428,5 +427,5 @@ fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) {
|
|||||||
terminal_id: terminal_id.to_string(),
|
terminal_id: terminal_id.to_string(),
|
||||||
data: String::new(),
|
data: String::new(),
|
||||||
};
|
};
|
||||||
let _ = app_handle.emit(&exit_event, &event);
|
crate::web::event_bridge::emit_event(app_handle, &exit_event, event.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<WebEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WebEvent> {
|
||||||
|
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::<WebEventBroadcaster>() {
|
||||||
|
web.send(event, &payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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<AgentType>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_folder_conversations(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<ListFolderConversationsParams>,
|
||||||
|
) -> Result<Json<Vec<DbConversationSummary>>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<AgentType>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub folder_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_conversations(
|
||||||
|
Json(params): Json<ListConversationsParams>,
|
||||||
|
) -> Result<Json<Vec<ConversationSummary>>, 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<GetConversationParams>,
|
||||||
|
) -> Result<Json<ConversationDetail>, AppCommandError> {
|
||||||
|
let at = params.agent_type;
|
||||||
|
let cid = params.conversation_id;
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> Result<ConversationDetail, AppCommandError> {
|
||||||
|
let parser: Box<dyn AgentParser> = 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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<GetFolderConversationParams>,
|
||||||
|
) -> Result<Json<DbConversationDetail>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<dyn AgentParser> = 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::<String>)),
|
||||||
|
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<Json<Vec<FolderInfo>>, AppCommandError> {
|
||||||
|
let result = crate::commands::conversations::list_folders_for_web().await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_stats() -> Result<Json<AgentStats>, AppCommandError> {
|
||||||
|
let result = crate::commands::conversations::get_stats_for_web().await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_sidebar_data() -> Result<Json<SidebarData>, 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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<ImportLocalConversationsParams>,
|
||||||
|
) -> Result<Json<ImportResult>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_conversation(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<CreateConversationParams>,
|
||||||
|
) -> Result<Json<i32>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<UpdateConversationStatusParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<UpdateConversationTitleParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<DeleteConversationParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
conversation_service::soft_delete(&db.conn, params.conversation_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Folder commands web handlers.
|
||||||
|
// TODO: Implement folder command CRUD handlers for web mode.
|
||||||
@@ -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<tauri::AppHandle>,
|
||||||
|
) -> Result<Json<Vec<FolderHistoryEntry>>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
let result = folder_service::list_folders(&db.conn)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_folder(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<FolderIdParams>,
|
||||||
|
) -> Result<Json<FolderDetail>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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<tauri::AppHandle>,
|
||||||
|
Json(params): Json<AddFolderParams>,
|
||||||
|
) -> Result<Json<FolderHistoryEntry>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// MCP (Model Context Protocol) web handlers.
|
||||||
|
// TODO: Implement MCP marketplace and server management handlers for web mode.
|
||||||
@@ -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;
|
||||||
@@ -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<tauri::AppHandle>,
|
||||||
|
) -> Result<Json<SystemProxySettings>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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::<SystemProxySettings>(&v).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_system_language_settings(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
) -> Result<Json<SystemLanguageSettings>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
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::<SystemLanguageSettings>(&v).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(Json(settings))
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Terminal web handlers.
|
||||||
|
// TODO: Implement terminal handlers for web mode.
|
||||||
|
// Terminal I/O streams over WebSocket instead of Tauri events.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Version control web handlers.
|
||||||
|
// TODO: Implement git settings and GitHub account handlers for web mode.
|
||||||
@@ -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<Option<tauri::async_runtime::JoinHandle<()>>>,
|
||||||
|
port: AtomicU16,
|
||||||
|
token: Mutex<String>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<u16>,
|
||||||
|
host: Option<String>,
|
||||||
|
) -> Result<WebServerInfo, AppCommandError> {
|
||||||
|
// 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<Option<WebServerInfo>, 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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::<Uri>() {
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<tauri::AppHandle>,
|
||||||
|
) -> 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::<WebEventBroadcaster>();
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
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 { Loader2 } from "lucide-react"
|
||||||
import { CommitWorkspace } from "@/components/layout/commit-dialog"
|
import { CommitWorkspace } from "@/components/layout/commit-dialog"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { getFolder } from "@/lib/tauri"
|
import { getFolder } from "@/lib/api"
|
||||||
import type { FolderDetail } from "@/lib/types"
|
import type { FolderDetail } from "@/lib/types"
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
const TOAST_DURATION_MS = 6000
|
||||||
@@ -35,12 +35,13 @@ function CommitPageInner() {
|
|||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||||
|
|
||||||
const closeWindow = useCallback(() => {
|
const closeWindow = useCallback(async () => {
|
||||||
getCurrentWindow()
|
try {
|
||||||
.close()
|
const win = await getCurrentWindow()
|
||||||
.catch((err) => {
|
await win.close()
|
||||||
|
} catch (err) {
|
||||||
console.error("[CommitPage] failed to close window:", err)
|
console.error("[CommitPage] failed to close window:", err)
|
||||||
})
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<div className="w-full max-w-sm space-y-6 px-4">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Codeg</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
输入访问 Token 以连接到桌面端
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!token || loading}
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "连接中..." : "连接"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Token 可在桌面端 设置 → Web 服务 中获取
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
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 { Loader2 } from "lucide-react"
|
||||||
import { MergeWorkspace } from "@/components/merge/merge-workspace"
|
import { MergeWorkspace } from "@/components/merge/merge-workspace"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { getFolder } from "@/lib/tauri"
|
import { getFolder } from "@/lib/api"
|
||||||
import type { FolderDetail } from "@/lib/types"
|
import type { FolderDetail } from "@/lib/types"
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
const TOAST_DURATION_MS = 6000
|
||||||
@@ -37,12 +37,13 @@ function MergePageInner() {
|
|||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||||
|
|
||||||
const closeWindow = useCallback(() => {
|
const closeWindow = useCallback(async () => {
|
||||||
getCurrentWindow()
|
try {
|
||||||
.close()
|
const win = await getCurrentWindow()
|
||||||
.catch((err) => {
|
await win.close()
|
||||||
|
} catch (err) {
|
||||||
console.error("[MergePage] failed to close window:", err)
|
console.error("[MergePage] failed to close window:", err)
|
||||||
})
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,11 +2,43 @@
|
|||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { isDesktop } from "@/lib/platform"
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDesktop()) {
|
||||||
router.replace("/welcome")
|
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])
|
}, [router])
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
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 { Loader2 } from "lucide-react"
|
||||||
import { PushWorkspace } from "@/components/layout/push-workspace"
|
import { PushWorkspace } from "@/components/layout/push-workspace"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { getFolder } from "@/lib/tauri"
|
import { getFolder } from "@/lib/api"
|
||||||
import type { FolderDetail } from "@/lib/types"
|
import type { FolderDetail } from "@/lib/types"
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
const TOAST_DURATION_MS = 6000
|
||||||
@@ -28,12 +28,13 @@ function PushPageInner() {
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const closeWindow = useCallback(() => {
|
const closeWindow = useCallback(async () => {
|
||||||
getCurrentWindow()
|
try {
|
||||||
.close()
|
const win = await getCurrentWindow()
|
||||||
.catch((err) => {
|
await win.close()
|
||||||
|
} catch (err) {
|
||||||
console.error("[PushPage] failed to close window:", err)
|
console.error("[PushPage] failed to close window:", err)
|
||||||
})
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WebServiceSettings } from "@/components/settings/web-service-settings"
|
||||||
|
|
||||||
|
export default function SettingsWebServicePage() {
|
||||||
|
return <WebServiceSettings />
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { getFolder } from "@/lib/tauri"
|
import { getFolder } from "@/lib/api"
|
||||||
import type { FolderDetail } from "@/lib/types"
|
import type { FolderDetail } from "@/lib/types"
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
const TOAST_DURATION_MS = 6000
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react"
|
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 type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { acpListAgents } from "@/lib/tauri"
|
import { acpListAgents } from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
||||||
import { AGENT_LABELS } from "@/lib/types"
|
import { AGENT_LABELS } from "@/lib/types"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { TauriEvent } from "@tauri-apps/api/event"
|
import { isDesktop } from "@/lib/platform"
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview"
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -34,7 +32,8 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
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 { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import type {
|
import type {
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
@@ -751,10 +750,9 @@ export function MessageInput({
|
|||||||
const handlePickFiles = useCallback(async () => {
|
const handlePickFiles = useCallback(async () => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
try {
|
try {
|
||||||
const selected = await open({
|
const selected = await openFileDialog({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
directory: false,
|
directory: false,
|
||||||
defaultPath: defaultPath || undefined,
|
|
||||||
})
|
})
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
const picked = Array.isArray(selected) ? selected : [selected]
|
const picked = Array.isArray(selected) ? selected : [selected]
|
||||||
@@ -846,6 +844,9 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setup = async () => {
|
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()
|
const webview = getCurrentWebview()
|
||||||
try {
|
try {
|
||||||
const unlistenEnter = await webview.listen<{
|
const unlistenEnter = await webview.listen<{
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
updateConversationExternalId,
|
updateConversationExternalId,
|
||||||
updateConversationStatus,
|
updateConversationStatus,
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||||
import { useConversationDetail } from "@/hooks/use-conversation-detail"
|
import { useConversationDetail } from "@/hooks/use-conversation-detail"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getFileTree,
|
getFileTree,
|
||||||
listFolderConversations,
|
listFolderConversations,
|
||||||
readFilePreview,
|
readFilePreview,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
ConversationStatus,
|
ConversationStatus,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
updateConversationStatus,
|
updateConversationStatus,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||||
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
||||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { code } from "@streamdown/code"
|
|||||||
import { math } from "@streamdown/math"
|
import { math } from "@streamdown/math"
|
||||||
import { mermaid } from "@streamdown/mermaid"
|
import { mermaid } from "@streamdown/mermaid"
|
||||||
import { Streamdown } from "streamdown"
|
import { Streamdown } from "streamdown"
|
||||||
import { readFileBase64 } from "@/lib/tauri"
|
import { readFileBase64 } from "@/lib/api"
|
||||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||||
import "@/lib/monaco-local"
|
import "@/lib/monaco-local"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
|
|||||||
import { Reorder } from "motion/react"
|
import { Reorder } from "motion/react"
|
||||||
import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react"
|
import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { openPath } from "@tauri-apps/plugin-opener"
|
import { openPath } from "@/lib/platform"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
toIntlLocale,
|
toIntlLocale,
|
||||||
type IntlLocale,
|
type IntlLocale,
|
||||||
} from "@/lib/i18n"
|
} from "@/lib/i18n"
|
||||||
import { getSystemLanguageSettings } from "@/lib/tauri"
|
import { getSystemLanguageSettings } from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
||||||
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
import { revealItemInDir, subscribe } from "@/lib/platform"
|
||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener"
|
|
||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
import { Check, ChevronRight } from "lucide-react"
|
import { Check, ChevronRight } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
@@ -36,8 +35,7 @@ import {
|
|||||||
saveFileCopy,
|
saveFileCopy,
|
||||||
startFileTreeWatch,
|
startFileTreeWatch,
|
||||||
stopFileTreeWatch,
|
stopFileTreeWatch,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import {
|
import {
|
||||||
emitAttachFileToSession,
|
emitAttachFileToSession,
|
||||||
emitAppendTextToSession,
|
emitAppendTextToSession,
|
||||||
@@ -1907,7 +1905,7 @@ export function FileTreeTab() {
|
|||||||
const rootPath = folder?.path
|
const rootPath = folder?.path
|
||||||
if (!rootPath) return
|
if (!rootPath) return
|
||||||
|
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: (() => void) | null = null
|
||||||
const normalizedRootPath = normalizeComparePath(rootPath)
|
const normalizedRootPath = normalizeComparePath(rootPath)
|
||||||
|
|
||||||
const scheduleTreeRefresh = (refreshGitStatus: boolean) => {
|
const scheduleTreeRefresh = (refreshGitStatus: boolean) => {
|
||||||
@@ -2046,20 +2044,17 @@ export function FileTreeTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlisten = await listen<FileTreeChangedEvent>(
|
unlisten = await subscribe<FileTreeChangedEvent>(
|
||||||
"folder://file-tree-changed",
|
"folder://file-tree-changed",
|
||||||
(event) => {
|
(payload) => {
|
||||||
if (
|
if (
|
||||||
normalizeComparePath(event.payload.root_path) !==
|
normalizeComparePath(payload.root_path) !== normalizedRootPath
|
||||||
normalizedRootPath
|
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedPaths =
|
const changedPaths = payload.changed_paths.map(normalizeComparePath)
|
||||||
event.payload.changed_paths.map(normalizeComparePath)
|
const shouldRefreshGitStatus = payload.refresh_git_status ?? true
|
||||||
const shouldRefreshGitStatus =
|
|
||||||
event.payload.refresh_git_status ?? true
|
|
||||||
const nonGitChangedPaths = changedPaths.filter(
|
const nonGitChangedPaths = changedPaths.filter(
|
||||||
(path) => !isGitMetadataPath(path)
|
(path) => !isGitMetadataPath(path)
|
||||||
)
|
)
|
||||||
@@ -2069,13 +2064,13 @@ export function FileTreeTab() {
|
|||||||
(path) => !filePathSetRef.current.has(path)
|
(path) => !filePathSetRef.current.has(path)
|
||||||
)
|
)
|
||||||
const needsTreeRefresh =
|
const needsTreeRefresh =
|
||||||
event.payload.full_reload ||
|
payload.full_reload ||
|
||||||
(!onlyGitMetadataChanges &&
|
(!onlyGitMetadataChanges &&
|
||||||
(event.payload.kind !== "modify" ||
|
(payload.kind !== "modify" ||
|
||||||
nonGitChangedPaths.length === 0 ||
|
nonGitChangedPaths.length === 0 ||
|
||||||
hasUnknownPath))
|
hasUnknownPath))
|
||||||
|
|
||||||
if (onlyGitMetadataChanges && !event.payload.full_reload) {
|
if (onlyGitMetadataChanges && !payload.full_reload) {
|
||||||
if (shouldRefreshGitStatus) {
|
if (shouldRefreshGitStatus) {
|
||||||
scheduleStatusRefresh()
|
scheduleStatusRefresh()
|
||||||
}
|
}
|
||||||
@@ -2085,13 +2080,13 @@ export function FileTreeTab() {
|
|||||||
scheduleStatusRefresh()
|
scheduleStatusRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onlyGitMetadataChanges && !event.payload.full_reload) {
|
if (onlyGitMetadataChanges && !payload.full_reload) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedActivePath = getActiveChangedFilePath(
|
const changedActivePath = getActiveChangedFilePath(
|
||||||
nonGitChangedPaths,
|
nonGitChangedPaths,
|
||||||
event.payload.full_reload
|
payload.full_reload
|
||||||
)
|
)
|
||||||
if (!changedActivePath) return
|
if (!changedActivePath) return
|
||||||
|
|
||||||
@@ -2145,7 +2140,7 @@ export function FileTreeTab() {
|
|||||||
pendingTreeRefreshRef.current = false
|
pendingTreeRefreshRef.current = false
|
||||||
pendingTreeRefreshNeedsStatusRef.current = false
|
pendingTreeRefreshNeedsStatusRef.current = false
|
||||||
pendingStatusRefreshRef.current = false
|
pendingStatusRefreshRef.current = false
|
||||||
disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged")
|
unlisten?.()
|
||||||
void stopFileTreeWatch(rootPath)
|
void stopFileTreeWatch(rootPath)
|
||||||
}
|
}
|
||||||
}, [fetchTree, folder?.path, openFilePreview, t])
|
}, [fetchTree, folder?.path, openFilePreview, t])
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
import { subscribe } from "@/lib/platform"
|
||||||
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
|
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -46,8 +46,7 @@ import {
|
|||||||
openCommitWindow,
|
openCommitWindow,
|
||||||
startFileTreeWatch,
|
startFileTreeWatch,
|
||||||
stopFileTreeWatch,
|
stopFileTreeWatch,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
|
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -611,7 +610,7 @@ export function GitChangesTab() {
|
|||||||
const rootPath = folder?.path
|
const rootPath = folder?.path
|
||||||
if (!rootPath || !isChangesTabActive) return
|
if (!rootPath || !isChangesTabActive) return
|
||||||
|
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: (() => void) | null = null
|
||||||
const normalizedRootPath = normalizeComparePath(rootPath)
|
const normalizedRootPath = normalizeComparePath(rootPath)
|
||||||
|
|
||||||
const scheduleRefresh = () => {
|
const scheduleRefresh = () => {
|
||||||
@@ -631,16 +630,15 @@ export function GitChangesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlisten = await listen<FileTreeChangedEvent>(
|
unlisten = await subscribe<FileTreeChangedEvent>(
|
||||||
"folder://file-tree-changed",
|
"folder://file-tree-changed",
|
||||||
(event) => {
|
(payload) => {
|
||||||
if (
|
if (
|
||||||
normalizeComparePath(event.payload.root_path) !==
|
normalizeComparePath(payload.root_path) !== normalizedRootPath
|
||||||
normalizedRootPath
|
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!shouldRefreshFromEvent(event.payload)) return
|
if (!shouldRefreshFromEvent(payload)) return
|
||||||
scheduleRefresh()
|
scheduleRefresh()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -656,7 +654,7 @@ export function GitChangesTab() {
|
|||||||
clearTimeout(refreshTimerRef.current)
|
clearTimeout(refreshTimerRef.current)
|
||||||
refreshTimerRef.current = null
|
refreshTimerRef.current = null
|
||||||
}
|
}
|
||||||
disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged")
|
unlisten?.()
|
||||||
void stopFileTreeWatch(rootPath)
|
void stopFileTreeWatch(rootPath)
|
||||||
}
|
}
|
||||||
}, [fetchChanges, folder?.path, isChangesTabActive])
|
}, [fetchChanges, folder?.path, isChangesTabActive])
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
import { subscribe } from "@/lib/platform"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import {
|
import {
|
||||||
@@ -86,7 +85,7 @@ import {
|
|||||||
gitLog,
|
gitLog,
|
||||||
gitNewBranch,
|
gitNewBranch,
|
||||||
openPushWindow,
|
openPushWindow,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
|
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
@@ -874,11 +873,11 @@ export function GitLogTab() {
|
|||||||
"folder://git-push-succeeded",
|
"folder://git-push-succeeded",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const unlistens: (UnlistenFn | null)[] = events.map(() => null)
|
const unlistens: ((() => void) | null)[] = events.map(() => null)
|
||||||
|
|
||||||
events.forEach((eventName, i) => {
|
events.forEach((eventName, i) => {
|
||||||
listen<{ folder_id: number }>(eventName, (event) => {
|
subscribe<{ folder_id: number }>(eventName, (payload) => {
|
||||||
if (event.payload.folder_id !== folder.id) return
|
if (payload.folder_id !== folder.id) return
|
||||||
void refreshBranches()
|
void refreshBranches()
|
||||||
void fetchLog({ inline: true })
|
void fetchLog({ inline: true })
|
||||||
})
|
})
|
||||||
@@ -891,8 +890,8 @@ export function GitLogTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
events.forEach((eventName, i) => {
|
events.forEach((_eventName, i) => {
|
||||||
disposeTauriListener(unlistens[i], `GitLogTab.${eventName}`)
|
unlistens[i]?.()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [folder, refreshBranches, fetchLog])
|
}, [folder, refreshBranches, fetchLog])
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
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 {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -62,7 +68,6 @@ import { useTranslations } from "next-intl"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import {
|
import {
|
||||||
gitInit,
|
gitInit,
|
||||||
gitPull,
|
gitPull,
|
||||||
@@ -79,11 +84,10 @@ import {
|
|||||||
setFolderParentBranch,
|
setFolderParentBranch,
|
||||||
openStashWindow,
|
openStashWindow,
|
||||||
openPushWindow,
|
openPushWindow,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||||
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
||||||
import { StashDialog } from "@/components/layout/stash-dialog"
|
import { StashDialog } from "@/components/layout/stash-dialog"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -167,15 +171,15 @@ export function BranchDropdown({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
|
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
listen<GitCommitSucceededEventPayload>(
|
subscribe<GitCommitSucceededEventPayload>(
|
||||||
"folder://git-commit-succeeded",
|
"folder://git-commit-succeeded",
|
||||||
(event) => {
|
(payload) => {
|
||||||
if (event.payload.folder_id !== folder.id) return
|
if (payload.folder_id !== folder.id) return
|
||||||
toast.success(t("toasts.commitCodeCompleted"), {
|
toast.success(t("toasts.commitCodeCompleted"), {
|
||||||
description: t("toasts.committedFiles", {
|
description: t("toasts.committedFiles", {
|
||||||
count: event.payload.committed_files,
|
count: payload.committed_files,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
onBranchChange()
|
onBranchChange()
|
||||||
@@ -189,20 +193,20 @@ export function BranchDropdown({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded")
|
unlisten?.()
|
||||||
}
|
}
|
||||||
}, [folder, onBranchChange, t])
|
}, [folder, onBranchChange, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
|
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
listen<GitPushSucceededEventPayload>(
|
subscribe<GitPushSucceededEventPayload>(
|
||||||
"folder://git-push-succeeded",
|
"folder://git-push-succeeded",
|
||||||
(event) => {
|
(payload) => {
|
||||||
if (event.payload.folder_id !== folder.id) return
|
if (payload.folder_id !== folder.id) return
|
||||||
const { pushed_commits, upstream_set } = event.payload
|
const { pushed_commits, upstream_set } = payload
|
||||||
let description: string
|
let description: string
|
||||||
if (upstream_set) {
|
if (upstream_set) {
|
||||||
description =
|
description =
|
||||||
@@ -226,7 +230,7 @@ export function BranchDropdown({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposeTauriListener(unlisten, "BranchDropdown.gitPushSucceeded")
|
unlisten?.()
|
||||||
}
|
}
|
||||||
}, [folder, onBranchChange, t])
|
}, [folder, onBranchChange, t])
|
||||||
|
|
||||||
@@ -245,7 +249,7 @@ export function BranchDropdown({
|
|||||||
const successDescription = getSuccessDescription?.(result)
|
const successDescription = getSuccessDescription?.(result)
|
||||||
updateTask(taskId, { status: "completed" })
|
updateTask(taskId, { status: "completed" })
|
||||||
onBranchChange()
|
onBranchChange()
|
||||||
void emit("folder://git-branch-changed", {
|
void emitEvent("folder://git-branch-changed", {
|
||||||
folder_id: folder?.id,
|
folder_id: folder?.id,
|
||||||
})
|
})
|
||||||
if (successDescription !== false) {
|
if (successDescription !== false) {
|
||||||
@@ -326,9 +330,11 @@ export function BranchDropdown({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleBrowseWorktreePath() {
|
async function handleBrowseWorktreePath() {
|
||||||
const selected = await open({ directory: true, multiple: false })
|
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setWorktreePath(selected)
|
setWorktreePath(
|
||||||
|
Array.isArray(selected) ? selected[0] : selected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"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 { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||||
import { ChevronDown, Play, Plus, Square } from "lucide-react"
|
import { ChevronDown, Play, Plus, Square } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
listFolderCommands,
|
listFolderCommands,
|
||||||
terminalKill,
|
terminalKill,
|
||||||
terminalList,
|
terminalList,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import type { FolderCommand, TerminalEvent } from "@/lib/types"
|
import type { FolderCommand, TerminalEvent } from "@/lib/types"
|
||||||
import { CommandManageDialog } from "./command-manage-dialog"
|
import { CommandManageDialog } from "./command-manage-dialog"
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ export function CommandDropdown() {
|
|||||||
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
|
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
|
||||||
Record<number, string>
|
Record<number, string>
|
||||||
>({})
|
>({})
|
||||||
const exitUnlistenersRef = useRef<Map<string, UnlistenFn>>(new Map())
|
const exitUnlistenersRef = useRef<Map<string, () => void>>(new Map())
|
||||||
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
|
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
|
||||||
|
|
||||||
const folderId = folder?.id ?? 0
|
const folderId = folder?.id ?? 0
|
||||||
@@ -67,7 +66,7 @@ export function CommandDropdown() {
|
|||||||
const clearRunningByTerminalId = useCallback((terminalId: string) => {
|
const clearRunningByTerminalId = useCallback((terminalId: string) => {
|
||||||
const unlisten = exitUnlistenersRef.current.get(terminalId)
|
const unlisten = exitUnlistenersRef.current.get(terminalId)
|
||||||
if (unlisten) {
|
if (unlisten) {
|
||||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
unlisten()
|
||||||
exitUnlistenersRef.current.delete(terminalId)
|
exitUnlistenersRef.current.delete(terminalId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ export function CommandDropdown() {
|
|||||||
|
|
||||||
const clearAllRunningStates = useCallback(() => {
|
const clearAllRunningStates = useCallback(() => {
|
||||||
for (const unlisten of exitUnlistenersRef.current.values()) {
|
for (const unlisten of exitUnlistenersRef.current.values()) {
|
||||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
unlisten()
|
||||||
}
|
}
|
||||||
exitUnlistenersRef.current.clear()
|
exitUnlistenersRef.current.clear()
|
||||||
setRunningCommandTerminals({})
|
setRunningCommandTerminals({})
|
||||||
@@ -165,7 +164,7 @@ export function CommandDropdown() {
|
|||||||
async (terminalId: string) => {
|
async (terminalId: string) => {
|
||||||
if (exitUnlistenersRef.current.has(terminalId)) return
|
if (exitUnlistenersRef.current.has(terminalId)) return
|
||||||
try {
|
try {
|
||||||
const unlisten = await listen<TerminalEvent>(
|
const unlisten = await subscribe<TerminalEvent>(
|
||||||
`terminal://exit/${terminalId}`,
|
`terminal://exit/${terminalId}`,
|
||||||
() => {
|
() => {
|
||||||
clearRunningByTerminalId(terminalId)
|
clearRunningByTerminalId(terminalId)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
createFolderCommand,
|
createFolderCommand,
|
||||||
updateFolderCommand,
|
updateFolderCommand,
|
||||||
deleteFolderCommand,
|
deleteFolderCommand,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
|
|
||||||
interface CommandDraft {
|
interface CommandDraft {
|
||||||
id: number | null
|
id: number | null
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
gitStatus,
|
gitStatus,
|
||||||
deleteFileTreeEntry,
|
deleteFileTreeEntry,
|
||||||
readFilePreview,
|
readFilePreview,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type { GitStatusEntry } from "@/lib/types"
|
import type { GitStatusEntry } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
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 { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
gitAbortOperation,
|
gitAbortOperation,
|
||||||
gitContinueOperation,
|
gitContinueOperation,
|
||||||
openMergeWindow,
|
openMergeWindow,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import type { GitConflictInfo } from "@/lib/types"
|
import type { GitConflictInfo } from "@/lib/types"
|
||||||
|
|
||||||
interface ConflictDialogProps {
|
interface ConflictDialogProps {
|
||||||
@@ -76,15 +75,15 @@ export function ConflictDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
let unlistenResolved: UnlistenFn | null = null
|
let unlistenResolved: (() => void) | null = null
|
||||||
let unlistenCompleted: UnlistenFn | null = null
|
let unlistenCompleted: (() => void) | null = null
|
||||||
let unlistenAborted: UnlistenFn | null = null
|
let unlistenAborted: (() => void) | null = null
|
||||||
|
|
||||||
listen<{ folder_id: number; file: string }>(
|
subscribe<{ folder_id: number; file: string }>(
|
||||||
"folder://merge-conflict-resolved",
|
"folder://merge-conflict-resolved",
|
||||||
(event) => {
|
(payload) => {
|
||||||
if (event.payload.folder_id !== folderId) return
|
if (payload.folder_id !== folderId) return
|
||||||
setResolvedFiles((prev) => new Set([...prev, event.payload.file]))
|
setResolvedFiles((prev) => new Set([...prev, payload.file]))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then((fn) => {
|
.then((fn) => {
|
||||||
@@ -92,8 +91,8 @@ export function ConflictDialog({
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
listen<{ folder_id: number }>("folder://merge-completed", (event) => {
|
subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => {
|
||||||
if (event.payload.folder_id !== folderId) return
|
if (payload.folder_id !== folderId) return
|
||||||
setDone(true)
|
setDone(true)
|
||||||
onResolved()
|
onResolved()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -105,8 +104,8 @@ export function ConflictDialog({
|
|||||||
|
|
||||||
// Merge was aborted (user clicked abort in merge window, or window closed)
|
// Merge was aborted (user clicked abort in merge window, or window closed)
|
||||||
// Reset resolved state since abort reverts all changes
|
// Reset resolved state since abort reverts all changes
|
||||||
listen<{ folder_id: number }>("folder://merge-aborted", (event) => {
|
subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => {
|
||||||
if (event.payload.folder_id !== folderId) return
|
if (payload.folder_id !== folderId) return
|
||||||
setDone(true)
|
setDone(true)
|
||||||
setResolvedFiles(new Set())
|
setResolvedFiles(new Set())
|
||||||
onClose()
|
onClose()
|
||||||
@@ -117,12 +116,9 @@ export function ConflictDialog({
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposeTauriListener(
|
unlistenResolved?.()
|
||||||
unlistenResolved,
|
unlistenCompleted?.()
|
||||||
"ConflictDialog.mergeConflictResolved"
|
unlistenAborted?.()
|
||||||
)
|
|
||||||
disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted")
|
|
||||||
disposeTauriListener(unlistenAborted, "ConflictDialog.mergeAborted")
|
|
||||||
}
|
}
|
||||||
}, [open, folderId, onResolved, onClose])
|
}, [open, folderId, onResolved, onClose])
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react"
|
import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -17,7 +16,8 @@ import {
|
|||||||
listOpenFolders,
|
listOpenFolders,
|
||||||
loadFolderHistory,
|
loadFolderHistory,
|
||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
|
import { openFileDialog } from "@/lib/platform"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
||||||
import type { FolderHistoryEntry } from "@/lib/types"
|
import type { FolderHistoryEntry } from "@/lib/types"
|
||||||
@@ -50,9 +50,11 @@ export function FolderNameDropdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenFolder() {
|
async function handleOpenFolder() {
|
||||||
const selected = await open({ directory: true, multiple: false })
|
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||||
if (selected) {
|
if (selected) {
|
||||||
await openFolderWindow(selected)
|
await openFolderWindow(
|
||||||
|
Array.isArray(selected) ? selected[0] : selected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import {
|
import {
|
||||||
Columns2,
|
Columns2,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
@@ -19,7 +18,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
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 { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||||
@@ -79,8 +79,9 @@ export function FolderTitleBar() {
|
|||||||
|
|
||||||
const handleOpenFolder = useCallback(async () => {
|
const handleOpenFolder = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await open({ directory: true, multiple: false })
|
const result = await openFileDialog({ directory: true, multiple: false })
|
||||||
if (!selected) return
|
if (!result) return
|
||||||
|
const selected = Array.isArray(result) ? result[0] : result
|
||||||
await openFolderWindow(selected)
|
await openFolderWindow(selected)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderTitleBar] failed to open folder:", err)
|
console.error("[FolderTitleBar] failed to open folder:", err)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 { toErrorMessage } from "@/lib/app-error"
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
import { languageFromPath } from "@/lib/language-detect"
|
||||||
import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
|
import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
gitAddRemote,
|
gitAddRemote,
|
||||||
gitRemoveRemote,
|
gitRemoveRemote,
|
||||||
gitSetRemoteUrl,
|
gitSetRemoteUrl,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
|
|
||||||
interface RemoteDraft {
|
interface RemoteDraft {
|
||||||
originalName: string | null
|
originalName: string | null
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { gitStashPush } from "@/lib/tauri"
|
import { gitStashPush } from "@/lib/api"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
|
|
||||||
interface StashDialogProps {
|
interface StashDialogProps {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
import { openUrl } from "@/lib/platform"
|
||||||
import { openSettingsWindow } from "@/lib/tauri"
|
import { openSettingsWindow } from "@/lib/api"
|
||||||
import { AGENT_LABELS, type AgentType } from "@/lib/types"
|
import { AGENT_LABELS, type AgentType } from "@/lib/types"
|
||||||
|
|
||||||
const KNOWN_AGENT_TYPES = new Set<AgentType>(
|
const KNOWN_AGENT_TYPES = new Set<AgentType>(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
gitStashApply,
|
gitStashApply,
|
||||||
gitStashDrop,
|
gitStashDrop,
|
||||||
gitShowFile,
|
gitShowFile,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
import { languageFromPath } from "@/lib/language-detect"
|
||||||
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
||||||
|
|||||||
@@ -1,41 +1,46 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
import { isDesktop } from "@/lib/platform"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { usePlatform } from "@/hooks/use-platform"
|
import { usePlatform } from "@/hooks/use-platform"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
async function getTauriWindow() {
|
||||||
|
const { getCurrentWindow } = await import("@tauri-apps/api/window")
|
||||||
|
return getCurrentWindow()
|
||||||
|
}
|
||||||
|
|
||||||
export function WindowControls() {
|
export function WindowControls() {
|
||||||
const t = useTranslations("Folder.windowControls")
|
const t = useTranslations("Folder.windowControls")
|
||||||
const { isWindows } = usePlatform()
|
const { isWindows } = usePlatform()
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
const appWindowRef = useRef<Awaited<
|
||||||
|
ReturnType<typeof getTauriWindow>
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWindows) return
|
if (!isWindows || !isDesktop()) return
|
||||||
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let unlistenResize: (() => void) | null = null
|
let unlistenResize: (() => void) | null = null
|
||||||
let resizeFrame: number | null = null
|
let resizeFrame: number | null = null
|
||||||
const appWindow = getCurrentWindow()
|
|
||||||
|
getTauriWindow().then((appWindow) => {
|
||||||
|
if (disposed) return
|
||||||
|
appWindowRef.current = appWindow
|
||||||
|
|
||||||
const syncMaximized = async () => {
|
const syncMaximized = async () => {
|
||||||
try {
|
try {
|
||||||
const maximized = await appWindow.isMaximized()
|
const maximized = await appWindow.isMaximized()
|
||||||
if (!disposed) {
|
if (!disposed) setIsMaximized(maximized)
|
||||||
setIsMaximized(maximized)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!disposed) {
|
if (!disposed) setIsMaximized(false)
|
||||||
setIsMaximized(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleSync = () => {
|
const scheduleSync = () => {
|
||||||
if (resizeFrame !== null) return
|
if (resizeFrame !== null) return
|
||||||
|
|
||||||
resizeFrame = window.requestAnimationFrame(() => {
|
resizeFrame = window.requestAnimationFrame(() => {
|
||||||
resizeFrame = null
|
resizeFrame = null
|
||||||
void syncMaximized()
|
void syncMaximized()
|
||||||
@@ -45,28 +50,25 @@ export function WindowControls() {
|
|||||||
void syncMaximized()
|
void syncMaximized()
|
||||||
|
|
||||||
appWindow
|
appWindow
|
||||||
.onResized(() => {
|
.onResized(() => scheduleSync())
|
||||||
scheduleSync()
|
|
||||||
})
|
|
||||||
.then((unlisten) => {
|
.then((unlisten) => {
|
||||||
unlistenResize = unlisten
|
unlistenResize = unlisten
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
unlistenResize = null
|
unlistenResize = null
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
if (resizeFrame !== null) {
|
if (resizeFrame !== null) {
|
||||||
window.cancelAnimationFrame(resizeFrame)
|
window.cancelAnimationFrame(resizeFrame)
|
||||||
}
|
}
|
||||||
disposeTauriListener(unlistenResize, "WindowControls.resize")
|
unlistenResize?.()
|
||||||
}
|
}
|
||||||
}, [isWindows])
|
}, [isWindows])
|
||||||
|
|
||||||
if (!isWindows) return null
|
if (!isWindows || !isDesktop()) return null
|
||||||
|
|
||||||
const appWindow = getCurrentWindow()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-8 items-stretch [-webkit-app-region:no-drag]">
|
<div className="flex h-8 items-stretch [-webkit-app-region:no-drag]">
|
||||||
@@ -74,7 +76,7 @@ export function WindowControls() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
appWindow.minimize().catch((err) => {
|
appWindowRef.current?.minimize().catch((err: unknown) => {
|
||||||
console.error("[WindowControls] failed to minimize:", err)
|
console.error("[WindowControls] failed to minimize:", err)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -87,7 +89,7 @@ export function WindowControls() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
appWindow.toggleMaximize().catch((err) => {
|
appWindowRef.current?.toggleMaximize().catch((err: unknown) => {
|
||||||
console.error("[WindowControls] failed to toggle maximize:", err)
|
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"
|
"hover:bg-[#e81123] hover:text-white active:bg-[#c50f1f] active:text-white"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
appWindow.close().catch((err) => {
|
appWindowRef.current?.close().catch((err: unknown) => {
|
||||||
console.error("[WindowControls] failed to close:", err)
|
console.error("[WindowControls] failed to close:", err)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
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 { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -19,7 +24,7 @@ import {
|
|||||||
gitAbortOperation,
|
gitAbortOperation,
|
||||||
gitContinueOperation,
|
gitContinueOperation,
|
||||||
gitStartPullMerge,
|
gitStartPullMerge,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
import { languageFromPath } from "@/lib/language-detect"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
import type { GitConflictFileVersions } from "@/lib/types"
|
import type { GitConflictFileVersions } from "@/lib/types"
|
||||||
@@ -121,7 +126,7 @@ export function MergeWorkspace({
|
|||||||
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
||||||
|
|
||||||
// Notify parent window
|
// Notify parent window
|
||||||
await emit("folder://merge-conflict-resolved", {
|
await emitEvent("folder://merge-conflict-resolved", {
|
||||||
folder_id: folderId,
|
folder_id: folderId,
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
})
|
})
|
||||||
@@ -145,7 +150,7 @@ export function MergeWorkspace({
|
|||||||
try {
|
try {
|
||||||
await gitAbortOperation(folderPath, operation)
|
await gitAbortOperation(folderPath, operation)
|
||||||
toast.success(t("abortSuccess"))
|
toast.success(t("abortSuccess"))
|
||||||
await emit("folder://merge-aborted", { folder_id: folderId })
|
await emitEvent("folder://merge-aborted", { folder_id: folderId })
|
||||||
onAborted()
|
onAborted()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(toErrorMessage(err))
|
toast.error(toErrorMessage(err))
|
||||||
@@ -159,7 +164,7 @@ export function MergeWorkspace({
|
|||||||
try {
|
try {
|
||||||
await gitContinueOperation(folderPath, operation)
|
await gitContinueOperation(folderPath, operation)
|
||||||
toast.success(t("allResolved"))
|
toast.success(t("allResolved"))
|
||||||
await emit("folder://merge-completed", { folder_id: folderId })
|
await emitEvent("folder://merge-completed", { folder_id: folderId })
|
||||||
onCompleted()
|
onCompleted()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(toErrorMessage(err))
|
toast.error(toErrorMessage(err))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
import { openUrl } from "@/lib/platform"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { AgentIcon } from "@/components/agent-icon"
|
import { AgentIcon } from "@/components/agent-icon"
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +64,7 @@ import {
|
|||||||
acpReorderAgents,
|
acpReorderAgents,
|
||||||
acpUninstallAgent,
|
acpUninstallAgent,
|
||||||
acpUpdateAgentPreferences,
|
acpUpdateAgentPreferences,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AcpAgentInfo,
|
AcpAgentInfo,
|
||||||
AgentType,
|
AgentType,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { saveAccountToken } from "@/lib/tauri"
|
import { saveAccountToken } from "@/lib/api"
|
||||||
import type { GitHubAccount } from "@/lib/types"
|
import type { GitHubAccount } from "@/lib/types"
|
||||||
|
|
||||||
interface AddGitAccountDialogProps {
|
interface AddGitAccountDialogProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-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 { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { validateGitHubToken, saveAccountToken } from "@/lib/tauri"
|
import { validateGitHubToken, saveAccountToken } from "@/lib/api"
|
||||||
import type { GitHubAccount } from "@/lib/types"
|
import type { GitHubAccount } from "@/lib/types"
|
||||||
|
|
||||||
interface AddGitHubAccountDialogProps {
|
interface AddGitHubAccountDialogProps {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
mcpScanLocal,
|
mcpScanLocal,
|
||||||
mcpSearchMarketplace,
|
mcpSearchMarketplace,
|
||||||
mcpUpsertLocalServer,
|
mcpUpsertLocalServer,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type {
|
import type {
|
||||||
LocalMcpServer,
|
LocalMcpServer,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Globe,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Palette,
|
Palette,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
@@ -28,6 +29,7 @@ interface SettingsNavItem {
|
|||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "version_control"
|
| "version_control"
|
||||||
| "system"
|
| "system"
|
||||||
|
| "web_service"
|
||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
|||||||
labelKey: "system",
|
labelKey: "system",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/settings/web-service",
|
||||||
|
labelKey: "web_service",
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsShellProps {
|
interface SettingsShellProps {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
acpReadAgentSkill,
|
acpReadAgentSkill,
|
||||||
acpSaveAgentSkill,
|
acpSaveAgentSkill,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AcpAgentInfo,
|
AcpAgentInfo,
|
||||||
AgentSkillItem,
|
AgentSkillItem,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Wifi,
|
Wifi,
|
||||||
} from "lucide-react"
|
} 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 { useLocale, useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAppI18n } from "@/components/i18n-provider"
|
import { useAppI18n } from "@/components/i18n-provider"
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
getSystemProxySettings,
|
getSystemProxySettings,
|
||||||
updateSystemLanguageSettings,
|
updateSystemLanguageSettings,
|
||||||
updateSystemProxySettings,
|
updateSystemProxySettings,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type { AppLocale } from "@/lib/types"
|
import type { AppLocale } from "@/lib/types"
|
||||||
import {
|
import {
|
||||||
checkAppUpdate,
|
checkAppUpdate,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
validateGitHubToken,
|
validateGitHubToken,
|
||||||
getAccountToken,
|
getAccountToken,
|
||||||
deleteAccountToken,
|
deleteAccountToken,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
GitDetectResult,
|
GitDetectResult,
|
||||||
GitHubAccount,
|
GitHubAccount,
|
||||||
|
|||||||
@@ -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<WebServerInfo | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Web 服务</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
启用后可通过浏览器远程访问 Codeg
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Port config */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-medium">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start/Stop button */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-medium">状态</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isRunning ? "bg-green-500" : "bg-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
{isRunning ? "运行中" : "已停止"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={isRunning ? handleStop : handleStart}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex h-8 items-center rounded-md border border-input bg-background px-3 text-xs font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "处理中..."
|
||||||
|
: isRunning
|
||||||
|
? "停止"
|
||||||
|
: "启动"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection info */}
|
||||||
|
{isRunning && (
|
||||||
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
访问地址
|
||||||
|
</div>
|
||||||
|
{status.addresses.map((addr) => (
|
||||||
|
<div key={addr} className="flex items-center gap-2">
|
||||||
|
<code className="text-sm">{addr}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
复制局域网地址
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
访问 Token
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-2 py-0.5 text-xs">
|
||||||
|
{status.token}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyToken}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Web 客户端首次访问时需输入此 Token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { listen } from "@tauri-apps/api/event"
|
import { subscribe } from "@/lib/platform"
|
||||||
import { terminalWrite, terminalResize } from "@/lib/tauri"
|
import { terminalWrite, terminalResize } from "@/lib/api"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import type { TerminalEvent } from "@/lib/types"
|
import type { TerminalEvent } from "@/lib/types"
|
||||||
import type { ITheme } from "@xterm/xterm"
|
import type { ITheme } from "@xterm/xterm"
|
||||||
|
|
||||||
@@ -169,14 +168,14 @@ export function TerminalView({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Set up event listeners BEFORE fit so initial output is captured
|
// Set up event listeners BEFORE fit so initial output is captured
|
||||||
const unlisten = await listen<TerminalEvent>(
|
const unlisten = await subscribe<TerminalEvent>(
|
||||||
`terminal://output/${terminalId}`,
|
`terminal://output/${terminalId}`,
|
||||||
(event) => {
|
(payload) => {
|
||||||
term.write(event.payload.data)
|
term.write(payload.data)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const unlistenExit = await listen<TerminalEvent>(
|
const unlistenExit = await subscribe<TerminalEvent>(
|
||||||
`terminal://exit/${terminalId}`,
|
`terminal://exit/${terminalId}`,
|
||||||
() => {
|
() => {
|
||||||
term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n")
|
term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n")
|
||||||
@@ -187,8 +186,8 @@ export function TerminalView({
|
|||||||
themeObserver.disconnect()
|
themeObserver.disconnect()
|
||||||
onDataDisposable.dispose()
|
onDataDisposable.dispose()
|
||||||
onResizeDisposable.dispose()
|
onResizeDisposable.dispose()
|
||||||
disposeTauriListener(unlisten, "TerminalView.output")
|
unlisten()
|
||||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
unlistenExit()
|
||||||
term.dispose()
|
term.dispose()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,8 +221,8 @@ export function TerminalView({
|
|||||||
themeObserver.disconnect()
|
themeObserver.disconnect()
|
||||||
onDataDisposable.dispose()
|
onDataDisposable.dispose()
|
||||||
onResizeDisposable.dispose()
|
onResizeDisposable.dispose()
|
||||||
disposeTauriListener(unlisten, "TerminalView.output")
|
unlisten()
|
||||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
unlistenExit()
|
||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
term.dispose()
|
term.dispose()
|
||||||
fitAddonRef.current = null
|
fitAddonRef.current = null
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
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 { useGitCredential } from "@/contexts/git-credential-context"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -36,9 +36,9 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async () => {
|
||||||
const selected = await open({ directory: true, multiple: false })
|
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setTargetDir(selected)
|
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useState } from "react"
|
|||||||
import { FolderOpen, GitBranch } from "lucide-react"
|
import { FolderOpen, GitBranch } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
import { openFolderWindow } from "@/lib/api"
|
||||||
import { openFolderWindow } from "@/lib/tauri"
|
import { openFileDialog } from "@/lib/platform"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CloneDialog } from "./clone-dialog"
|
import { CloneDialog } from "./clone-dialog"
|
||||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||||
@@ -15,8 +15,9 @@ export function FolderActions() {
|
|||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
|
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
const selected = await open({ directory: true, multiple: false })
|
const result = await openFileDialog({ directory: true, multiple: false })
|
||||||
if (!selected) return
|
if (!result) return
|
||||||
|
const selected = Array.isArray(result) ? result[0] : result
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openFolderWindow(selected)
|
await openFolderWindow(selected)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { formatDistanceToNow } from "date-fns"
|
|||||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { openFolderWindow, removeFolderFromHistory } from "@/lib/tauri"
|
import { openFolderWindow, removeFolderFromHistory } from "@/lib/api"
|
||||||
import type { FolderHistoryEntry } from "@/lib/types"
|
import type { FolderHistoryEntry } from "@/lib/types"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"
|
|||||||
import { Settings } from "lucide-react"
|
import { Settings } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/tauri"
|
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
||||||
import type { FolderHistoryEntry } from "@/lib/types"
|
import type { FolderHistoryEntry } from "@/lib/types"
|
||||||
import { FolderList } from "@/components/welcome/folder-list"
|
import { FolderList } from "@/components/welcome/folder-list"
|
||||||
import { FolderActions } from "@/components/welcome/folder-actions"
|
import { FolderActions } from "@/components/welcome/folder-actions"
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
import { subscribe } from "@/lib/platform"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||||
import {
|
import {
|
||||||
acpConnect,
|
acpConnect,
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
acpCancel,
|
acpCancel,
|
||||||
acpRespondPermission,
|
acpRespondPermission,
|
||||||
acpDisconnect,
|
acpDisconnect,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
AcpAgentStatus,
|
AcpAgentStatus,
|
||||||
@@ -1609,25 +1608,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
// Single global event listener
|
// Single global event listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
listenerReadyRef.current = false
|
listenerReadyRef.current = false
|
||||||
|
|
||||||
listen<AcpEvent>("acp://event", (event) => {
|
subscribe<AcpEvent>("acp://event", (payload) => {
|
||||||
const e = event.payload
|
const contextKey = reverseMapRef.current.get(payload.connection_id)
|
||||||
const contextKey = reverseMapRef.current.get(e.connection_id)
|
|
||||||
if (!contextKey) {
|
if (!contextKey) {
|
||||||
bufferUnmappedEvent(e)
|
bufferUnmappedEvent(payload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch activity on every incoming event
|
// Touch activity on every incoming event
|
||||||
lastActivityRef.current.set(contextKey, Date.now())
|
lastActivityRef.current.set(contextKey, Date.now())
|
||||||
handleMappedEvent(contextKey, e)
|
handleMappedEvent(contextKey, payload)
|
||||||
})
|
})
|
||||||
.then((fn) => {
|
.then((fn) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent")
|
fn()
|
||||||
} else {
|
} else {
|
||||||
unlisten = fn
|
unlisten = fn
|
||||||
listenerReadyRef.current = true
|
listenerReadyRef.current = true
|
||||||
@@ -1647,7 +1645,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
clearTimeout(flushTimerRef.current)
|
clearTimeout(flushTimerRef.current)
|
||||||
flushTimerRef.current = null
|
flushTimerRef.current = null
|
||||||
}
|
}
|
||||||
disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent")
|
unlisten?.()
|
||||||
}
|
}
|
||||||
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])
|
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||||
import { getFolderConversation } from "@/lib/tauri"
|
import { getFolderConversation } from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
DbConversationDetail,
|
DbConversationDetail,
|
||||||
MessageTurn,
|
MessageTurn,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
import { getFolder, listFolderConversations } from "@/lib/tauri"
|
import { getFolder, listFolderConversations } from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
AgentStats,
|
AgentStats,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
import { openUrl } from "@/lib/platform"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
getGitHubAccounts,
|
getGitHubAccounts,
|
||||||
updateGitHubAccounts,
|
updateGitHubAccounts,
|
||||||
saveAccountToken,
|
saveAccountToken,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Context
|
// Context
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import { saveFolderOpenedConversations } from "@/lib/tauri"
|
import { saveFolderOpenedConversations } from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
ConversationStatus,
|
ConversationStatus,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { terminalSpawn, terminalKill } from "@/lib/tauri"
|
import { terminalSpawn, terminalKill } from "@/lib/api"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
readFileForEdit,
|
readFileForEdit,
|
||||||
readFilePreview,
|
readFilePreview,
|
||||||
saveFileContent,
|
saveFileContent,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/api"
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
import { languageFromPath } from "@/lib/language-detect"
|
||||||
import {
|
import {
|
||||||
loadPersistedWorkspaceMode,
|
loadPersistedWorkspaceMode,
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "الاختصارات",
|
"shortcuts": "الاختصارات",
|
||||||
"version_control": "التحكم بالإصدارات",
|
"version_control": "التحكم بالإصدارات",
|
||||||
"system": "النظام"
|
"system": "النظام",
|
||||||
|
"web_service": "خدمة الويب"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "Kurzbefehle",
|
"shortcuts": "Kurzbefehle",
|
||||||
"version_control": "Versionskontrolle",
|
"version_control": "Versionskontrolle",
|
||||||
"system": "Systemeinstellungen"
|
"system": "Systemeinstellungen",
|
||||||
|
"web_service": "Webdienst"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"version_control": "Version Control",
|
"version_control": "Version Control",
|
||||||
"system": "System"
|
"system": "System",
|
||||||
|
"web_service": "Web Service"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "Atajos",
|
"shortcuts": "Atajos",
|
||||||
"version_control": "Control de versiones",
|
"version_control": "Control de versiones",
|
||||||
"system": "Sistema"
|
"system": "Sistema",
|
||||||
|
"web_service": "Servicio Web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "Raccourcis",
|
"shortcuts": "Raccourcis",
|
||||||
"version_control": "Contrôle de version",
|
"version_control": "Contrôle de version",
|
||||||
"system": "Système"
|
"system": "Système",
|
||||||
|
"web_service": "Service Web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "ショートカット",
|
"shortcuts": "ショートカット",
|
||||||
"version_control": "バージョン管理",
|
"version_control": "バージョン管理",
|
||||||
"system": "システム"
|
"system": "システム",
|
||||||
|
"web_service": "Webサービス"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "단축키",
|
"shortcuts": "단축키",
|
||||||
"version_control": "버전 관리",
|
"version_control": "버전 관리",
|
||||||
"system": "시스템"
|
"system": "시스템",
|
||||||
|
"web_service": "웹 서비스"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "Atalhos",
|
"shortcuts": "Atalhos",
|
||||||
"version_control": "Controle de versão",
|
"version_control": "Controle de versão",
|
||||||
"system": "Sistema"
|
"system": "Sistema",
|
||||||
|
"web_service": "Serviço Web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "快捷键",
|
"shortcuts": "快捷键",
|
||||||
"version_control": "版本控制",
|
"version_control": "版本控制",
|
||||||
"system": "系统"
|
"system": "系统",
|
||||||
|
"web_service": "Web 服务"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"shortcuts": "快捷鍵",
|
"shortcuts": "快捷鍵",
|
||||||
"version_control": "版本控制",
|
"version_control": "版本控制",
|
||||||
"system": "系統"
|
"system": "系統",
|
||||||
|
"web_service": "Web 服務"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AppearanceSettings": {
|
"AppearanceSettings": {
|
||||||
|
|||||||
+1151
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -1,9 +1,22 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { getTransport } from "./transport"
|
||||||
|
import { isDesktop } from "./transport"
|
||||||
|
|
||||||
export async function notifyTurnComplete(
|
export async function notifyTurnComplete(
|
||||||
title: string,
|
title: string,
|
||||||
body: string
|
body: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!document.hidden) return
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: T) => void
|
||||||
|
): Promise<UnsubscribeFn> {
|
||||||
|
return getTransport().subscribe(event, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a URL in the default browser (desktop) or new tab (web).
|
||||||
|
*/
|
||||||
|
export async function openUrl(url: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string | string[] | null> {
|
||||||
|
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<void> {
|
||||||
|
if (isDesktop()) {
|
||||||
|
const win = await getCurrentWindow()
|
||||||
|
await win?.close()
|
||||||
|
} else {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Transport, UnsubscribeFn } from "./types"
|
||||||
|
|
||||||
|
export class TauriTransport implements Transport {
|
||||||
|
async call<T>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core")
|
||||||
|
return invoke(command, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: T) => void
|
||||||
|
): Promise<UnsubscribeFn> {
|
||||||
|
const { listen } = await import("@tauri-apps/api/event")
|
||||||
|
return listen<T>(event, (e) => handler(e.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
isDesktop(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type UnsubscribeFn = () => void
|
||||||
|
|
||||||
|
export interface Transport {
|
||||||
|
/**
|
||||||
|
* Invoke a backend command (replaces Tauri's invoke()).
|
||||||
|
*/
|
||||||
|
call<T>(command: string, args?: Record<string, unknown>): Promise<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a backend event stream (replaces Tauri's listen()).
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
subscribe<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: T) => void
|
||||||
|
): Promise<UnsubscribeFn>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is running in a desktop Tauri environment.
|
||||||
|
*/
|
||||||
|
isDesktop(): boolean
|
||||||
|
}
|
||||||
@@ -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<string, Set<(payload: unknown) => void>>()
|
||||||
|
private baseUrl: string
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private wsFailCount = 0
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
async call<T>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: T) => void
|
||||||
|
): Promise<UnsubscribeFn> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-6
@@ -1,6 +1,6 @@
|
|||||||
import { getVersion } from "@tauri-apps/api/app"
|
// All updater imports are dynamic to avoid crashing in non-Tauri browsers.
|
||||||
import { relaunch } from "@tauri-apps/plugin-process"
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater"
|
type Update = any
|
||||||
|
|
||||||
export interface AppUpdateCheckResult {
|
export interface AppUpdateCheckResult {
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
@@ -20,23 +20,31 @@ export interface AppUpdateErrorInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentAppVersion(): Promise<string> {
|
export async function getCurrentAppVersion(): Promise<string> {
|
||||||
return getVersion()
|
try {
|
||||||
|
const { getVersion } = await import("@tauri-apps/api/app")
|
||||||
|
return await getVersion()
|
||||||
|
} catch {
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAppUpdate(): Promise<AppUpdateCheckResult> {
|
export async function checkAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||||
|
const { getVersion } = await import("@tauri-apps/api/app")
|
||||||
|
const { check } = await import("@tauri-apps/plugin-updater")
|
||||||
const [currentVersion, update] = await Promise.all([getVersion(), check()])
|
const [currentVersion, update] = await Promise.all([getVersion(), check()])
|
||||||
return { currentVersion, update }
|
return { currentVersion, update }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installAppUpdate(update: Update): Promise<void> {
|
export async function installAppUpdate(update: NonNullable<Update>): Promise<void> {
|
||||||
await update.downloadAndInstall()
|
await update.downloadAndInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function relaunchApp(): Promise<void> {
|
export async function relaunchApp(): Promise<void> {
|
||||||
|
const { relaunch } = await import("@tauri-apps/plugin-process")
|
||||||
await relaunch()
|
await relaunch()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeAppUpdate(update: Update): Promise<void> {
|
export async function closeAppUpdate(update: NonNullable<Update>): Promise<void> {
|
||||||
await update.close()
|
await update.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user