add: 移除除了 desktop 以外的所有版本
This commit is contained in:
@@ -7,8 +7,6 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"server:build": "cd src-tauri && cargo build --release --bin codeg-server --no-default-features",
|
|
||||||
"server:dev": "cd src-tauri && cargo run --bin codeg-server --no-default-features",
|
|
||||||
"postinstall": "node -e \"const fs=require('fs');fs.cpSync('node_modules/monaco-editor/min/vs','public/vs',{recursive:true,force:true});const p='public/vs/loader.js';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/\\n\\/\\/# sourceMappingURL=.*/,''))\""
|
"postinstall": "node -e \"const fs=require('fs');fs.cpSync('node_modules/monaco-editor/min/vs','public/vs',{recursive:true,force:true});const p='public/vs/loader.js';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/\\n\\/\\/# sourceMappingURL=.*/,''))\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+6
-123
@@ -386,7 +386,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.28.0",
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -615,12 +615,6 @@ 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 = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder-lite"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -866,7 +860,6 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"image",
|
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"junction",
|
"junction",
|
||||||
"keyring",
|
"keyring",
|
||||||
@@ -874,9 +867,6 @@ dependencies = [
|
|||||||
"mac-notification-sys",
|
"mac-notification-sys",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"prost",
|
|
||||||
"qrcode",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"sacp",
|
"sacp",
|
||||||
@@ -897,7 +887,6 @@ dependencies = [
|
|||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.26.2",
|
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -2476,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2587,19 +2576,6 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "image"
|
|
||||||
version = "0.25.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|
||||||
dependencies = [
|
|
||||||
"bytemuck",
|
|
||||||
"byteorder-lite",
|
|
||||||
"moxcms",
|
|
||||||
"num-traits",
|
|
||||||
"png 0.18.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "include_dir"
|
name = "include_dir"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
@@ -3249,16 +3225,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "moxcms"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
"pxfm",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -3274,7 +3240,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -4182,19 +4148,6 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "png"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"crc32fast",
|
|
||||||
"fdeflate",
|
|
||||||
"flate2",
|
|
||||||
"miniz_oxide",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -4373,29 +4326,6 @@ dependencies = [
|
|||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost"
|
|
||||||
version = "0.13.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"prost-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost-derive"
|
|
||||||
version = "0.13.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"itertools",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.114",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -4416,21 +4346,6 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pxfm"
|
|
||||||
version = "0.1.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "qrcode"
|
|
||||||
version = "0.14.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
|
||||||
dependencies = [
|
|
||||||
"image",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.5"
|
version = "0.37.5"
|
||||||
@@ -6360,7 +6275,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -6856,20 +6771,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.26.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
"tokio-native-tls",
|
|
||||||
"tungstenite 0.26.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -6879,7 +6780,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite 0.28.0",
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7111,7 +7012,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -7123,24 +7024,6 @@ 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.26.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"native-tls",
|
|
||||||
"rand 0.9.2",
|
|
||||||
"sha1",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ tauri-runtime = [
|
|||||||
name = "codeg"
|
name = "codeg"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "codeg-server"
|
|
||||||
path = "src/bin/codeg_server.rs"
|
|
||||||
required-features = []
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [], optional = true }
|
tauri-build = { version = "2", features = [], optional = true }
|
||||||
@@ -78,12 +74,7 @@ which = "7"
|
|||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||||
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
prost = "0.13"
|
|
||||||
rand = "0.8"
|
|
||||||
qrcode = "0.14"
|
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
use crate::acp::manager::ConnectionManager;
|
||||||
use crate::chat_channel::manager::ChatChannelManager;
|
|
||||||
use crate::db::AppDatabase;
|
use crate::db::AppDatabase;
|
||||||
use crate::terminal::manager::TerminalManager;
|
use crate::terminal::manager::TerminalManager;
|
||||||
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
||||||
@@ -16,7 +15,6 @@ pub struct AppState {
|
|||||||
pub emitter: EventEmitter,
|
pub emitter: EventEmitter,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub web_server_state: WebServerState,
|
pub web_server_state: WebServerState,
|
||||||
pub chat_channel_manager: ChatChannelManager,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_connection_manager() -> ConnectionManager {
|
pub fn default_connection_manager() -> ConnectionManager {
|
||||||
@@ -26,7 +24,3 @@ pub fn default_connection_manager() -> ConnectionManager {
|
|||||||
pub fn default_terminal_manager() -> TerminalManager {
|
pub fn default_terminal_manager() -> TerminalManager {
|
||||||
TerminalManager::new()
|
TerminalManager::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_chat_channel_manager() -> ChatChannelManager {
|
|
||||||
ChatChannelManager::new()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use codeg_lib::app_state::AppState;
|
|
||||||
use codeg_lib::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
|
||||||
use codeg_lib::web::{
|
|
||||||
find_static_dir_standalone, generate_random_token, get_local_addresses, WebServerState,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Support --version flag
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
|
||||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATH initialisation MUST happen before the tokio runtime is created.
|
|
||||||
// std::env::set_var is not thread-safe (unsafe in Rust edition 2024);
|
|
||||||
// #[tokio::main] would spawn worker threads before we reach this point.
|
|
||||||
codeg_lib::process::ensure_node_in_path();
|
|
||||||
codeg_lib::process::ensure_user_npm_prefix_in_path();
|
|
||||||
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build tokio runtime")
|
|
||||||
.block_on(async_main());
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn async_main() {
|
|
||||||
// Sweep stale ACP binary cache trash (rename-aside fallback artifacts).
|
|
||||||
// Detached OS thread: cannot block startup, panics are caught and dropped,
|
|
||||||
// errors are silenced, no subprocesses spawned.
|
|
||||||
std::thread::spawn(|| {
|
|
||||||
let _ = std::panic::catch_unwind(|| {
|
|
||||||
codeg_lib::sweep_acp_binary_trash();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let port: u16 = std::env::var("CODEG_PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(3080);
|
|
||||||
let host = std::env::var("CODEG_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
|
||||||
let token = std::env::var("CODEG_TOKEN").unwrap_or_else(|_| generate_random_token());
|
|
||||||
let data_dir = std::env::var("CODEG_DATA_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| default_data_dir());
|
|
||||||
let static_dir_env = std::env::var("CODEG_STATIC_DIR").ok();
|
|
||||||
|
|
||||||
let static_dir = find_static_dir_standalone(static_dir_env.as_deref());
|
|
||||||
let app_version = env!("CARGO_PKG_VERSION");
|
|
||||||
|
|
||||||
eprintln!("[SERVER] codeg-server v{}", app_version);
|
|
||||||
eprintln!("[SERVER] Data directory: {}", data_dir.display());
|
|
||||||
eprintln!("[SERVER] Static directory: {}", static_dir.display());
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
let db = codeg_lib::db::init_database(&data_dir, app_version)
|
|
||||||
.await
|
|
||||||
.expect("Failed to initialize database");
|
|
||||||
|
|
||||||
// Create shared broadcaster
|
|
||||||
let broadcaster = Arc::new(WebEventBroadcaster::new());
|
|
||||||
let emitter = EventEmitter::WebOnly(broadcaster.clone());
|
|
||||||
|
|
||||||
// Build AppState
|
|
||||||
let state = Arc::new(AppState {
|
|
||||||
db,
|
|
||||||
connection_manager: codeg_lib::app_state::default_connection_manager(),
|
|
||||||
terminal_manager: codeg_lib::app_state::default_terminal_manager(),
|
|
||||||
event_broadcaster: broadcaster,
|
|
||||||
emitter,
|
|
||||||
data_dir,
|
|
||||||
web_server_state: WebServerState::new(),
|
|
||||||
chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Install bundled expert skills into the central store
|
|
||||||
// (`~/.codeg/skills/`). Runs in the background; failures are logged
|
|
||||||
// but non-fatal.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let report = codeg_lib::commands::experts::ensure_central_experts_installed().await;
|
|
||||||
if !report.errors.is_empty() {
|
|
||||||
eprintln!(
|
|
||||||
"[Experts] install finished with {} error(s): {:?}",
|
|
||||||
report.errors.len(),
|
|
||||||
report.errors
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"[Experts] install ok: installed={} updated={} pending_review={}",
|
|
||||||
report.installed_count,
|
|
||||||
report.updated_count,
|
|
||||||
report.pending_user_review.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect)
|
|
||||||
state
|
|
||||||
.chat_channel_manager
|
|
||||||
.start_background(
|
|
||||||
state.event_broadcaster.clone(),
|
|
||||||
state.db.conn.clone(),
|
|
||||||
state.connection_manager.clone_ref(),
|
|
||||||
state.emitter.clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Build router
|
|
||||||
let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir);
|
|
||||||
|
|
||||||
// Bind
|
|
||||||
let addr = format!("{}:{}", host, port);
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
eprintln!("[SERVER] Failed to bind {}: {}", addr, e);
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
|
||||||
let addresses = get_local_addresses(actual_port);
|
|
||||||
|
|
||||||
eprintln!("[SERVER] Token: {}", token);
|
|
||||||
eprintln!("[SERVER] Listening on:");
|
|
||||||
for addr in &addresses {
|
|
||||||
eprintln!(" {}", addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start serving
|
|
||||||
if let Err(e) = axum::serve(listener, router).await {
|
|
||||||
eprintln!("[SERVER] Server error: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_data_dir() -> PathBuf {
|
|
||||||
dirs::data_dir()
|
|
||||||
.map(|d| d.join("codeg"))
|
|
||||||
.unwrap_or_else(|| PathBuf::from(".codeg-data"))
|
|
||||||
}
|
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use prost::Message as ProstMessage;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
|
||||||
use tokio_tungstenite::tungstenite;
|
|
||||||
|
|
||||||
use crate::chat_channel::error::ChatChannelError;
|
|
||||||
use crate::chat_channel::traits::ChatChannelBackend;
|
|
||||||
use crate::chat_channel::types::*;
|
|
||||||
|
|
||||||
const FEISHU_BASE_URL: &str = "https://open.feishu.cn";
|
|
||||||
const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
|
|
||||||
|
|
||||||
// ── Lark WebSocket protobuf Frame (pbbp2) ──
|
|
||||||
// Source: larksuite/oapi-sdk-go ws/pbbp2.pb.go
|
|
||||||
|
|
||||||
const FRAME_METHOD_CONTROL: i32 = 0; // Ping/Pong
|
|
||||||
const FRAME_METHOD_DATA: i32 = 1; // Event/Card
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, ProstMessage)]
|
|
||||||
struct Frame {
|
|
||||||
#[prost(uint64, tag = 1)]
|
|
||||||
seq_id: u64,
|
|
||||||
#[prost(uint64, tag = 2)]
|
|
||||||
log_id: u64,
|
|
||||||
#[prost(int32, tag = 3)]
|
|
||||||
service: i32,
|
|
||||||
#[prost(int32, tag = 4)]
|
|
||||||
method: i32,
|
|
||||||
#[prost(message, repeated, tag = 5)]
|
|
||||||
headers: Vec<FrameHeader>,
|
|
||||||
#[prost(string, tag = 6)]
|
|
||||||
payload_encoding: String,
|
|
||||||
#[prost(string, tag = 7)]
|
|
||||||
payload_type: String,
|
|
||||||
#[prost(bytes = "vec", tag = 8)]
|
|
||||||
payload: Vec<u8>,
|
|
||||||
#[prost(string, tag = 9)]
|
|
||||||
log_id_new: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, ProstMessage)]
|
|
||||||
struct FrameHeader {
|
|
||||||
#[prost(string, tag = 1)]
|
|
||||||
key: String,
|
|
||||||
#[prost(string, tag = 2)]
|
|
||||||
value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Frame {
|
|
||||||
fn get_header(&self, key: &str) -> Option<&str> {
|
|
||||||
self.headers
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.key == key)
|
|
||||||
.map(|h| h.value.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_header(&mut self, key: &str, value: &str) {
|
|
||||||
if let Some(h) = self.headers.iter_mut().find(|h| h.key == key) {
|
|
||||||
h.value = value.to_string();
|
|
||||||
} else {
|
|
||||||
self.headers.push(FrameHeader {
|
|
||||||
key: key.to_string(),
|
|
||||||
value: value.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lark REST API types ──
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TenantAccessTokenResponse {
|
|
||||||
code: i32,
|
|
||||||
msg: String,
|
|
||||||
tenant_access_token: Option<String>,
|
|
||||||
expire: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct SendMessageRequest {
|
|
||||||
receive_id: String,
|
|
||||||
msg_type: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SendMessageResponse {
|
|
||||||
code: i32,
|
|
||||||
msg: String,
|
|
||||||
data: Option<SendMessageData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SendMessageData {
|
|
||||||
message_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WsConnectResponse {
|
|
||||||
code: i32,
|
|
||||||
msg: String,
|
|
||||||
data: Option<WsConnectData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WsConnectData {
|
|
||||||
#[serde(rename = "URL")]
|
|
||||||
url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Token cache ──
|
|
||||||
|
|
||||||
struct TokenCache {
|
|
||||||
token: String,
|
|
||||||
expires_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Multi-part frame cache ──
|
|
||||||
|
|
||||||
struct PartialMessage {
|
|
||||||
parts: HashMap<i32, Vec<u8>>,
|
|
||||||
total: i32,
|
|
||||||
created_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TTL for partial message reassembly entries. Prevents unbounded memory growth
|
|
||||||
/// if a multi-part message never completes (network issue, Lark SDK bug, etc).
|
|
||||||
const PARTIAL_MSG_TTL_SECS: u64 = 60;
|
|
||||||
|
|
||||||
// ── LarkBackend ──
|
|
||||||
|
|
||||||
pub struct LarkBackend {
|
|
||||||
app_id: String,
|
|
||||||
app_secret: String,
|
|
||||||
chat_id: String,
|
|
||||||
channel_id: i32,
|
|
||||||
client: reqwest::Client,
|
|
||||||
token_cache: Arc<RwLock<Option<TokenCache>>>,
|
|
||||||
status: Arc<Mutex<ChannelConnectionStatus>>,
|
|
||||||
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LarkBackend {
|
|
||||||
pub fn new(channel_id: i32, app_id: String, app_secret: String, chat_id: String) -> Self {
|
|
||||||
Self {
|
|
||||||
app_id,
|
|
||||||
app_secret,
|
|
||||||
chat_id,
|
|
||||||
channel_id,
|
|
||||||
client: reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
token_cache: Arc::new(RwLock::new(None)),
|
|
||||||
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
|
|
||||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_tenant_access_token(&self) -> Result<String, ChatChannelError> {
|
|
||||||
{
|
|
||||||
let cache = self.token_cache.read().await;
|
|
||||||
if let Some(cached) = cache.as_ref() {
|
|
||||||
if cached.expires_at > Instant::now() {
|
|
||||||
return Ok(cached.token.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(format!(
|
|
||||||
"{}/open-apis/auth/v3/tenant_access_token/internal",
|
|
||||||
FEISHU_BASE_URL
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"app_id": self.app_id,
|
|
||||||
"app_secret": self.app_secret,
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let result: TenantAccessTokenResponse = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if result.code != 0 {
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(format!(
|
|
||||||
"code={}, msg={}",
|
|
||||||
result.code, result.msg
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = result
|
|
||||||
.tenant_access_token
|
|
||||||
.ok_or_else(|| ChatChannelError::AuthenticationFailed("No token in response".into()))?;
|
|
||||||
let expire_secs = result.expire.unwrap_or(7200);
|
|
||||||
|
|
||||||
let expires_at = Instant::now()
|
|
||||||
+ Duration::from_secs(expire_secs.saturating_sub(TOKEN_REFRESH_MARGIN_SECS));
|
|
||||||
*self.token_cache.write().await = Some(TokenCache {
|
|
||||||
token: token.clone(),
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_lark_message(
|
|
||||||
&self,
|
|
||||||
msg_type: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let token = self.get_tenant_access_token().await?;
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(format!(
|
|
||||||
"{}/open-apis/im/v1/messages?receive_id_type=chat_id",
|
|
||||||
FEISHU_BASE_URL
|
|
||||||
))
|
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
|
||||||
.json(&SendMessageRequest {
|
|
||||||
receive_id: self.chat_id.clone(),
|
|
||||||
msg_type: msg_type.to_string(),
|
|
||||||
content: content.to_string(),
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let result: SendMessageResponse = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if result.code != 0 {
|
|
||||||
return Err(ChatChannelError::SendFailed(format!(
|
|
||||||
"code={}, msg={}",
|
|
||||||
result.code, result.msg
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let message_id = result.data.and_then(|d| d.message_id).unwrap_or_default();
|
|
||||||
Ok(SentMessageId(message_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_ws_receiver(
|
|
||||||
&self,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
// Verify we can get a WS URL before spawning the background task
|
|
||||||
let _ = fetch_ws_url(&self.client, &self.app_id, &self.app_secret).await?;
|
|
||||||
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
|
|
||||||
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
|
||||||
|
|
||||||
let channel_id = self.channel_id;
|
|
||||||
let status = self.status.clone();
|
|
||||||
let app_id = self.app_id.clone();
|
|
||||||
let app_secret = self.app_secret.clone();
|
|
||||||
let client = self.client.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut retry_count = 0u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if *shutdown_rx.borrow() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ws_url = match fetch_ws_url(&client, &app_id, &app_secret).await {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[Lark] failed to get WS endpoint: {e}");
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
let delay = Duration::from_secs((2u64).pow(retry_count.min(5)));
|
|
||||||
retry_count += 1;
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(delay) => continue,
|
|
||||||
_ = shutdown_rx.changed() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ws_result = tokio_tungstenite::connect_async(&ws_url).await;
|
|
||||||
let ws_stream = match ws_result {
|
|
||||||
Ok((stream, _)) => {
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Connected;
|
|
||||||
retry_count = 0;
|
|
||||||
eprintln!("[Lark] WebSocket connected");
|
|
||||||
stream
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[Lark] WebSocket connect failed: {e}");
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
let delay = Duration::from_secs((2u64).pow(retry_count.min(5)));
|
|
||||||
retry_count += 1;
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(delay) => continue,
|
|
||||||
_ = shutdown_rx.changed() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (mut write, mut read) = ws_stream.split();
|
|
||||||
let mut partial_msgs: HashMap<String, PartialMessage> = HashMap::new();
|
|
||||||
let mut last_partial_cleanup = Instant::now();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
msg = read.next() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(tungstenite::Message::Binary(data))) => {
|
|
||||||
match Frame::decode(data.as_ref()) {
|
|
||||||
Ok(frame) => {
|
|
||||||
let frame_type = frame.get_header("type").unwrap_or("").to_string();
|
|
||||||
|
|
||||||
if frame.method == FRAME_METHOD_CONTROL {
|
|
||||||
// Control frame: ping → respond with pong
|
|
||||||
if frame_type == "ping" {
|
|
||||||
let mut pong = frame.clone();
|
|
||||||
// Clear type header and set to pong
|
|
||||||
pong.set_header("type", "pong");
|
|
||||||
pong.payload = Vec::new();
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
if pong.encode(&mut buf).is_ok() {
|
|
||||||
let _ = write.send(tungstenite::Message::Binary(buf.into())).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if frame.method == FRAME_METHOD_DATA && frame_type == "event" {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Multi-part reassembly
|
|
||||||
let msg_id = frame.get_header("message_id").unwrap_or("").to_string();
|
|
||||||
let sum: i32 = frame.get_header("sum").and_then(|s| s.parse().ok()).unwrap_or(1);
|
|
||||||
let seq: i32 = frame.get_header("seq").and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
|
|
||||||
// Evict stale partial messages to prevent unbounded memory growth
|
|
||||||
if last_partial_cleanup.elapsed() > Duration::from_secs(PARTIAL_MSG_TTL_SECS) {
|
|
||||||
partial_msgs.retain(|_, pm| pm.created_at.elapsed() < Duration::from_secs(PARTIAL_MSG_TTL_SECS));
|
|
||||||
last_partial_cleanup = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
let full_payload = if sum <= 1 {
|
|
||||||
Some(frame.payload.clone())
|
|
||||||
} else {
|
|
||||||
let entry = partial_msgs.entry(msg_id.clone()).or_insert_with(|| PartialMessage {
|
|
||||||
parts: HashMap::new(),
|
|
||||||
total: sum,
|
|
||||||
created_at: Instant::now(),
|
|
||||||
});
|
|
||||||
entry.parts.insert(seq, frame.payload.clone());
|
|
||||||
if entry.parts.len() as i32 >= entry.total {
|
|
||||||
// All parts received — reassemble in order
|
|
||||||
let mut combined = Vec::new();
|
|
||||||
for i in 0..entry.total {
|
|
||||||
if let Some(part) = entry.parts.get(&i) {
|
|
||||||
combined.extend_from_slice(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
partial_msgs.remove(&msg_id);
|
|
||||||
Some(combined)
|
|
||||||
} else {
|
|
||||||
None // Still waiting for more parts
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(payload_bytes) = full_payload {
|
|
||||||
// Process event
|
|
||||||
if let Ok(payload_str) = std::str::from_utf8(&payload_bytes) {
|
|
||||||
if let Ok(event) = serde_json::from_str::<serde_json::Value>(payload_str) {
|
|
||||||
handle_lark_event(&event, channel_id, &command_tx).await;
|
|
||||||
} else {
|
|
||||||
eprintln!("[Lark] event payload is not valid JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send acknowledgment: echo frame back with {"code":200}
|
|
||||||
let elapsed_ms = start.elapsed().as_millis();
|
|
||||||
let mut ack = frame.clone();
|
|
||||||
ack.payload = br#"{"code":200}"#.to_vec();
|
|
||||||
ack.set_header("biz_rt", &elapsed_ms.to_string());
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
if ack.encode(&mut buf).is_ok() {
|
|
||||||
let _ = write.send(tungstenite::Message::Binary(buf.into())).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[Lark] protobuf decode error: {e}, len={}", data.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(tungstenite::Message::Ping(data))) => {
|
|
||||||
let _ = write.send(tungstenite::Message::Pong(data)).await;
|
|
||||||
}
|
|
||||||
Some(Ok(tungstenite::Message::Close(_))) | None => {
|
|
||||||
eprintln!("[Lark] WebSocket closed, will reconnect");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
eprintln!("[Lark] WebSocket error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = shutdown_rx.changed() => {
|
|
||||||
let _ = write.close().await;
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Connecting;
|
|
||||||
let delay = Duration::from_secs(3);
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(delay) => {},
|
|
||||||
_ = shutdown_rx.changed() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_lark_event(
|
|
||||||
event: &serde_json::Value,
|
|
||||||
channel_id: i32,
|
|
||||||
command_tx: &mpsc::Sender<IncomingCommand>,
|
|
||||||
) {
|
|
||||||
let event_type = event
|
|
||||||
.pointer("/header/event_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if event_type == "im.message.receive_v1" {
|
|
||||||
let msg_type = event
|
|
||||||
.pointer("/event/message/message_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if msg_type != "text" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group chat filtering: only process if bot is mentioned
|
|
||||||
let chat_type = event
|
|
||||||
.pointer("/event/message/chat_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("p2p");
|
|
||||||
|
|
||||||
if chat_type == "group" {
|
|
||||||
let mentions = event
|
|
||||||
.pointer("/event/message/mentions")
|
|
||||||
.and_then(|v| v.as_array());
|
|
||||||
if mentions.is_none() || mentions.unwrap().is_empty() {
|
|
||||||
return; // No mentions in group chat, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_str = event
|
|
||||||
.pointer("/event/message/content")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Content is JSON string: {"text":"actual message"}
|
|
||||||
let text = serde_json::from_str::<serde_json::Value>(content_str)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip mention placeholders (e.g. "@_user_1") from text
|
|
||||||
let clean_text = strip_lark_mentions(&text, event);
|
|
||||||
|
|
||||||
if clean_text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sender_id = event
|
|
||||||
.pointer("/event/sender/sender_id/open_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
eprintln!("[Lark] incoming message from {}: {}", sender_id, clean_text);
|
|
||||||
|
|
||||||
let _ = command_tx
|
|
||||||
.send(IncomingCommand {
|
|
||||||
channel_id,
|
|
||||||
sender_id,
|
|
||||||
command_text: clean_text,
|
|
||||||
metadata: event.clone(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip Lark mention placeholders (e.g. `@_user_1`) from the message text.
|
|
||||||
fn strip_lark_mentions(text: &str, event: &serde_json::Value) -> String {
|
|
||||||
let mut result = text.to_string();
|
|
||||||
if let Some(mentions) = event
|
|
||||||
.pointer("/event/message/mentions")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for mention in mentions {
|
|
||||||
if let Some(key) = mention.get("key").and_then(|v| v.as_str()) {
|
|
||||||
result = result.replace(key, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a fresh WebSocket endpoint URL from Feishu.
|
|
||||||
async fn fetch_ws_url(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
app_id: &str,
|
|
||||||
app_secret: &str,
|
|
||||||
) -> Result<String, ChatChannelError> {
|
|
||||||
let resp = client
|
|
||||||
.post(format!("{}/callback/ws/endpoint", FEISHU_BASE_URL))
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"AppID": app_id,
|
|
||||||
"AppSecret": app_secret,
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let ws_resp: WsConnectResponse = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if ws_resp.code != 0 {
|
|
||||||
return Err(ChatChannelError::ConnectionFailed(format!(
|
|
||||||
"WS connect failed: code={}, msg={}",
|
|
||||||
ws_resp.code, ws_resp.msg
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ws_resp
|
|
||||||
.data
|
|
||||||
.and_then(|d| d.url)
|
|
||||||
.ok_or_else(|| ChatChannelError::ConnectionFailed("No WebSocket URL returned".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ChatChannelBackend for LarkBackend {
|
|
||||||
fn channel_type(&self) -> ChannelType {
|
|
||||||
ChannelType::Lark
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start(
|
|
||||||
&self,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connecting;
|
|
||||||
self.get_tenant_access_token().await?;
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connected;
|
|
||||||
|
|
||||||
if let Err(e) = self.start_ws_receiver(command_tx).await {
|
|
||||||
eprintln!("[Lark] WebSocket receiver failed to start: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop(&self) -> Result<(), ChatChannelError> {
|
|
||||||
if let Some(tx) = self.shutdown_tx.lock().await.take() {
|
|
||||||
let _ = tx.send(true);
|
|
||||||
}
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelConnectionStatus {
|
|
||||||
*self.status.lock().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let content = serde_json::json!({ "text": text }).to_string();
|
|
||||||
self.send_lark_message("text", &content).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_rich_message(
|
|
||||||
&self,
|
|
||||||
message: &RichMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let card = build_lark_card(message);
|
|
||||||
let content = serde_json::to_string(&card)
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
self.send_lark_message("interactive", &content).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_connection(&self) -> Result<(), ChatChannelError> {
|
|
||||||
self.get_tenant_access_token().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_lark_card(msg: &RichMessage) -> serde_json::Value {
|
|
||||||
let header_color = match msg.level {
|
|
||||||
MessageLevel::Info => "blue",
|
|
||||||
MessageLevel::Warning => "orange",
|
|
||||||
MessageLevel::Error => "red",
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = msg.title.as_deref().unwrap_or("Codeg");
|
|
||||||
|
|
||||||
let mut elements: Vec<serde_json::Value> = Vec::new();
|
|
||||||
|
|
||||||
if !msg.body.is_empty() {
|
|
||||||
elements.push(serde_json::json!({
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": msg.body,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !msg.fields.is_empty() {
|
|
||||||
let field_elements: Vec<serde_json::Value> = msg
|
|
||||||
.fields
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| {
|
|
||||||
serde_json::json!({
|
|
||||||
"is_short": true,
|
|
||||||
"text": {
|
|
||||||
"tag": "lark_md",
|
|
||||||
"content": format!("**{}**\n{}", k, v),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
elements.push(serde_json::json!({
|
|
||||||
"tag": "div",
|
|
||||||
"fields": field_elements,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"config": { "wide_screen_mode": true },
|
|
||||||
"header": {
|
|
||||||
"title": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": title,
|
|
||||||
},
|
|
||||||
"template": header_color,
|
|
||||||
},
|
|
||||||
"elements": elements,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
pub mod lark;
|
|
||||||
pub mod telegram;
|
|
||||||
pub mod weixin;
|
|
||||||
|
|
||||||
use super::error::ChatChannelError;
|
|
||||||
use super::traits::ChatChannelBackend;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Factory function to create a backend instance from channel type, config, and token.
|
|
||||||
/// Eliminates duplicated match blocks across connect, test, and auto-connect paths.
|
|
||||||
pub fn create_backend(
|
|
||||||
channel_id: i32,
|
|
||||||
channel_type: ChannelType,
|
|
||||||
config: &serde_json::Value,
|
|
||||||
token: String,
|
|
||||||
) -> Result<Box<dyn ChatChannelBackend>, ChatChannelError> {
|
|
||||||
match channel_type {
|
|
||||||
ChannelType::Telegram => {
|
|
||||||
let cfg: TelegramConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
|
||||||
ChatChannelError::ConfigurationInvalid(format!("Invalid Telegram config: {e}"))
|
|
||||||
})?;
|
|
||||||
if cfg.chat_id.is_empty() {
|
|
||||||
return Err(ChatChannelError::ConfigurationInvalid(
|
|
||||||
"chat_id is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Box::new(telegram::TelegramBackend::new(
|
|
||||||
channel_id,
|
|
||||||
token,
|
|
||||||
cfg.chat_id,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
ChannelType::Weixin => {
|
|
||||||
let cfg: WeixinConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
|
||||||
ChatChannelError::ConfigurationInvalid(format!("Invalid Weixin config: {e}"))
|
|
||||||
})?;
|
|
||||||
if cfg.base_url.is_empty() {
|
|
||||||
return Err(ChatChannelError::ConfigurationInvalid(
|
|
||||||
"base_url is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Box::new(weixin::WeixinBackend::new(
|
|
||||||
channel_id,
|
|
||||||
token,
|
|
||||||
cfg.base_url,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
ChannelType::Lark => {
|
|
||||||
let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| {
|
|
||||||
ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}"))
|
|
||||||
})?;
|
|
||||||
if cfg.app_id.is_empty() || cfg.chat_id.is_empty() {
|
|
||||||
return Err(ChatChannelError::ConfigurationInvalid(
|
|
||||||
"app_id and chat_id are required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Box::new(lark::LarkBackend::new(
|
|
||||||
channel_id,
|
|
||||||
cfg.app_id,
|
|
||||||
token,
|
|
||||||
cfg.chat_id,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
|
|
||||||
use crate::chat_channel::error::ChatChannelError;
|
|
||||||
use crate::chat_channel::traits::ChatChannelBackend;
|
|
||||||
use crate::chat_channel::types::*;
|
|
||||||
|
|
||||||
pub struct TelegramBackend {
|
|
||||||
bot_token: String,
|
|
||||||
chat_id: String,
|
|
||||||
client: reqwest::Client,
|
|
||||||
status: Arc<Mutex<ChannelConnectionStatus>>,
|
|
||||||
channel_id: i32,
|
|
||||||
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TelegramBackend {
|
|
||||||
pub fn new(channel_id: i32, bot_token: String, chat_id: String) -> Self {
|
|
||||||
Self {
|
|
||||||
bot_token,
|
|
||||||
chat_id,
|
|
||||||
client: reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.timeout(Duration::from_secs(60))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
|
|
||||||
channel_id,
|
|
||||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_url(&self, method: &str) -> String {
|
|
||||||
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_text(
|
|
||||||
&self,
|
|
||||||
text: &str,
|
|
||||||
parse_mode: Option<&str>,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let mut body = serde_json::json!({
|
|
||||||
"chat_id": self.chat_id,
|
|
||||||
"text": text,
|
|
||||||
});
|
|
||||||
if let Some(mode) = parse_mode {
|
|
||||||
body["parse_mode"] = serde_json::Value::String(mode.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(self.api_url("sendMessage"))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let result: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if result.get("ok").and_then(|v| v.as_bool()) != Some(true) {
|
|
||||||
let desc = result
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown error");
|
|
||||||
return Err(ChatChannelError::SendFailed(desc.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let message_id = result
|
|
||||||
.pointer("/result/message_id")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.map(|id| id.to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(SentMessageId(message_id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ChatChannelBackend for TelegramBackend {
|
|
||||||
fn channel_type(&self) -> ChannelType {
|
|
||||||
ChannelType::Telegram
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start(
|
|
||||||
&self,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connecting;
|
|
||||||
|
|
||||||
// Verify bot token and extract bot username for group @mention filtering
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(self.api_url("getMe"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let me_body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if me_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(
|
|
||||||
"Invalid bot token".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bot_username = me_body
|
|
||||||
.pointer("/result/username")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connected;
|
|
||||||
|
|
||||||
// Start long-polling loop
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
|
|
||||||
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
|
||||||
|
|
||||||
let client = self.client.clone();
|
|
||||||
let bot_token = self.bot_token.clone();
|
|
||||||
let channel_id = self.channel_id;
|
|
||||||
let status = self.status.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut offset: i64 = 0;
|
|
||||||
loop {
|
|
||||||
if *shutdown_rx.borrow() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://api.telegram.org/bot{}/getUpdates?timeout=30&offset={}",
|
|
||||||
bot_token, offset
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = tokio::select! {
|
|
||||||
r = client.get(&url).send() => r,
|
|
||||||
_ = shutdown_rx.changed() => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(resp) => {
|
|
||||||
// Recover from error state after successful poll
|
|
||||||
{
|
|
||||||
let mut s = status.lock().await;
|
|
||||||
if *s == ChannelConnectionStatus::Error {
|
|
||||||
*s = ChannelConnectionStatus::Connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
|
||||||
if let Some(updates) = body.get("result").and_then(|r| r.as_array()) {
|
|
||||||
if !updates.is_empty() {
|
|
||||||
eprintln!("[Telegram] got {} update(s)", updates.len());
|
|
||||||
}
|
|
||||||
for update in updates {
|
|
||||||
if let Some(uid) =
|
|
||||||
update.get("update_id").and_then(|u| u.as_i64())
|
|
||||||
{
|
|
||||||
offset = uid + 1;
|
|
||||||
}
|
|
||||||
if let Some(text) =
|
|
||||||
update.pointer("/message/text").and_then(|t| t.as_str())
|
|
||||||
{
|
|
||||||
// Group chat filtering: only process if @bot is mentioned
|
|
||||||
let chat_type = update
|
|
||||||
.pointer("/message/chat/type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("private");
|
|
||||||
|
|
||||||
if (chat_type == "group" || chat_type == "supergroup")
|
|
||||||
&& !bot_username.is_empty()
|
|
||||||
{
|
|
||||||
let at_bot = format!("@{}", bot_username);
|
|
||||||
if !text.to_lowercase().contains(&at_bot) {
|
|
||||||
eprintln!("[Telegram] skipped group msg without @bot: {text}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip @bot_username from command text (case-insensitive)
|
|
||||||
let clean_text = strip_bot_mention(text, &bot_username);
|
|
||||||
|
|
||||||
let sender_id = update
|
|
||||||
.pointer("/message/from/id")
|
|
||||||
.and_then(|i| i.as_i64())
|
|
||||||
.map(|i| i.to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
eprintln!("[Telegram] dispatching: {clean_text}");
|
|
||||||
let send_result = command_tx
|
|
||||||
.send(IncomingCommand {
|
|
||||||
channel_id,
|
|
||||||
sender_id,
|
|
||||||
command_text: clean_text,
|
|
||||||
metadata: update.clone(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
if let Err(e) = send_result {
|
|
||||||
eprintln!("[Telegram] command_tx.send failed: {e}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[Telegram] update without /message/text");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[Telegram] failed to parse response body");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[Telegram] polling error: {e}");
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop(&self) -> Result<(), ChatChannelError> {
|
|
||||||
if let Some(tx) = self.shutdown_tx.lock().await.take() {
|
|
||||||
let _ = tx.send(true);
|
|
||||||
}
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelConnectionStatus {
|
|
||||||
*self.status.lock().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
self.send_text(text, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_rich_message(
|
|
||||||
&self,
|
|
||||||
message: &RichMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let markdown_text = format_telegram_markdown(message);
|
|
||||||
let result = self.send_text(&markdown_text, Some("MarkdownV2")).await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(id) => Ok(id),
|
|
||||||
Err(e) => {
|
|
||||||
// MarkdownV2 failed — fall back to plain text
|
|
||||||
eprintln!("[Telegram] MarkdownV2 send failed: {e}, retrying as plain text");
|
|
||||||
let plain_text = message.to_plain_text();
|
|
||||||
self.send_text(&plain_text, None).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_connection(&self) -> Result<(), ChatChannelError> {
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(self.api_url("getMe"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let desc = body
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Invalid bot token");
|
|
||||||
Err(ChatChannelError::AuthenticationFailed(desc.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip `@bot_username` from text (case-insensitive).
|
|
||||||
/// Handles Telegram convention: `/command@botname args` → `/command args`
|
|
||||||
fn strip_bot_mention(text: &str, bot_username: &str) -> String {
|
|
||||||
if bot_username.is_empty() {
|
|
||||||
return text.to_string();
|
|
||||||
}
|
|
||||||
let at_bot = format!("@{}", bot_username);
|
|
||||||
let text_lower = text.to_lowercase();
|
|
||||||
let at_bot_lower = at_bot.to_lowercase();
|
|
||||||
if let Some(pos) = text_lower.find(&at_bot_lower) {
|
|
||||||
let mut result = String::with_capacity(text.len());
|
|
||||||
result.push_str(&text[..pos]);
|
|
||||||
result.push_str(&text[pos + at_bot.len()..]);
|
|
||||||
result.trim().to_string()
|
|
||||||
} else {
|
|
||||||
text.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_telegram_markdown(msg: &RichMessage) -> String {
|
|
||||||
let mut text = String::new();
|
|
||||||
|
|
||||||
let level_emoji = match msg.level {
|
|
||||||
MessageLevel::Info => "ℹ️",
|
|
||||||
MessageLevel::Warning => "⚠️",
|
|
||||||
MessageLevel::Error => "❌",
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(title) = &msg.title {
|
|
||||||
text.push_str(&format!("{} *{}*\n", level_emoji, escape_markdown(title)));
|
|
||||||
}
|
|
||||||
|
|
||||||
text.push_str(&escape_markdown(&msg.body));
|
|
||||||
|
|
||||||
if !msg.fields.is_empty() {
|
|
||||||
text.push('\n');
|
|
||||||
for (key, value) in &msg.fields {
|
|
||||||
text.push_str(&format!(
|
|
||||||
"\n*{}*: {}",
|
|
||||||
escape_markdown(key),
|
|
||||||
escape_markdown(value)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_markdown(text: &str) -> String {
|
|
||||||
// Backslash must be escaped first to avoid double-escaping
|
|
||||||
text.replace('\\', "\\\\")
|
|
||||||
.replace('_', "\\_")
|
|
||||||
.replace('*', "\\*")
|
|
||||||
.replace('[', "\\[")
|
|
||||||
.replace(']', "\\]")
|
|
||||||
.replace('(', "\\(")
|
|
||||||
.replace(')', "\\)")
|
|
||||||
.replace('~', "\\~")
|
|
||||||
.replace('`', "\\`")
|
|
||||||
.replace('>', "\\>")
|
|
||||||
.replace('#', "\\#")
|
|
||||||
.replace('+', "\\+")
|
|
||||||
.replace('-', "\\-")
|
|
||||||
.replace('=', "\\=")
|
|
||||||
.replace('|', "\\|")
|
|
||||||
.replace('{', "\\{")
|
|
||||||
.replace('}', "\\}")
|
|
||||||
.replace('.', "\\.")
|
|
||||||
.replace('!', "\\!")
|
|
||||||
}
|
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
|
||||||
use rand::Rng;
|
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
|
|
||||||
use crate::chat_channel::error::ChatChannelError;
|
|
||||||
use crate::chat_channel::traits::ChatChannelBackend;
|
|
||||||
use crate::chat_channel::types::*;
|
|
||||||
|
|
||||||
const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
|
||||||
const ILINK_CHANNEL_VERSION: &str = "1.0.2";
|
|
||||||
/// Maximum number of messages buffered while context_token is expired.
|
|
||||||
const MAX_PENDING_MESSAGES: usize = 50;
|
|
||||||
|
|
||||||
/// Shared HTTP client for QR code auth requests (avoids re-creating TLS state).
|
|
||||||
fn qr_client() -> reqwest::Client {
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
|
||||||
CLIENT
|
|
||||||
.get_or_init(|| {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.timeout(Duration::from_secs(15))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── QR code auth types (public, used by commands) ──
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WeixinQrcodeInfo {
|
|
||||||
pub qrcode_id: String,
|
|
||||||
pub qrcode_img_content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WeixinQrcodeStatus {
|
|
||||||
pub status: String,
|
|
||||||
/// bot_token and base_url are consumed by the _core command layer and
|
|
||||||
/// stripped before the response reaches the frontend.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub bot_token: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frontend-safe subset of [`WeixinQrcodeStatus`] — no credentials.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WeixinQrcodeStatusPublic {
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SendRequest<'a> {
|
|
||||||
client: &'a reqwest::Client,
|
|
||||||
base_url: &'a str,
|
|
||||||
bot_token: &'a str,
|
|
||||||
wechat_uin: &'a str,
|
|
||||||
to_user_id: &'a str,
|
|
||||||
context_token: &'a str,
|
|
||||||
text: &'a str,
|
|
||||||
reply_context: &'a Mutex<Option<WeixinReplyContext>>,
|
|
||||||
pending_messages: &'a Mutex<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── QR code auth functions (called before backend exists) ──
|
|
||||||
|
|
||||||
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
|
|
||||||
let client = qr_client();
|
|
||||||
let resp = client
|
|
||||||
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode"))
|
|
||||||
.query(&[("bot_type", "3")])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code request failed: {e}")))?;
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code parse failed: {e}")))?;
|
|
||||||
|
|
||||||
let qrcode_id = body
|
|
||||||
.get("qrcode")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
let raw_img = body
|
|
||||||
.get("qrcode_img_content")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if qrcode_id.is_empty() {
|
|
||||||
return Err(ChatChannelError::ConnectionFailed(
|
|
||||||
"Empty qrcode in response".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the image content is a URL, try to fetch the actual image bytes.
|
|
||||||
// If the URL points to an HTML SPA (which renders the QR code via JS),
|
|
||||||
// generate the QR code ourselves — the SPA simply encodes the page URL.
|
|
||||||
let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") {
|
|
||||||
match fetch_image_as_data_uri(&client, &raw_img).await {
|
|
||||||
Ok(data_uri) => data_uri,
|
|
||||||
Err(_) => {
|
|
||||||
eprintln!("[Weixin] URL is an SPA page, generating QR code from URL");
|
|
||||||
generate_qrcode_data_uri(&raw_img)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
raw_img
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(WeixinQrcodeInfo {
|
|
||||||
qrcode_id,
|
|
||||||
qrcode_img_content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch an image from a URL and return it as a `data:<mime>;base64,...` string.
|
|
||||||
///
|
|
||||||
/// Returns an error if the URL points to an HTML page (SPA) rather than a
|
|
||||||
/// raw image — the caller will generate a QR code from the URL instead.
|
|
||||||
async fn fetch_image_as_data_uri(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<String, ChatChannelError> {
|
|
||||||
let resp = client
|
|
||||||
.get(url)
|
|
||||||
.header(
|
|
||||||
reqwest::header::USER_AGENT,
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
||||||
)
|
|
||||||
.header(reqwest::header::REFERER, ILINK_BASE_URL)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image fetch failed: {e}")))?;
|
|
||||||
|
|
||||||
let content_type = resp
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("image/png")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if content_type.contains("text/html") || content_type.contains("text/plain") {
|
|
||||||
return Err(ChatChannelError::ConnectionFailed(
|
|
||||||
"QR code URL is an SPA page".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = resp
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?;
|
|
||||||
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err(ChatChannelError::ConnectionFailed(
|
|
||||||
"Empty image response".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let b64 = B64.encode(&bytes);
|
|
||||||
let mime = content_type.split(';').next().unwrap_or("image/png").trim();
|
|
||||||
Ok(format!("data:{mime};base64,{b64}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a QR code image encoding the given text and return as a PNG data URI.
|
|
||||||
///
|
|
||||||
/// The iLink QR page is a SPA that renders `window.location.href` as a QR code.
|
|
||||||
/// We replicate that logic server-side so the frontend can display it directly.
|
|
||||||
fn generate_qrcode_data_uri(content: &str) -> Result<String, ChatChannelError> {
|
|
||||||
use image::{codecs::png::PngEncoder, ImageEncoder, Luma};
|
|
||||||
use qrcode::QrCode;
|
|
||||||
|
|
||||||
let code = QrCode::new(content.as_bytes()).map_err(|e| {
|
|
||||||
ChatChannelError::ConnectionFailed(format!("QR code generation failed: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let img = code
|
|
||||||
.render::<Luma<u8>>()
|
|
||||||
.quiet_zone(true)
|
|
||||||
.min_dimensions(250, 250)
|
|
||||||
.build();
|
|
||||||
let (w, h) = (img.width(), img.height());
|
|
||||||
|
|
||||||
let mut png_buf: Vec<u8> = Vec::new();
|
|
||||||
PngEncoder::new(&mut png_buf)
|
|
||||||
.write_image(img.as_raw(), w, h, image::ExtendedColorType::L8)
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("PNG encoding failed: {e}")))?;
|
|
||||||
|
|
||||||
let b64 = B64.encode(&png_buf);
|
|
||||||
Ok(format!("data:image/png;base64,{b64}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn weixin_check_qrcode(qrcode: &str) -> Result<WeixinQrcodeStatus, ChatChannelError> {
|
|
||||||
let client = qr_client();
|
|
||||||
let resp = client
|
|
||||||
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
|
|
||||||
.query(&[("qrcode", qrcode)])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status parse failed: {e}")))?;
|
|
||||||
|
|
||||||
let status = body
|
|
||||||
.get("status")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("waiting")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let bot_token = body
|
|
||||||
.get("bot_token")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
let base_url = body
|
|
||||||
.get("baseurl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
Ok(WeixinQrcodeStatus {
|
|
||||||
status,
|
|
||||||
bot_token,
|
|
||||||
base_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Backend implementation ──
|
|
||||||
|
|
||||||
struct WeixinReplyContext {
|
|
||||||
to_user_id: String,
|
|
||||||
context_token: String,
|
|
||||||
expired: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct WeixinBackend {
|
|
||||||
bot_token: String,
|
|
||||||
base_url: String,
|
|
||||||
client: reqwest::Client,
|
|
||||||
status: Arc<Mutex<ChannelConnectionStatus>>,
|
|
||||||
channel_id: i32,
|
|
||||||
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
|
||||||
reply_context: Arc<Mutex<Option<WeixinReplyContext>>>,
|
|
||||||
/// Messages that failed due to expired context_token, resend on next refresh.
|
|
||||||
pending_messages: Arc<Mutex<Vec<String>>>,
|
|
||||||
/// Stable X-WECHAT-UIN value for this backend instance.
|
|
||||||
wechat_uin: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WeixinBackend {
|
|
||||||
pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self {
|
|
||||||
let uin_raw = rand::thread_rng().gen::<u32>().to_string();
|
|
||||||
let wechat_uin = B64.encode(uin_raw.as_bytes());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
bot_token,
|
|
||||||
base_url,
|
|
||||||
client: reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.timeout(Duration::from_secs(45))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
|
|
||||||
channel_id,
|
|
||||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
|
||||||
reply_context: Arc::new(Mutex::new(None)),
|
|
||||||
pending_messages: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
wechat_uin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_headers(bot_token: &str, wechat_uin: &str) -> HeaderMap {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
|
||||||
headers.insert(
|
|
||||||
"AuthorizationType",
|
|
||||||
HeaderValue::from_static("ilink_bot_token"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Ok(val) = HeaderValue::from_str(wechat_uin) {
|
|
||||||
headers.insert("X-WECHAT-UIN", val);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bearer = format!("Bearer {bot_token}");
|
|
||||||
if let Ok(val) = HeaderValue::from_str(&bearer) {
|
|
||||||
headers.insert("Authorization", val);
|
|
||||||
}
|
|
||||||
|
|
||||||
headers
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the JSON body for the iLink sendmessage API.
|
|
||||||
fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"msg": {
|
|
||||||
"from_user_id": "",
|
|
||||||
"to_user_id": to_user_id,
|
|
||||||
"client_id": format!("codeg-{}", uuid::Uuid::new_v4()),
|
|
||||||
"message_type": 2,
|
|
||||||
"message_state": 2,
|
|
||||||
"context_token": context_token,
|
|
||||||
"item_list": [{
|
|
||||||
"type": 1,
|
|
||||||
"text_item": { "text": text }
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message via the iLink API and handle the response.
|
|
||||||
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
|
|
||||||
async fn do_send(req: SendRequest<'_>) -> Result<bool, ChatChannelError> {
|
|
||||||
let body = Self::build_send_body(req.to_user_id, req.context_token, req.text);
|
|
||||||
let url = format!("{}/ilink/bot/sendmessage", req.base_url);
|
|
||||||
|
|
||||||
let resp = req
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.headers(Self::build_headers(req.bot_token, req.wechat_uin))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let status_code = resp.status();
|
|
||||||
let resp_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if !status_code.is_success() {
|
|
||||||
return Err(ChatChannelError::SendFailed(format!(
|
|
||||||
"HTTP {status_code}: {resp_text}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for ret errors in response (e.g. -2 = context expired)
|
|
||||||
if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&resp_text) {
|
|
||||||
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
|
|
||||||
if ret != 0 {
|
|
||||||
let errmsg = resp_json
|
|
||||||
.get("errmsg")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}");
|
|
||||||
|
|
||||||
if ret == -2 {
|
|
||||||
// Context token expired — mark stale and buffer
|
|
||||||
if let Some(ref mut c) = *req.reply_context.lock().await {
|
|
||||||
c.expired = true;
|
|
||||||
}
|
|
||||||
let mut buf = req.pending_messages.lock().await;
|
|
||||||
if buf.len() < MAX_PENDING_MESSAGES {
|
|
||||||
buf.push(req.text.to_string());
|
|
||||||
}
|
|
||||||
eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_text(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
// Extract context data under lock, then release
|
|
||||||
let (to_user_id, context_token, expired) = {
|
|
||||||
let guard = self.reply_context.lock().await;
|
|
||||||
let ctx = guard.as_ref().ok_or_else(|| {
|
|
||||||
ChatChannelError::SendFailed(
|
|
||||||
"No active WeChat conversation context. A user must message the bot first."
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
(
|
|
||||||
ctx.to_user_id.clone(),
|
|
||||||
ctx.context_token.clone(),
|
|
||||||
ctx.expired,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// If context is expired, buffer the message for resend on next refresh
|
|
||||||
if expired {
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] context expired, buffering message (len={})",
|
|
||||||
text.len()
|
|
||||||
);
|
|
||||||
let mut buf = self.pending_messages.lock().await;
|
|
||||||
if buf.len() < MAX_PENDING_MESSAGES {
|
|
||||||
buf.push(text.to_string());
|
|
||||||
} else {
|
|
||||||
eprintln!("[Weixin] pending buffer full, dropping message");
|
|
||||||
}
|
|
||||||
return Ok(SentMessageId(String::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}",
|
|
||||||
context_token.len(),
|
|
||||||
text.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Self::do_send(SendRequest {
|
|
||||||
client: &self.client,
|
|
||||||
base_url: &self.base_url,
|
|
||||||
bot_token: &self.bot_token,
|
|
||||||
wechat_uin: &self.wechat_uin,
|
|
||||||
to_user_id: &to_user_id,
|
|
||||||
context_token: &context_token,
|
|
||||||
text,
|
|
||||||
reply_context: &self.reply_context,
|
|
||||||
pending_messages: &self.pending_messages,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(SentMessageId(String::new()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ChatChannelBackend for WeixinBackend {
|
|
||||||
fn channel_type(&self) -> ChannelType {
|
|
||||||
ChannelType::Weixin
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start(
|
|
||||||
&self,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connecting;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] start: base_url={}, token_len={}",
|
|
||||||
self.base_url,
|
|
||||||
self.bot_token.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify auth by doing a quick getupdates with empty cursor
|
|
||||||
let verify_body = serde_json::json!({
|
|
||||||
"get_updates_buf": "",
|
|
||||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
|
||||||
});
|
|
||||||
let url = format!("{}/ilink/bot/getupdates", self.base_url);
|
|
||||||
eprintln!("[Weixin] verify POST {url}");
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
|
|
||||||
.json(&verify_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let status_code = resp.status();
|
|
||||||
let resp_text = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
|
|
||||||
|
|
||||||
let verify_result: serde_json::Value = serde_json::from_str(&resp_text)
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?;
|
|
||||||
|
|
||||||
// iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}`
|
|
||||||
// (no `ret` field). Treat any non-zero errcode as authentication failure.
|
|
||||||
if let Some(errcode) = verify_result.get("errcode").and_then(|v| v.as_i64()) {
|
|
||||||
if errcode != 0 {
|
|
||||||
let errmsg = verify_result
|
|
||||||
.get("errmsg")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown error");
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(format!(
|
|
||||||
"Weixin verification failed (errcode={errcode}): {errmsg}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ret = verify_result.get("ret").and_then(|v| v.as_i64());
|
|
||||||
|
|
||||||
// Check for known auth-failure codes
|
|
||||||
if ret == Some(-14) {
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(
|
|
||||||
"Session expired (ret=-14), please re-authenticate".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The iLink API may omit the `ret` field or return non-zero on the first
|
|
||||||
// call. Always extract the cursor if present — it's needed for polling.
|
|
||||||
let initial_cursor = verify_result
|
|
||||||
.get("get_updates_buf")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if let Some(r) = ret {
|
|
||||||
if r != 0 {
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] verify returned ret={r}, but got cursor len={}",
|
|
||||||
initial_cursor.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Connected;
|
|
||||||
|
|
||||||
// Start long-polling loop
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
|
|
||||||
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
|
||||||
|
|
||||||
let client = self.client.clone();
|
|
||||||
let bot_token = self.bot_token.clone();
|
|
||||||
let base_url = self.base_url.clone();
|
|
||||||
let wechat_uin = self.wechat_uin.clone();
|
|
||||||
let channel_id = self.channel_id;
|
|
||||||
let status = self.status.clone();
|
|
||||||
let reply_context = self.reply_context.clone();
|
|
||||||
let pending_messages = self.pending_messages.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut cursor = initial_cursor;
|
|
||||||
let mut consecutive_errors: u32 = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if *shutdown_rx.borrow() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"get_updates_buf": cursor,
|
|
||||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = tokio::select! {
|
|
||||||
r = client
|
|
||||||
.post(format!("{base_url}/ilink/bot/getupdates"))
|
|
||||||
.headers(WeixinBackend::build_headers(&bot_token, &wechat_uin))
|
|
||||||
.json(&body)
|
|
||||||
.send() => r,
|
|
||||||
_ = shutdown_rx.changed() => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(resp) => {
|
|
||||||
// Recover from error state after successful poll
|
|
||||||
consecutive_errors = 0;
|
|
||||||
{
|
|
||||||
let mut s = status.lock().await;
|
|
||||||
if *s == ChannelConnectionStatus::Error {
|
|
||||||
*s = ChannelConnectionStatus::Connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
|
||||||
let ret = body.get("ret").and_then(|v| v.as_i64());
|
|
||||||
|
|
||||||
// Always update cursor if present
|
|
||||||
if let Some(new_cursor) =
|
|
||||||
body.get("get_updates_buf").and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
if !new_cursor.is_empty() {
|
|
||||||
cursor = new_cursor.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If ret is explicitly non-zero (not just missing), log it
|
|
||||||
if let Some(r) = ret {
|
|
||||||
if r != 0 {
|
|
||||||
eprintln!("[Weixin] getupdates ret={r}");
|
|
||||||
}
|
|
||||||
// Session expired — pause and wait for re-auth
|
|
||||||
if r == -14 {
|
|
||||||
eprintln!("[Weixin] session expired (ret=-14), pausing 30s");
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process messages
|
|
||||||
if let Some(msgs) = body.get("msgs").and_then(|v| v.as_array()) {
|
|
||||||
if !msgs.is_empty() {
|
|
||||||
eprintln!("[Weixin] got {} message(s)", msgs.len());
|
|
||||||
}
|
|
||||||
for msg in msgs {
|
|
||||||
// Only handle user messages (message_type=1),
|
|
||||||
// skip bot echo (message_type=2)
|
|
||||||
let msg_type = msg.get("message_type").and_then(|v| v.as_i64());
|
|
||||||
if msg_type != Some(1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text from type=1 (text) or type=3 (voice-to-text)
|
|
||||||
let text = msg
|
|
||||||
.get("item_list")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.and_then(|items| {
|
|
||||||
items.iter().find_map(|item| {
|
|
||||||
let t =
|
|
||||||
item.get("type").and_then(|v| v.as_i64())?;
|
|
||||||
match t {
|
|
||||||
1 => item
|
|
||||||
.pointer("/text_item/text")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
3 => item
|
|
||||||
.pointer("/voice_item/text")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let text = match text {
|
|
||||||
Some(t) if !t.is_empty() => t,
|
|
||||||
_ => {
|
|
||||||
eprintln!("[Weixin] skipped non-text message");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let from_user_id = msg
|
|
||||||
.get("from_user_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let context_token = msg
|
|
||||||
.get("context_token")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Store reply context for outbound messages
|
|
||||||
// Single lock scope to avoid TOCTOU
|
|
||||||
if !from_user_id.is_empty() && !context_token.is_empty() {
|
|
||||||
let was_expired = {
|
|
||||||
let mut guard = reply_context.lock().await;
|
|
||||||
let was =
|
|
||||||
guard.as_ref().map(|c| c.expired).unwrap_or(false);
|
|
||||||
*guard = Some(WeixinReplyContext {
|
|
||||||
to_user_id: from_user_id.to_string(),
|
|
||||||
context_token: context_token.to_string(),
|
|
||||||
expired: false,
|
|
||||||
});
|
|
||||||
was
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resend buffered messages with fresh context
|
|
||||||
if was_expired {
|
|
||||||
let buffered: Vec<String> =
|
|
||||||
pending_messages.lock().await.drain(..).collect();
|
|
||||||
if !buffered.is_empty() {
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] context refreshed, resending {} buffered message(s)",
|
|
||||||
buffered.len()
|
|
||||||
);
|
|
||||||
for pending_text in &buffered {
|
|
||||||
let ok = WeixinBackend::do_send(SendRequest {
|
|
||||||
client: &client,
|
|
||||||
base_url: &base_url,
|
|
||||||
bot_token: &bot_token,
|
|
||||||
wechat_uin: &wechat_uin,
|
|
||||||
to_user_id: from_user_id,
|
|
||||||
context_token,
|
|
||||||
text: pending_text,
|
|
||||||
reply_context: &reply_context,
|
|
||||||
pending_messages: &pending_messages,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
if let Err(e) = ok {
|
|
||||||
eprintln!("[Weixin] resend error: {e}");
|
|
||||||
// Re-buffer remaining on hard error
|
|
||||||
let mut buf = pending_messages.lock().await;
|
|
||||||
if buf.len() < MAX_PENDING_MESSAGES {
|
|
||||||
buf.push(pending_text.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If do_send returned Ok(false), it
|
|
||||||
// already re-buffered internally.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("[Weixin] dispatching: {text}");
|
|
||||||
let send_result = command_tx
|
|
||||||
.send(IncomingCommand {
|
|
||||||
channel_id,
|
|
||||||
sender_id: from_user_id.to_string(),
|
|
||||||
command_text: text.to_string(),
|
|
||||||
metadata: msg.clone(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
if let Err(e) = send_result {
|
|
||||||
eprintln!("[Weixin] command_tx.send failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("[Weixin] failed to parse response body");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
consecutive_errors += 1;
|
|
||||||
eprintln!("[Weixin] polling error ({consecutive_errors}): {e}");
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Error;
|
|
||||||
// Exponential backoff: 5s, 10s, 20s, capped at 30s
|
|
||||||
let delay =
|
|
||||||
std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30);
|
|
||||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop(&self) -> Result<(), ChatChannelError> {
|
|
||||||
if let Some(tx) = self.shutdown_tx.lock().await.take() {
|
|
||||||
let _ = tx.send(true);
|
|
||||||
}
|
|
||||||
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(&self) -> ChannelConnectionStatus {
|
|
||||||
*self.status.lock().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
self.send_text(text).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_rich_message(
|
|
||||||
&self,
|
|
||||||
message: &RichMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let plain_text = message.to_plain_text();
|
|
||||||
self.send_text(&plain_text).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_connection(&self) -> Result<(), ChatChannelError> {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"get_updates_buf": "",
|
|
||||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = format!("{}/ilink/bot/getupdates", self.base_url);
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let status_code = resp.status();
|
|
||||||
let resp_text = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
|
|
||||||
|
|
||||||
let resp_json: serde_json::Value = serde_json::from_str(&resp_text)
|
|
||||||
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?;
|
|
||||||
|
|
||||||
if !status_code.is_success() {
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(format!(
|
|
||||||
"HTTP {status_code}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for known auth-failure codes
|
|
||||||
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
|
|
||||||
if ret == -14 {
|
|
||||||
return Err(ChatChannelError::AuthenticationFailed(
|
|
||||||
"Session expired (ret=-14)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::command_handlers;
|
|
||||||
use super::i18n::{self, Lang};
|
|
||||||
use super::manager::ChatChannelManager;
|
|
||||||
use super::session_bridge::SessionBridge;
|
|
||||||
use super::session_commands;
|
|
||||||
use super::types::IncomingCommand;
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
|
||||||
use crate::db::service::{app_metadata_service, chat_channel_message_log_service};
|
|
||||||
use crate::web::event_bridge::EventEmitter;
|
|
||||||
|
|
||||||
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
|
|
||||||
const DEFAULT_COMMAND_PREFIX: &str = "/";
|
|
||||||
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
|
|
||||||
/// How often to refresh cached config from DB.
|
|
||||||
const CONFIG_CACHE_TTL_SECS: u64 = 30;
|
|
||||||
|
|
||||||
struct CommandConfigCache {
|
|
||||||
prefix: String,
|
|
||||||
lang: Lang,
|
|
||||||
last_refresh: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandConfigCache {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
prefix: DEFAULT_COMMAND_PREFIX.to_string(),
|
|
||||||
lang: Lang::default(),
|
|
||||||
// Force refresh on first use
|
|
||||||
last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_if_needed(&mut self, db: &DatabaseConnection) {
|
|
||||||
if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(Some(val)) = app_metadata_service::get_value(db, COMMAND_PREFIX_KEY).await {
|
|
||||||
self.prefix = val;
|
|
||||||
}
|
|
||||||
if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await {
|
|
||||||
self.lang = Lang::from_str_lossy(&val);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.last_refresh = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_command_dispatcher(
|
|
||||||
mut command_rx: mpsc::Receiver<IncomingCommand>,
|
|
||||||
manager: ChatChannelManager,
|
|
||||||
db_conn: DatabaseConnection,
|
|
||||||
conn_mgr: ConnectionManager,
|
|
||||||
emitter: EventEmitter,
|
|
||||||
bridge: Arc<Mutex<SessionBridge>>,
|
|
||||||
) -> JoinHandle<()> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut config = CommandConfigCache::new();
|
|
||||||
|
|
||||||
while let Some(cmd) = command_rx.recv().await {
|
|
||||||
let text = cmd.command_text.trim();
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] received command from channel={} sender={}: {:?}",
|
|
||||||
cmd.channel_id, cmd.sender_id, text
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log inbound command
|
|
||||||
let _ = chat_channel_message_log_service::create_log(
|
|
||||||
&db_conn,
|
|
||||||
cmd.channel_id,
|
|
||||||
"inbound",
|
|
||||||
"command_query",
|
|
||||||
text,
|
|
||||||
"sent",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
config.refresh_if_needed(&db_conn).await;
|
|
||||||
|
|
||||||
let response = dispatch_command(
|
|
||||||
text,
|
|
||||||
&config.prefix,
|
|
||||||
&db_conn,
|
|
||||||
&manager,
|
|
||||||
&conn_mgr,
|
|
||||||
&emitter,
|
|
||||||
&bridge,
|
|
||||||
cmd.channel_id,
|
|
||||||
&cmd.sender_id,
|
|
||||||
config.lang,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] dispatch result: title={:?}, body_len={}",
|
|
||||||
response.title,
|
|
||||||
response.body.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send response back via the same channel
|
|
||||||
let send_result = manager.send_to_channel(cmd.channel_id, &response).await;
|
|
||||||
let (status, error_detail) = match &send_result {
|
|
||||||
Ok(_) => ("sent", None),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] failed to send response for {:?} to channel {}: {e}",
|
|
||||||
text, cmd.channel_id
|
|
||||||
);
|
|
||||||
("failed", Some(e.to_string()))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = chat_channel_message_log_service::create_log(
|
|
||||||
&db_conn,
|
|
||||||
cmd.channel_id,
|
|
||||||
"outbound",
|
|
||||||
"command_response",
|
|
||||||
&response.to_plain_text(),
|
|
||||||
status,
|
|
||||||
error_detail,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn dispatch_command(
|
|
||||||
text: &str,
|
|
||||||
prefix: &str,
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
emitter: &EventEmitter,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
) -> super::types::RichMessage {
|
|
||||||
// Strip prefix; if text doesn't start with it, try as follow-up
|
|
||||||
let without_prefix = match text.strip_prefix(prefix) {
|
|
||||||
Some(rest) => rest,
|
|
||||||
None => {
|
|
||||||
// Check if sender has an active session for follow-up
|
|
||||||
let has_session = {
|
|
||||||
let guard = bridge.lock().await;
|
|
||||||
guard.find_by_sender(channel_id, sender_id).is_some()
|
|
||||||
};
|
|
||||||
if has_session {
|
|
||||||
return session_commands::handle_followup(session_commands::FollowupRequest {
|
|
||||||
db,
|
|
||||||
text,
|
|
||||||
channel_id,
|
|
||||||
sender_id,
|
|
||||||
conn_mgr,
|
|
||||||
bridge,
|
|
||||||
lang,
|
|
||||||
prefix,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
return command_handlers::handle_help(prefix, lang);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect();
|
|
||||||
let command = parts[0].to_lowercase();
|
|
||||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
|
||||||
|
|
||||||
match command.as_str() {
|
|
||||||
// Existing commands
|
|
||||||
"search" => {
|
|
||||||
if args.is_empty() {
|
|
||||||
super::types::RichMessage::info(i18n::search_usage(lang, prefix))
|
|
||||||
.with_title(i18n::invalid_args_title(lang))
|
|
||||||
} else {
|
|
||||||
command_handlers::handle_search(db, args, lang).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"today" => command_handlers::handle_today(db, lang).await,
|
|
||||||
"status" => command_handlers::handle_status(manager, lang).await,
|
|
||||||
"help" | "start" => command_handlers::handle_help(prefix, lang),
|
|
||||||
|
|
||||||
// Session commands
|
|
||||||
"folder" => {
|
|
||||||
session_commands::handle_folder(db, args, channel_id, sender_id, lang, prefix).await
|
|
||||||
}
|
|
||||||
"agent" => {
|
|
||||||
session_commands::handle_agent(db, args, channel_id, sender_id, lang, prefix).await
|
|
||||||
}
|
|
||||||
"task" | "do" => {
|
|
||||||
session_commands::handle_task(
|
|
||||||
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
"sessions" => {
|
|
||||||
session_commands::handle_sessions(db, channel_id, sender_id, lang, prefix).await
|
|
||||||
}
|
|
||||||
"resume" => {
|
|
||||||
session_commands::handle_resume(
|
|
||||||
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
"cancel" => {
|
|
||||||
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await
|
|
||||||
}
|
|
||||||
"approve" => {
|
|
||||||
let always = args.eq_ignore_ascii_case("always");
|
|
||||||
session_commands::handle_permission_response(
|
|
||||||
true, always, db, channel_id, sender_id, conn_mgr, bridge, lang,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
"deny" => {
|
|
||||||
session_commands::handle_permission_response(
|
|
||||||
false, false, db, channel_id, sender_id, conn_mgr, bridge, lang,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => super::types::RichMessage::info(i18n::unknown_command(lang, prefix, &command))
|
|
||||||
.with_title(i18n::unknown_command_title(lang)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
|
||||||
|
|
||||||
use super::i18n::{self, Lang};
|
|
||||||
use super::manager::ChatChannelManager;
|
|
||||||
use super::types::{MessageLevel, RichMessage};
|
|
||||||
use crate::db::entities::conversation;
|
|
||||||
|
|
||||||
pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage {
|
|
||||||
let matched = match conversation::Entity::find()
|
|
||||||
.filter(conversation::Column::DeletedAt.is_null())
|
|
||||||
.filter(conversation::Column::Title.contains(keyword))
|
|
||||||
.order_by_desc(conversation::Column::CreatedAt)
|
|
||||||
.limit(10)
|
|
||||||
.all(db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(rows) => rows,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage {
|
|
||||||
title: Some(i18n::query_failed_title(lang).to_string()),
|
|
||||||
body: e.to_string(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if matched.is_empty() {
|
|
||||||
return RichMessage::info(i18n::search_no_results(lang, keyword))
|
|
||||||
.with_title(i18n::search_results_title(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for conv in &matched {
|
|
||||||
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
|
|
||||||
let agent = &conv.agent_type;
|
|
||||||
let time = conv.created_at.format("%m-%d %H:%M");
|
|
||||||
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
|
|
||||||
}
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title(
|
|
||||||
lang,
|
|
||||||
keyword,
|
|
||||||
matched.len(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
|
|
||||||
let now = Utc::now();
|
|
||||||
let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
|
|
||||||
|
|
||||||
let rows = match conversation::Entity::find()
|
|
||||||
.filter(conversation::Column::DeletedAt.is_null())
|
|
||||||
.filter(conversation::Column::CreatedAt.gte(today_start))
|
|
||||||
.order_by_desc(conversation::Column::CreatedAt)
|
|
||||||
.all(db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(rows) => rows,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage {
|
|
||||||
title: Some(i18n::query_failed_title(lang).to_string()),
|
|
||||||
body: e.to_string(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return RichMessage::info(i18n::no_activity_today(lang))
|
|
||||||
.with_title(i18n::today_activity_title(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by agent_type
|
|
||||||
let mut by_agent: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
|
||||||
let mut titles: Vec<String> = Vec::new();
|
|
||||||
for conv in &rows {
|
|
||||||
*by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1;
|
|
||||||
if let Some(t) = &conv.title {
|
|
||||||
if titles.len() < 5 {
|
|
||||||
titles.push(t.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = i18n::total_sessions(lang, rows.len() as u32);
|
|
||||||
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
|
|
||||||
for (agent, count) in &by_agent {
|
|
||||||
body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !titles.is_empty() {
|
|
||||||
body.push_str(&format!("\n\n{}", i18n::recent_activity_label(lang)));
|
|
||||||
for t in &titles {
|
|
||||||
body.push_str(&format!("\n • {t}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RichMessage::info(body).with_title(i18n::today_activity_date_title(
|
|
||||||
lang,
|
|
||||||
&now.format("%Y-%m-%d").to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_status(manager: &ChatChannelManager, lang: Lang) -> RichMessage {
|
|
||||||
let statuses = manager.get_status().await;
|
|
||||||
if statuses.is_empty() {
|
|
||||||
return RichMessage::info(i18n::no_active_channels(lang))
|
|
||||||
.with_title(i18n::channel_status_title(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for s in &statuses {
|
|
||||||
let icon = match s.status.as_str() {
|
|
||||||
"connected" => "●",
|
|
||||||
"connecting" => "◎",
|
|
||||||
"error" => "✗",
|
|
||||||
_ => "○",
|
|
||||||
};
|
|
||||||
body.push_str(&format!(
|
|
||||||
"{} {} [{}] - {}\n",
|
|
||||||
icon, s.name, s.channel_type, s.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(i18n::channel_status_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_help(prefix: &str, lang: Lang) -> RichMessage {
|
|
||||||
RichMessage::info(i18n::help_body(lang, prefix)).with_title(i18n::help_title(lang))
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
use crate::app_error::AppCommandError;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ChatChannelError {
|
|
||||||
#[error("connection failed: {0}")]
|
|
||||||
ConnectionFailed(String),
|
|
||||||
#[error("send failed: {0}")]
|
|
||||||
SendFailed(String),
|
|
||||||
#[error("authentication failed: {0}")]
|
|
||||||
AuthenticationFailed(String),
|
|
||||||
#[error("configuration invalid: {0}")]
|
|
||||||
ConfigurationInvalid(String),
|
|
||||||
#[error("not connected")]
|
|
||||||
NotConnected,
|
|
||||||
#[error("already connected")]
|
|
||||||
AlreadyConnected,
|
|
||||||
#[error("channel not found: {0}")]
|
|
||||||
NotFound(i32),
|
|
||||||
#[error("{0}")]
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ChatChannelError> for AppCommandError {
|
|
||||||
fn from(err: ChatChannelError) -> Self {
|
|
||||||
match &err {
|
|
||||||
ChatChannelError::NotFound(_) => AppCommandError::not_found(err.to_string()),
|
|
||||||
ChatChannelError::AuthenticationFailed(_) => {
|
|
||||||
AppCommandError::authentication_failed(err.to_string())
|
|
||||||
}
|
|
||||||
ChatChannelError::ConfigurationInvalid(_) => {
|
|
||||||
AppCommandError::configuration_invalid(err.to_string())
|
|
||||||
}
|
|
||||||
ChatChannelError::ConnectionFailed(_) | ChatChannelError::SendFailed(_) => {
|
|
||||||
AppCommandError::network(err.to_string())
|
|
||||||
}
|
|
||||||
_ => AppCommandError::task_execution_failed(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::i18n::Lang;
|
|
||||||
use super::manager::ChatChannelManager;
|
|
||||||
use super::message_formatter;
|
|
||||||
use super::types::RichMessage;
|
|
||||||
use crate::db::service::{
|
|
||||||
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
|
|
||||||
};
|
|
||||||
use crate::web::event_bridge::WebEventBroadcaster;
|
|
||||||
|
|
||||||
/// Minimum interval between pushes for the same event type per channel (debounce).
|
|
||||||
const DEBOUNCE_SECS: u64 = 5;
|
|
||||||
/// How often to refresh cached config from DB.
|
|
||||||
const CONFIG_CACHE_TTL_SECS: u64 = 30;
|
|
||||||
|
|
||||||
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
|
|
||||||
const EVENT_FILTER_KEY: &str = "chat_event_filter";
|
|
||||||
|
|
||||||
struct CachedChannel {
|
|
||||||
id: i32,
|
|
||||||
event_filter_json: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EventConfigCache {
|
|
||||||
lang: Lang,
|
|
||||||
global_filter: Option<Vec<String>>,
|
|
||||||
enabled_channels: Vec<CachedChannel>,
|
|
||||||
last_refresh: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventConfigCache {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
lang: Lang::default(),
|
|
||||||
global_filter: None,
|
|
||||||
enabled_channels: Vec::new(),
|
|
||||||
// Force refresh on first use
|
|
||||||
last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_if_needed(&mut self, db: &DatabaseConnection) {
|
|
||||||
if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await {
|
|
||||||
self.lang = Lang::from_str_lossy(&val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse as Option<Vec<String>> so JSON "null" → None (intentional, not accidental)
|
|
||||||
self.global_filter = app_metadata_service::get_value(db, EVENT_FILTER_KEY)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|json| {
|
|
||||||
serde_json::from_str::<Option<Vec<String>>>(&json)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Ok(channels) = chat_channel_service::list_enabled(db).await {
|
|
||||||
self.enabled_channels = channels
|
|
||||||
.into_iter()
|
|
||||||
.map(|ch| CachedChannel {
|
|
||||||
id: ch.id,
|
|
||||||
event_filter_json: ch.event_filter_json,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.last_refresh = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_event_subscriber(
|
|
||||||
broadcaster: Arc<WebEventBroadcaster>,
|
|
||||||
manager: ChatChannelManager,
|
|
||||||
db_conn: DatabaseConnection,
|
|
||||||
) -> JoinHandle<()> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut rx = broadcaster.subscribe();
|
|
||||||
let mut last_push: HashMap<(i32, String), Instant> = HashMap::new();
|
|
||||||
let mut config = EventConfigCache::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let event = match rx.recv().await {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
eprintln!("[ChatChannel] event subscriber lagged by {n} messages");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
||||||
eprintln!("[ChatChannel] event broadcaster closed, stopping subscriber");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
config.refresh_if_needed(&db_conn).await;
|
|
||||||
|
|
||||||
// Prune stale debounce entries
|
|
||||||
last_push.retain(|_, t| t.elapsed() < Duration::from_secs(DEBOUNCE_SECS * 2));
|
|
||||||
|
|
||||||
if let Some((event_type, msg)) =
|
|
||||||
parse_event(&event.channel, event.payload.as_ref(), config.lang)
|
|
||||||
{
|
|
||||||
// Global event filter check
|
|
||||||
if let Some(filter) = &config.global_filter {
|
|
||||||
if !filter.contains(&event_type) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for ch in &config.enabled_channels {
|
|
||||||
// Per-channel event filter
|
|
||||||
if let Some(filter_json) = &ch.event_filter_json {
|
|
||||||
if let Ok(filter) = serde_json::from_str::<Vec<String>>(filter_json) {
|
|
||||||
if !filter.contains(&event_type) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce: skip if same event type was pushed to this channel recently
|
|
||||||
let key = (ch.id, event_type.clone());
|
|
||||||
let now = Instant::now();
|
|
||||||
if let Some(last) = last_push.get(&key) {
|
|
||||||
if now.duration_since(*last) < Duration::from_secs(DEBOUNCE_SECS) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send
|
|
||||||
let send_result = manager.send_to_channel(ch.id, &msg).await;
|
|
||||||
let (status, error_detail) = match &send_result {
|
|
||||||
Ok(_) => {
|
|
||||||
// Only update debounce timestamp on success
|
|
||||||
last_push.insert(key, now);
|
|
||||||
("sent", None)
|
|
||||||
}
|
|
||||||
Err(e) => ("failed", Some(e.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = chat_channel_message_log_service::create_log(
|
|
||||||
&db_conn,
|
|
||||||
ch.id,
|
|
||||||
"outbound",
|
|
||||||
"event_push",
|
|
||||||
&msg.to_plain_text(),
|
|
||||||
status,
|
|
||||||
error_detail,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_event(
|
|
||||||
channel: &str,
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
lang: Lang,
|
|
||||||
) -> Option<(String, RichMessage)> {
|
|
||||||
match channel {
|
|
||||||
"acp://event" => parse_acp_event(payload, lang),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_acp_event(payload: &serde_json::Value, lang: Lang) -> Option<(String, RichMessage)> {
|
|
||||||
let event_type = payload.get("type")?.as_str()?;
|
|
||||||
|
|
||||||
match event_type {
|
|
||||||
"turn_complete" => {
|
|
||||||
let stop_reason = payload
|
|
||||||
.get("stop_reason")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
// Only push for end_turn, not for intermediate completions
|
|
||||||
if stop_reason != "end_turn" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let agent_type = payload
|
|
||||||
.get("agent_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown Agent");
|
|
||||||
Some((
|
|
||||||
"turn_complete".to_string(),
|
|
||||||
message_formatter::format_turn_complete(agent_type, stop_reason, lang),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"error" => {
|
|
||||||
let agent_type = payload
|
|
||||||
.get("agent_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown Agent");
|
|
||||||
let message = payload
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown error");
|
|
||||||
Some((
|
|
||||||
"error".to_string(),
|
|
||||||
message_formatter::format_agent_error(agent_type, message, lang),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,339 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
|
|
||||||
use super::error::ChatChannelError;
|
|
||||||
use super::session_bridge::SessionBridge;
|
|
||||||
use super::traits::ChatChannelBackend;
|
|
||||||
use super::types::*;
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
|
||||||
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
|
||||||
|
|
||||||
struct ActiveChannel {
|
|
||||||
id: i32,
|
|
||||||
name: String,
|
|
||||||
channel_type: ChannelType,
|
|
||||||
backend: Arc<dyn ChatChannelBackend>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner state shared across clones.
|
|
||||||
struct Inner {
|
|
||||||
channels: Mutex<HashMap<i32, ActiveChannel>>,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
command_rx: Mutex<Option<mpsc::Receiver<IncomingCommand>>>,
|
|
||||||
broadcaster: Mutex<Option<Arc<WebEventBroadcaster>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ChatChannelManager {
|
|
||||||
inner: Arc<Inner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ChatChannelManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChatChannelManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (command_tx, command_rx) = mpsc::channel(256);
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Inner {
|
|
||||||
channels: Mutex::new(HashMap::new()),
|
|
||||||
command_tx,
|
|
||||||
command_rx: Mutex::new(Some(command_rx)),
|
|
||||||
broadcaster: Mutex::new(None),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shallow clone sharing the same state (like ConnectionManager::clone_ref).
|
|
||||||
pub fn clone_ref(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: self.inner.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn command_sender(&self) -> mpsc::Sender<IncomingCommand> {
|
|
||||||
self.inner.command_tx.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take the command receiver (can only be called once, at startup).
|
|
||||||
pub async fn take_command_receiver(&self) -> Option<mpsc::Receiver<IncomingCommand>> {
|
|
||||||
self.inner.command_rx.lock().await.take()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit a status change event to the frontend via broadcaster.
|
|
||||||
async fn emit_status_event(&self, channel_id: i32, status: &str) {
|
|
||||||
if let Some(broadcaster) = self.inner.broadcaster.lock().await.as_ref() {
|
|
||||||
broadcaster.send(
|
|
||||||
"chat-channel://status",
|
|
||||||
&serde_json::json!({
|
|
||||||
"channel_id": channel_id,
|
|
||||||
"status": status,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_channel(
|
|
||||||
&self,
|
|
||||||
id: i32,
|
|
||||||
name: String,
|
|
||||||
channel_type: ChannelType,
|
|
||||||
backend: Box<dyn ChatChannelBackend>,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
let backend: Arc<dyn ChatChannelBackend> = Arc::from(backend);
|
|
||||||
|
|
||||||
// Stop existing channel if present (prevents task leak on duplicate connect)
|
|
||||||
let old = self.inner.channels.lock().await.remove(&id);
|
|
||||||
if let Some(existing) = old {
|
|
||||||
let _ = existing.backend.stop().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let command_tx = self.inner.command_tx.clone();
|
|
||||||
backend.start(command_tx).await?;
|
|
||||||
|
|
||||||
let channel = ActiveChannel {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
channel_type,
|
|
||||||
backend,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.inner.channels.lock().await.insert(id, channel);
|
|
||||||
self.emit_status_event(id, "connected").await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove_channel(&self, id: i32) -> Result<(), ChatChannelError> {
|
|
||||||
let removed = self.inner.channels.lock().await.remove(&id);
|
|
||||||
if let Some(channel) = removed {
|
|
||||||
channel.backend.stop().await?;
|
|
||||||
self.emit_status_event(id, "disconnected").await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop_all(&self) {
|
|
||||||
let drained: Vec<ActiveChannel> = {
|
|
||||||
let mut channels = self.inner.channels.lock().await;
|
|
||||||
channels.drain().map(|(_, ch)| ch).collect()
|
|
||||||
};
|
|
||||||
for channel in drained {
|
|
||||||
let _ = channel.backend.stop().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_to_channel(
|
|
||||||
&self,
|
|
||||||
channel_id: i32,
|
|
||||||
message: &RichMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
let backend = {
|
|
||||||
let channels = self.inner.channels.lock().await;
|
|
||||||
channels
|
|
||||||
.get(&channel_id)
|
|
||||||
.ok_or(ChatChannelError::NotFound(channel_id))?
|
|
||||||
.backend
|
|
||||||
.clone()
|
|
||||||
};
|
|
||||||
backend.send_rich_message(message).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_to_all(&self, message: &RichMessage) {
|
|
||||||
let backends: Vec<Arc<dyn ChatChannelBackend>> = {
|
|
||||||
let channels = self.inner.channels.lock().await;
|
|
||||||
channels.values().map(|ch| ch.backend.clone()).collect()
|
|
||||||
};
|
|
||||||
for backend in backends {
|
|
||||||
let _ = backend.send_rich_message(message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_status(&self) -> Vec<crate::models::ChannelStatusInfo> {
|
|
||||||
let entries: Vec<(i32, String, String, Arc<dyn ChatChannelBackend>)> = {
|
|
||||||
let channels = self.inner.channels.lock().await;
|
|
||||||
channels
|
|
||||||
.values()
|
|
||||||
.map(|ch| {
|
|
||||||
(
|
|
||||||
ch.id,
|
|
||||||
ch.name.clone(),
|
|
||||||
ch.channel_type.to_string(),
|
|
||||||
ch.backend.clone(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
let mut result = Vec::with_capacity(entries.len());
|
|
||||||
for (id, name, ct, backend) in entries {
|
|
||||||
let status = backend.status().await;
|
|
||||||
result.push(crate::models::ChannelStatusInfo {
|
|
||||||
channel_id: id,
|
|
||||||
name,
|
|
||||||
channel_type: ct,
|
|
||||||
status: serde_json::to_value(status)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.as_str().map(String::from))
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn test_channel(&self, id: i32) -> Result<(), ChatChannelError> {
|
|
||||||
let backend = {
|
|
||||||
let channels = self.inner.channels.lock().await;
|
|
||||||
channels
|
|
||||||
.get(&id)
|
|
||||||
.ok_or(ChatChannelError::NotFound(id))?
|
|
||||||
.backend
|
|
||||||
.clone()
|
|
||||||
};
|
|
||||||
backend.test_connection().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_connected(&self, id: i32) -> bool {
|
|
||||||
let backend = {
|
|
||||||
let channels = self.inner.channels.lock().await;
|
|
||||||
channels.get(&id).map(|ch| ch.backend.clone())
|
|
||||||
};
|
|
||||||
if let Some(b) = backend {
|
|
||||||
b.status().await == ChannelConnectionStatus::Connected
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start background tasks (event subscriber + command dispatcher) and
|
|
||||||
/// auto-connect all enabled channels from DB.
|
|
||||||
pub async fn start_background(
|
|
||||||
&self,
|
|
||||||
broadcaster: Arc<WebEventBroadcaster>,
|
|
||||||
db_conn: DatabaseConnection,
|
|
||||||
conn_mgr: ConnectionManager,
|
|
||||||
emitter: EventEmitter,
|
|
||||||
) {
|
|
||||||
// Store broadcaster for status event emission
|
|
||||||
*self.inner.broadcaster.lock().await = Some(broadcaster.clone());
|
|
||||||
|
|
||||||
let db_conn2 = db_conn.clone();
|
|
||||||
|
|
||||||
// Create shared session bridge
|
|
||||||
let bridge = Arc::new(Mutex::new(SessionBridge::new()));
|
|
||||||
|
|
||||||
// Spawn event subscriber
|
|
||||||
let manager_for_events = self.clone_ref();
|
|
||||||
super::event_subscriber::spawn_event_subscriber(
|
|
||||||
broadcaster.clone(),
|
|
||||||
manager_for_events,
|
|
||||||
db_conn.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spawn session event subscriber (ACP event routing to channels)
|
|
||||||
let manager_for_session_events = self.clone_ref();
|
|
||||||
super::session_event_subscriber::spawn_session_event_subscriber(
|
|
||||||
broadcaster,
|
|
||||||
bridge.clone(),
|
|
||||||
manager_for_session_events,
|
|
||||||
conn_mgr.clone_ref(),
|
|
||||||
db_conn.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spawn command dispatcher
|
|
||||||
if let Some(command_rx) = self.take_command_receiver().await {
|
|
||||||
eprintln!("[ChatChannel] command dispatcher started");
|
|
||||||
let manager_for_cmds = self.clone_ref();
|
|
||||||
super::command_dispatcher::spawn_command_dispatcher(
|
|
||||||
command_rx,
|
|
||||||
manager_for_cmds,
|
|
||||||
db_conn.clone(),
|
|
||||||
conn_mgr,
|
|
||||||
emitter,
|
|
||||||
bridge,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("[ChatChannel] WARNING: command_rx already taken, dispatcher NOT started");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn daily report scheduler
|
|
||||||
let manager_for_scheduler = self.clone_ref();
|
|
||||||
super::scheduler::spawn_daily_report_scheduler(manager_for_scheduler, db_conn.clone());
|
|
||||||
|
|
||||||
// Auto-connect enabled channels
|
|
||||||
self.auto_connect_channels(&db_conn2).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) {
|
|
||||||
let channels = match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[ChatChannel] failed to load enabled channels: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for ch in channels {
|
|
||||||
let channel_type: ChannelType =
|
|
||||||
match serde_json::from_value(serde_json::Value::String(ch.channel_type.clone())) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => {
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
|
|
||||||
ch.channel_type, ch.name, ch.id
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] invalid config for '{}' (id={}): {e}, skipping",
|
|
||||||
ch.name, ch.id
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let token = match crate::keyring_store::get_channel_token(ch.id) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] no token found for '{}' (id={}), skipping auto-connect",
|
|
||||||
ch.name, ch.id
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let backend = match super::backends::create_backend(ch.id, channel_type, &config, token)
|
|
||||||
{
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
|
|
||||||
ch.name, ch.id
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = self
|
|
||||||
.add_channel(ch.id, ch.name.clone(), channel_type, backend)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!(
|
|
||||||
"[ChatChannel] failed to auto-connect '{}' (id={}): {e}",
|
|
||||||
ch.name, ch.id
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("[ChatChannel] auto-connected '{}' (id={})", ch.name, ch.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use super::i18n::{self, Lang};
|
|
||||||
use super::types::{MessageLevel, RichMessage};
|
|
||||||
|
|
||||||
pub fn format_turn_complete(agent_type: &str, stop_reason: &str, lang: Lang) -> RichMessage {
|
|
||||||
let reason = match stop_reason {
|
|
||||||
"end_turn" => i18n::stop_reason_end_turn(lang),
|
|
||||||
"cancelled" => i18n::stop_reason_cancelled(lang),
|
|
||||||
_ => stop_reason,
|
|
||||||
};
|
|
||||||
RichMessage::info(i18n::turn_complete_body(lang, agent_type))
|
|
||||||
.with_title(i18n::turn_complete_title(lang))
|
|
||||||
.with_field(i18n::stop_reason_label(lang), reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_agent_error(agent_type: &str, message: &str, lang: Lang) -> RichMessage {
|
|
||||||
RichMessage {
|
|
||||||
title: Some(i18n::agent_error_title(lang).to_string()),
|
|
||||||
body: i18n::agent_error_body(lang, agent_type),
|
|
||||||
fields: vec![(
|
|
||||||
i18n::error_message_label(lang).to_string(),
|
|
||||||
message.to_string(),
|
|
||||||
)],
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DailyReportData {
|
|
||||||
pub date: String,
|
|
||||||
pub conversations_by_agent: Vec<(String, u32)>,
|
|
||||||
pub total_conversations: u32,
|
|
||||||
pub projects_involved: Vec<String>,
|
|
||||||
pub key_activities: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_daily_report(report: &DailyReportData, lang: Lang) -> RichMessage {
|
|
||||||
let mut body = i18n::daily_report_summary(lang, &report.date);
|
|
||||||
|
|
||||||
body.push_str(&format!(
|
|
||||||
"\n\n{}",
|
|
||||||
i18n::total_sessions(lang, report.total_conversations)
|
|
||||||
));
|
|
||||||
|
|
||||||
if !report.conversations_by_agent.is_empty() {
|
|
||||||
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
|
|
||||||
for (agent, count) in &report.conversations_by_agent {
|
|
||||||
body.push_str(&format!(
|
|
||||||
"\n {}",
|
|
||||||
i18n::agent_session_count(lang, agent, *count)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.projects_involved.is_empty() {
|
|
||||||
body.push_str(&format!(
|
|
||||||
"\n\n{}",
|
|
||||||
i18n::projects_label(lang, &report.projects_involved.join(", "))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.key_activities.is_empty() {
|
|
||||||
body.push_str(&format!("\n\n{}", i18n::key_activities_label(lang)));
|
|
||||||
for activity in &report.key_activities {
|
|
||||||
body.push_str(&format!("\n • {}", activity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RichMessage::info(body).with_title(i18n::daily_report_title(lang))
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
pub mod backends;
|
|
||||||
pub mod command_dispatcher;
|
|
||||||
pub mod command_handlers;
|
|
||||||
pub mod error;
|
|
||||||
pub mod event_subscriber;
|
|
||||||
pub mod i18n;
|
|
||||||
pub mod manager;
|
|
||||||
pub mod message_formatter;
|
|
||||||
pub mod scheduler;
|
|
||||||
pub mod session_bridge;
|
|
||||||
pub mod session_commands;
|
|
||||||
pub mod session_event_subscriber;
|
|
||||||
pub mod traits;
|
|
||||||
pub mod types;
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use chrono::{Local, NaiveDate, Timelike, Utc};
|
|
||||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::i18n::Lang;
|
|
||||||
use super::manager::ChatChannelManager;
|
|
||||||
use super::message_formatter::{self, DailyReportData};
|
|
||||||
use crate::db::entities::conversation;
|
|
||||||
use crate::db::service::{
|
|
||||||
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
|
|
||||||
/// Days to retain message logs before cleanup.
|
|
||||||
const LOG_RETENTION_DAYS: i64 = 30;
|
|
||||||
|
|
||||||
pub fn spawn_daily_report_scheduler(
|
|
||||||
manager: ChatChannelManager,
|
|
||||||
db_conn: DatabaseConnection,
|
|
||||||
) -> JoinHandle<()> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut sent_today: HashSet<(i32, NaiveDate)> = HashSet::new();
|
|
||||||
let mut last_cleanup_date: Option<NaiveDate> = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
|
||||||
|
|
||||||
let now = Local::now();
|
|
||||||
let today = now.date_naive();
|
|
||||||
let current_time = format!("{:02}:{:02}", now.hour(), now.minute());
|
|
||||||
|
|
||||||
// Clean up old entries from sent_today
|
|
||||||
sent_today.retain(|(_, date)| *date == today);
|
|
||||||
|
|
||||||
// Periodic log cleanup: once per day
|
|
||||||
if last_cleanup_date != Some(today) {
|
|
||||||
last_cleanup_date = Some(today);
|
|
||||||
let cutoff = Utc::now() - chrono::Duration::days(LOG_RETENTION_DAYS);
|
|
||||||
match chat_channel_message_log_service::cleanup_old_logs(&db_conn, cutoff).await {
|
|
||||||
Ok(n) if n > 0 => {
|
|
||||||
eprintln!("[ChatChannel] cleaned up {n} old message logs");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[ChatChannel] log cleanup failed: {e}");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let channels = match chat_channel_service::list_enabled(&db_conn).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[ChatChannel] scheduler: failed to list channels: {e}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for ch in &channels {
|
|
||||||
if !ch.daily_report_enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let report_time = ch.daily_report_time.as_deref().unwrap_or("18:00");
|
|
||||||
|
|
||||||
if current_time != report_time {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = (ch.id, today);
|
|
||||||
if sent_today.contains(&key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lang = load_lang(&db_conn).await;
|
|
||||||
|
|
||||||
// Generate and send report
|
|
||||||
let report = generate_daily_report(&db_conn).await;
|
|
||||||
let message = message_formatter::format_daily_report(&report, lang);
|
|
||||||
|
|
||||||
let send_result = manager.send_to_channel(ch.id, &message).await;
|
|
||||||
let (status, error_detail) = match &send_result {
|
|
||||||
Ok(_) => ("sent", None),
|
|
||||||
Err(e) => ("failed", Some(e.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = chat_channel_message_log_service::create_log(
|
|
||||||
&db_conn,
|
|
||||||
ch.id,
|
|
||||||
"outbound",
|
|
||||||
"daily_report",
|
|
||||||
&message.to_plain_text(),
|
|
||||||
status,
|
|
||||||
error_detail,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
sent_today.insert(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_lang(db: &DatabaseConnection) -> Lang {
|
|
||||||
app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|v| Lang::from_str_lossy(&v))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData {
|
|
||||||
let now = Utc::now();
|
|
||||||
let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
|
|
||||||
|
|
||||||
let rows = conversation::Entity::find()
|
|
||||||
.filter(conversation::Column::DeletedAt.is_null())
|
|
||||||
.filter(conversation::Column::CreatedAt.gte(today_start))
|
|
||||||
.order_by_desc(conversation::Column::CreatedAt)
|
|
||||||
.all(db)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut by_agent: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
|
||||||
let mut folder_ids: HashSet<i32> = HashSet::new();
|
|
||||||
let mut activities: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for conv in &rows {
|
|
||||||
*by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1;
|
|
||||||
folder_ids.insert(conv.folder_id);
|
|
||||||
if let Some(title) = &conv.title {
|
|
||||||
if activities.len() < 10 {
|
|
||||||
activities.push(title.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve folder names
|
|
||||||
let mut project_names: Vec<String> = Vec::new();
|
|
||||||
for fid in &folder_ids {
|
|
||||||
if let Ok(Some(folder)) = crate::db::entities::folder::Entity::find_by_id(*fid)
|
|
||||||
.one(db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
project_names.push(folder.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let conversations_by_agent: Vec<(String, u32)> = by_agent.into_iter().collect();
|
|
||||||
|
|
||||||
DailyReportData {
|
|
||||||
date: now.format("%Y-%m-%d").to_string(),
|
|
||||||
total_conversations: rows.len() as u32,
|
|
||||||
conversations_by_agent,
|
|
||||||
projects_involved: project_names,
|
|
||||||
key_activities: activities,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::acp::types::PermissionOptionInfo;
|
|
||||||
use crate::chat_channel::types::SentMessageId;
|
|
||||||
use crate::models::agent::AgentType;
|
|
||||||
|
|
||||||
pub struct PendingPermission {
|
|
||||||
pub request_id: String,
|
|
||||||
pub tool_description: String,
|
|
||||||
pub options: Vec<PermissionOptionInfo>,
|
|
||||||
pub sent_message_id: Option<SentMessageId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ActiveSession {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub sender_id: String,
|
|
||||||
pub conversation_id: i32,
|
|
||||||
pub connection_id: String,
|
|
||||||
pub agent_type: AgentType,
|
|
||||||
pub content_buffer: String,
|
|
||||||
pub tool_calls: Vec<String>,
|
|
||||||
/// Stores raw_input by tool_call_id for detail extraction on completion.
|
|
||||||
pub tool_call_inputs: HashMap<String, String>,
|
|
||||||
pub last_flushed: Instant,
|
|
||||||
pub pending_prompt: Option<String>,
|
|
||||||
pub permission_pending: Option<PendingPermission>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct SessionBridge {
|
|
||||||
sessions: HashMap<String, ActiveSession>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionBridge {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register(&mut self, connection_id: String, session: ActiveSession) {
|
|
||||||
self.sessions.insert(connection_id, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, connection_id: &str) -> Option<ActiveSession> {
|
|
||||||
self.sessions.remove(connection_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, connection_id: &str) -> Option<&ActiveSession> {
|
|
||||||
self.sessions.get(connection_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_mut(&mut self, connection_id: &str) -> Option<&mut ActiveSession> {
|
|
||||||
self.sessions.get_mut(connection_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> {
|
|
||||||
self.sessions
|
|
||||||
.values()
|
|
||||||
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_by_sender_mut(
|
|
||||||
&mut self,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
) -> Option<&mut ActiveSession> {
|
|
||||||
self.sessions
|
|
||||||
.values_mut()
|
|
||||||
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> {
|
|
||||||
self.sessions.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_sessions_mut(&mut self) -> impl Iterator<Item = &mut ActiveSession> {
|
|
||||||
self.sessions.values_mut()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,799 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use super::i18n::{self, Lang};
|
|
||||||
use super::session_bridge::{ActiveSession, SessionBridge};
|
|
||||||
use super::types::{MessageLevel, RichMessage};
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
|
||||||
use crate::acp::registry::all_acp_agents;
|
|
||||||
use crate::acp::types::PromptInputBlock;
|
|
||||||
use crate::db::entities::conversation;
|
|
||||||
use crate::db::service::{conversation_service, folder_service, sender_context_service};
|
|
||||||
use crate::models::agent::AgentType;
|
|
||||||
use crate::web::event_bridge::EventEmitter;
|
|
||||||
|
|
||||||
pub struct FollowupRequest<'a> {
|
|
||||||
pub db: &'a DatabaseConnection,
|
|
||||||
pub text: &'a str,
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub sender_id: &'a str,
|
|
||||||
pub conn_mgr: &'a ConnectionManager,
|
|
||||||
pub bridge: &'a Arc<Mutex<SessionBridge>>,
|
|
||||||
pub lang: Lang,
|
|
||||||
pub prefix: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /folder ──
|
|
||||||
|
|
||||||
pub async fn handle_folder(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
args: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
if args.is_empty() {
|
|
||||||
return list_folders(db, channel_id, sender_id, lang, prefix).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parse as index (1-based)
|
|
||||||
if let Ok(idx) = args.parse::<usize>() {
|
|
||||||
return select_folder_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat as path
|
|
||||||
select_folder_by_path(db, args, channel_id, sender_id, lang).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_folders(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
let folders = match folder_service::list_folders(db).await {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if folders.is_empty() {
|
|
||||||
return RichMessage::info(i18n::no_folders_found(lang))
|
|
||||||
.with_title(i18n::folder_title(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for (i, f) in folders.iter().take(10).enumerate() {
|
|
||||||
let current = ctx
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| c.current_folder_id)
|
|
||||||
.map(|id| id == f.id)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let marker = if current { " [*]" } else { "" };
|
|
||||||
body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix)));
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(i18n::folder_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_folder_by_index(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
idx: usize,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
if idx == 0 {
|
|
||||||
return RichMessage::info(i18n::index_starts_from_one(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
let folders = match folder_service::list_folders(db).await {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(folder) = folders.get(idx - 1) else {
|
|
||||||
return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix));
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await;
|
|
||||||
|
|
||||||
RichMessage::info(format!("{} ({})", folder.name, folder.path))
|
|
||||||
.with_title(i18n::folder_selected_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_folder_by_path(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
path: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
) -> RichMessage {
|
|
||||||
let entry = match folder_service::add_folder(db, path).await {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_add_folder_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
|
|
||||||
|
|
||||||
RichMessage::info(format!("{} ({})", entry.name, entry.path))
|
|
||||||
.with_title(i18n::folder_selected_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /agent ──
|
|
||||||
|
|
||||||
pub async fn handle_agent(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
args: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
if args.is_empty() {
|
|
||||||
return list_agents(db, channel_id, sender_id, lang, prefix).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parse as index
|
|
||||||
if let Ok(idx) = args.parse::<usize>() {
|
|
||||||
return select_agent_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parse as agent type name
|
|
||||||
select_agent_by_name(db, args, channel_id, sender_id, lang).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_agents(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
let agents = all_acp_agents();
|
|
||||||
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for (i, at) in agents.iter().enumerate() {
|
|
||||||
let at_str = agent_type_to_string(*at);
|
|
||||||
let current = ctx
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| c.current_agent_type.as_deref())
|
|
||||||
.map(|s| s == at_str)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let marker = if current { " [*]" } else { "" };
|
|
||||||
body.push_str(&format!("{}. {}{}\n", i + 1, at, marker));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.push_str(&format!("\n{}", i18n::agent_select_hint(lang, prefix)));
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(i18n::agent_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_agent_by_index(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
idx: usize,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
let agents = all_acp_agents();
|
|
||||||
if idx == 0 || idx > agents.len() {
|
|
||||||
return RichMessage::info(i18n::agent_index_out_of_range(lang, prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
let at = agents[idx - 1];
|
|
||||||
let at_str = agent_type_to_string(at);
|
|
||||||
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
|
|
||||||
|
|
||||||
RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_agent_by_name(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
name: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
) -> RichMessage {
|
|
||||||
let at = match parse_agent_type(name) {
|
|
||||||
Some(a) => a,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(format!("{}{}", i18n::unknown_agent_label(lang), name));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let at_str = agent_type_to_string(at);
|
|
||||||
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
|
|
||||||
|
|
||||||
RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /task ──
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn handle_task(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
task_description: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
emitter: &EventEmitter,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
if task_description.is_empty() {
|
|
||||||
return RichMessage::info(i18n::task_usage(lang, prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Load sender context
|
|
||||||
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder_id = match ctx.current_folder_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_folder_selected(lang, prefix));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Get folder info
|
|
||||||
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
_ => {
|
|
||||||
return RichMessage::info(i18n::folder_not_found_with_hint(lang, prefix));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Resolve agent type
|
|
||||||
let agent_type = match resolve_agent_type(&ctx.current_agent_type, &folder.default_agent_type) {
|
|
||||||
Some(at) => at,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_agent_selected(lang, prefix));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Create conversation record
|
|
||||||
let conv = match conversation_service::create(
|
|
||||||
db,
|
|
||||||
folder_id,
|
|
||||||
agent_type,
|
|
||||||
Some(truncate_title(task_description)),
|
|
||||||
folder.git_branch.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!(
|
|
||||||
"{}{e}",
|
|
||||||
i18n::failed_to_create_conversation_label(lang)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Spawn ACP agent
|
|
||||||
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
|
|
||||||
let connection_id = match conn_mgr
|
|
||||||
.spawn_agent(
|
|
||||||
agent_type,
|
|
||||||
Some(folder.path.clone()),
|
|
||||||
None,
|
|
||||||
BTreeMap::new(),
|
|
||||||
owner_label,
|
|
||||||
emitter.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
// Clean up the conversation record
|
|
||||||
let _ = conversation_service::update_status(
|
|
||||||
db,
|
|
||||||
conv.id,
|
|
||||||
conversation::ConversationStatus::Cancelled,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Register in bridge (prompt will be sent after SessionStarted event)
|
|
||||||
{
|
|
||||||
let session = ActiveSession {
|
|
||||||
channel_id,
|
|
||||||
sender_id: sender_id.to_string(),
|
|
||||||
conversation_id: conv.id,
|
|
||||||
connection_id: connection_id.clone(),
|
|
||||||
agent_type,
|
|
||||||
content_buffer: String::new(),
|
|
||||||
tool_calls: Vec::new(),
|
|
||||||
tool_call_inputs: std::collections::HashMap::new(),
|
|
||||||
last_flushed: Instant::now(),
|
|
||||||
pending_prompt: Some(task_description.to_string()),
|
|
||||||
permission_pending: None,
|
|
||||||
};
|
|
||||||
bridge.lock().await.register(connection_id.clone(), session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Update sender context
|
|
||||||
let _ = sender_context_service::update_session(
|
|
||||||
db,
|
|
||||||
channel_id,
|
|
||||||
sender_id,
|
|
||||||
Some(conv.id),
|
|
||||||
Some(connection_id),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,))
|
|
||||||
.with_title(i18n::task_started_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /sessions ──
|
|
||||||
|
|
||||||
pub async fn handle_sessions(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder_id = match ctx.current_folder_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_folder_selected(lang, prefix));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
_ => {
|
|
||||||
return RichMessage::info(i18n::folder_not_found(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let convs = match conversation_service::list_by_folder(
|
|
||||||
db,
|
|
||||||
folder_id,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some("in_progress".to_string()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if convs.is_empty() {
|
|
||||||
return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!(
|
|
||||||
"{} - {}",
|
|
||||||
i18n::sessions_title(lang),
|
|
||||||
folder.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for (i, c) in convs.iter().take(10).enumerate() {
|
|
||||||
let title = c.title.as_deref().unwrap_or("(untitled)");
|
|
||||||
let current = ctx
|
|
||||||
.current_conversation_id
|
|
||||||
.map(|id| id == c.id)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let marker = if current { " [*]" } else { "" };
|
|
||||||
body.push_str(&format!(
|
|
||||||
"{}. [{}] {} (#{}){} \n",
|
|
||||||
i + 1,
|
|
||||||
c.agent_type,
|
|
||||||
title,
|
|
||||||
c.id,
|
|
||||||
marker,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix)));
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(format!(
|
|
||||||
"{} - {}",
|
|
||||||
i18n::sessions_title(lang),
|
|
||||||
folder.name
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /resume ──
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn handle_resume(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
args: &str,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
emitter: &EventEmitter,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
lang: Lang,
|
|
||||||
prefix: &str,
|
|
||||||
) -> RichMessage {
|
|
||||||
if args.is_empty() {
|
|
||||||
return list_recent_sessions(db, lang, prefix).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let conversation_id: i32 = match args.parse() {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => {
|
|
||||||
return list_recent_sessions(db, lang, prefix).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let conv = match conversation_service::get_by_id(db, conversation_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => {
|
|
||||||
return RichMessage::info(i18n::conversation_not_found(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder = match folder_service::get_folder_by_id(db, conv.folder_id).await {
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
_ => {
|
|
||||||
return RichMessage::info(i18n::folder_not_found(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Spawn agent with session_id for resume
|
|
||||||
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
|
|
||||||
let connection_id = match conn_mgr
|
|
||||||
.spawn_agent(
|
|
||||||
conv.agent_type,
|
|
||||||
Some(folder.path.clone()),
|
|
||||||
conv.external_id.clone(),
|
|
||||||
BTreeMap::new(),
|
|
||||||
owner_label,
|
|
||||||
emitter.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register in bridge (no pending prompt for resume)
|
|
||||||
{
|
|
||||||
let session = ActiveSession {
|
|
||||||
channel_id,
|
|
||||||
sender_id: sender_id.to_string(),
|
|
||||||
conversation_id: conv.id,
|
|
||||||
connection_id: connection_id.clone(),
|
|
||||||
agent_type: conv.agent_type,
|
|
||||||
content_buffer: String::new(),
|
|
||||||
tool_calls: Vec::new(),
|
|
||||||
tool_call_inputs: std::collections::HashMap::new(),
|
|
||||||
last_flushed: Instant::now(),
|
|
||||||
pending_prompt: None,
|
|
||||||
permission_pending: None,
|
|
||||||
};
|
|
||||||
bridge.lock().await.register(connection_id.clone(), session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sender context
|
|
||||||
let _ = sender_context_service::update_session(
|
|
||||||
db,
|
|
||||||
channel_id,
|
|
||||||
sender_id,
|
|
||||||
Some(conv.id),
|
|
||||||
Some(connection_id),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(conv.folder_id))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let title = conv.title.as_deref().unwrap_or("(untitled)");
|
|
||||||
RichMessage::info(format!(
|
|
||||||
"[{}] #{} {} @ {}",
|
|
||||||
conv.agent_type, conv.id, title, folder.name,
|
|
||||||
))
|
|
||||||
.with_title(i18n::session_resumed_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /cancel ──
|
|
||||||
|
|
||||||
pub async fn handle_cancel(
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
lang: Lang,
|
|
||||||
) -> RichMessage {
|
|
||||||
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let connection_id = match &ctx.current_connection_id {
|
|
||||||
Some(id) => id.clone(),
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_active_session_to_cancel(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cancel the ACP connection
|
|
||||||
let _ = conn_mgr.cancel(&connection_id).await;
|
|
||||||
|
|
||||||
// Remove from bridge
|
|
||||||
bridge.lock().await.remove(&connection_id);
|
|
||||||
|
|
||||||
// Update conversation status
|
|
||||||
if let Some(conv_id) = ctx.current_conversation_id {
|
|
||||||
let _ = conversation_service::update_status(
|
|
||||||
db,
|
|
||||||
conv_id,
|
|
||||||
conversation::ConversationStatus::Cancelled,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear session from context
|
|
||||||
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
|
|
||||||
|
|
||||||
RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /approve, /deny ──
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn handle_permission_response(
|
|
||||||
approve: bool,
|
|
||||||
always: bool,
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
lang: Lang,
|
|
||||||
) -> RichMessage {
|
|
||||||
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let connection_id = match &ctx.current_connection_id {
|
|
||||||
Some(id) => id.clone(),
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_active_session(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let pending = {
|
|
||||||
let mut bridge_guard = bridge.lock().await;
|
|
||||||
let session = match bridge_guard.get_mut(&connection_id) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_active_session_found(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
session.permission_pending.take()
|
|
||||||
};
|
|
||||||
|
|
||||||
let pending = match pending {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_pending_permission(lang));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the appropriate option_id
|
|
||||||
let option_id = if approve {
|
|
||||||
pending
|
|
||||||
.options
|
|
||||||
.iter()
|
|
||||||
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
|
|
||||||
.or_else(|| pending.options.first())
|
|
||||||
.map(|o| o.option_id.clone())
|
|
||||||
} else {
|
|
||||||
pending
|
|
||||||
.options
|
|
||||||
.iter()
|
|
||||||
.find(|o| o.kind == "deny")
|
|
||||||
.or_else(|| pending.options.last())
|
|
||||||
.map(|o| o.option_id.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(option_id) = option_id else {
|
|
||||||
return RichMessage::info(i18n::no_valid_permission_option(lang));
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = conn_mgr
|
|
||||||
.respond_permission(&connection_id, &pending.request_id, &option_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return RichMessage::error(format!(
|
|
||||||
"{}{e}",
|
|
||||||
i18n::failed_permission_response_label(lang)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update auto_approve if requested
|
|
||||||
if always && approve {
|
|
||||||
let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = if approve {
|
|
||||||
i18n::approved_label(lang)
|
|
||||||
} else {
|
|
||||||
i18n::denied_label(lang)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut msg = RichMessage::info(format!("{}: {}", action, pending.tool_description));
|
|
||||||
if always && approve {
|
|
||||||
msg = msg.with_field("", i18n::auto_approve_enabled(lang));
|
|
||||||
}
|
|
||||||
msg.with_title(i18n::permission_response_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── follow-up (non-command text) ──
|
|
||||||
|
|
||||||
pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage {
|
|
||||||
let ctx =
|
|
||||||
match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage::error(format!(
|
|
||||||
"{}{e}",
|
|
||||||
i18n::failed_to_load_context_label(req.lang)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let connection_id = match &ctx.current_connection_id {
|
|
||||||
Some(id) => id.clone(),
|
|
||||||
None => {
|
|
||||||
return RichMessage::info(i18n::no_active_session_use_task(req.lang, req.prefix));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check connection exists in bridge
|
|
||||||
{
|
|
||||||
let bridge_guard = req.bridge.lock().await;
|
|
||||||
if bridge_guard.get(&connection_id).is_none() {
|
|
||||||
// Connection lost, clear context
|
|
||||||
drop(bridge_guard);
|
|
||||||
let _ =
|
|
||||||
sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
|
|
||||||
return RichMessage::info(i18n::session_connection_lost(req.lang, req.prefix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send prompt to agent
|
|
||||||
let blocks = vec![PromptInputBlock::Text {
|
|
||||||
text: req.text.to_string(),
|
|
||||||
}];
|
|
||||||
|
|
||||||
if let Err(e) = req.conn_mgr.send_prompt(&connection_id, blocks).await {
|
|
||||||
// Connection may have died
|
|
||||||
req.bridge.lock().await.remove(&connection_id);
|
|
||||||
let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
|
|
||||||
return RichMessage::error(format!(
|
|
||||||
"{}{e}",
|
|
||||||
i18n::failed_to_send_message_label(req.lang)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
RichMessage::info(i18n::message_sent(req.lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── /resume (list recent) ──
|
|
||||||
|
|
||||||
async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage {
|
|
||||||
let recent = match conversation::Entity::find()
|
|
||||||
.filter(conversation::Column::DeletedAt.is_null())
|
|
||||||
.order_by_desc(conversation::Column::CreatedAt)
|
|
||||||
.limit(10)
|
|
||||||
.all(db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(rows) => rows,
|
|
||||||
Err(e) => {
|
|
||||||
return RichMessage {
|
|
||||||
title: Some(i18n::query_failed_title(lang).to_string()),
|
|
||||||
body: e.to_string(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if recent.is_empty() {
|
|
||||||
return RichMessage::info(i18n::no_conversations_found(lang))
|
|
||||||
.with_title(i18n::recent_conversations_title(lang));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for conv in &recent {
|
|
||||||
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
|
|
||||||
let agent = &conv.agent_type;
|
|
||||||
let time = conv.created_at.format("%m-%d %H:%M");
|
|
||||||
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix)));
|
|
||||||
|
|
||||||
RichMessage::info(body.trim_end()).with_title(i18n::recent_conversations_title(lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
fn agent_type_to_string(at: AgentType) -> String {
|
|
||||||
serde_json::to_value(at)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.as_str().map(String::from))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_agent_type(name: &str) -> Option<AgentType> {
|
|
||||||
let normalized = name.to_lowercase().replace([' ', '-'], "_");
|
|
||||||
serde_json::from_value(serde_json::Value::String(normalized)).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_agent_type(
|
|
||||||
sender_agent: &Option<String>,
|
|
||||||
folder_default: &Option<AgentType>,
|
|
||||||
) -> Option<AgentType> {
|
|
||||||
if let Some(ref at_str) = sender_agent {
|
|
||||||
if let Some(at) = parse_agent_type(at_str) {
|
|
||||||
return Some(at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
folder_default.as_ref().copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_title(s: &str) -> String {
|
|
||||||
if s.chars().count() <= 80 {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
let truncated: String = s.chars().take(77).collect();
|
|
||||||
format!("{truncated}...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,727 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::i18n::Lang;
|
|
||||||
use super::session_bridge::{PendingPermission, SessionBridge};
|
|
||||||
use super::types::{MessageLevel, RichMessage};
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
|
||||||
use crate::acp::types::PromptInputBlock;
|
|
||||||
use crate::db::service::{app_metadata_service, conversation_service, sender_context_service};
|
|
||||||
use crate::web::event_bridge::WebEventBroadcaster;
|
|
||||||
|
|
||||||
use super::manager::ChatChannelManager;
|
|
||||||
|
|
||||||
const FLUSH_INTERVAL_SECS: u64 = 10;
|
|
||||||
const BUFFER_FLUSH_THRESHOLD: usize = 500;
|
|
||||||
const MAX_MESSAGE_LEN: usize = 2000;
|
|
||||||
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
|
|
||||||
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
|
|
||||||
const DEFAULT_COMMAND_PREFIX: &str = "/";
|
|
||||||
|
|
||||||
pub fn spawn_session_event_subscriber(
|
|
||||||
broadcaster: Arc<WebEventBroadcaster>,
|
|
||||||
bridge: Arc<Mutex<SessionBridge>>,
|
|
||||||
manager: ChatChannelManager,
|
|
||||||
conn_mgr: ConnectionManager,
|
|
||||||
db_conn: DatabaseConnection,
|
|
||||||
) -> JoinHandle<()> {
|
|
||||||
let mut rx = broadcaster.subscribe();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut last_heartbeat = Instant::now();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = rx.recv() => {
|
|
||||||
let event = match result {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
eprintln!("[SessionEventSub] lagged {n} events");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
if event.channel == "acp://event" {
|
|
||||||
handle_acp_event_payload(
|
|
||||||
event.payload.as_ref(),
|
|
||||||
&bridge,
|
|
||||||
&manager,
|
|
||||||
&conn_mgr,
|
|
||||||
&db_conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(FLUSH_INTERVAL_SECS)) => {
|
|
||||||
if last_heartbeat.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) {
|
|
||||||
flush_progress(&bridge, &manager, &db_conn).await;
|
|
||||||
last_heartbeat = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_lang(db: &DatabaseConnection) -> Lang {
|
|
||||||
app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|v| Lang::from_str_lossy(&v))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_prefix(db: &DatabaseConnection) -> String {
|
|
||||||
app_metadata_service::get_value(db, COMMAND_PREFIX_KEY)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_acp_event_payload(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
conn_mgr: &ConnectionManager,
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
) {
|
|
||||||
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
let connection_id = match payload.get("connection_id").and_then(|v| v.as_str()) {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
match event_type {
|
|
||||||
"session_started" => {
|
|
||||||
let session_id = payload
|
|
||||||
.get("session_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.get_mut(connection_id) {
|
|
||||||
let _ = conversation_service::update_external_id(
|
|
||||||
db,
|
|
||||||
session.conversation_id,
|
|
||||||
session_id.to_string(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Some(prompt_text) = session.pending_prompt.take() {
|
|
||||||
let blocks = vec![PromptInputBlock::Text { text: prompt_text }];
|
|
||||||
if let Err(e) = conn_mgr.send_prompt(connection_id, blocks).await {
|
|
||||||
eprintln!("[SessionEventSub] failed to send pending prompt: {e}");
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
let msg = RichMessage::error(format!("Failed to send task: {e}"));
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"content_delta" => {
|
|
||||||
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
// Collect flush info under the lock, then release before any IO.
|
|
||||||
let flush_info: Option<(i32, String, Option<String>)> = {
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
match guard.get_mut(connection_id) {
|
|
||||||
Some(session) => {
|
|
||||||
session.content_buffer.push_str(text);
|
|
||||||
if session.content_buffer.len() >= BUFFER_FLUSH_THRESHOLD
|
|
||||||
&& session.last_flushed.elapsed() >= Duration::from_secs(2)
|
|
||||||
{
|
|
||||||
session.last_flushed = Instant::now();
|
|
||||||
Some((
|
|
||||||
session.channel_id,
|
|
||||||
session.agent_type.to_string(),
|
|
||||||
session.tool_calls.last().cloned(),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((channel_id, agent_label, last_tool)) = flush_info {
|
|
||||||
let lang = get_lang(db).await;
|
|
||||||
let mut status = super::i18n::agent_responding(lang, &agent_label);
|
|
||||||
if let Some(tool) = last_tool {
|
|
||||||
status.push_str(&format!(" | {tool}"));
|
|
||||||
}
|
|
||||||
let msg = RichMessage::info(status);
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"tool_call" => {
|
|
||||||
let title = payload
|
|
||||||
.get("title")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("tool");
|
|
||||||
let tool_call_id = payload
|
|
||||||
.get("tool_call_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.get_mut(connection_id) {
|
|
||||||
// Store title for progress indicator; store raw_input for later
|
|
||||||
session.tool_calls.push(title.to_string());
|
|
||||||
if let Some(input) = raw_input {
|
|
||||||
session
|
|
||||||
.tool_call_inputs
|
|
||||||
.insert(tool_call_id.to_string(), input.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"tool_call_update" => {
|
|
||||||
let title = payload.get("title").and_then(|v| v.as_str());
|
|
||||||
let status = payload.get("status").and_then(|v| v.as_str());
|
|
||||||
let tool_call_id = payload
|
|
||||||
.get("tool_call_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.get_mut(connection_id) {
|
|
||||||
// Accumulate raw_input if newly available
|
|
||||||
if let Some(input) = raw_input {
|
|
||||||
session
|
|
||||||
.tool_call_inputs
|
|
||||||
.insert(tool_call_id.to_string(), input.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if status == Some("completed") {
|
|
||||||
let stored_input = session.tool_call_inputs.remove(tool_call_id);
|
|
||||||
let effective_title = title.unwrap_or("tool");
|
|
||||||
let input_ref = stored_input.as_deref().or(raw_input);
|
|
||||||
let detail = format_tool_call_detail(effective_title, input_ref);
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let msg = RichMessage::info(format!(">> {detail}"));
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"permission_request" => {
|
|
||||||
let request_id = payload
|
|
||||||
.get("request_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let tool_call = payload
|
|
||||||
.get("tool_call")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(serde_json::Value::Null);
|
|
||||||
let options: Vec<crate::acp::types::PermissionOptionInfo> = payload
|
|
||||||
.get("options")
|
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.get_mut(connection_id) {
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
let sender_id = session.sender_id.clone();
|
|
||||||
|
|
||||||
let auto_approve =
|
|
||||||
sender_context_service::get_or_create(db, channel_id, &sender_id)
|
|
||||||
.await
|
|
||||||
.map(|ctx| ctx.auto_approve)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if auto_approve {
|
|
||||||
let option_id = options
|
|
||||||
.iter()
|
|
||||||
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
|
|
||||||
.or_else(|| options.first())
|
|
||||||
.map(|o| o.option_id.clone());
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
if let Some(oid) = option_id {
|
|
||||||
let _ = conn_mgr
|
|
||||||
.respond_permission(connection_id, request_id, &oid)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tool_title = tool_call
|
|
||||||
.get("title")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str()))
|
|
||||||
.unwrap_or("Unknown tool");
|
|
||||||
|
|
||||||
// Extract detail from rawInput / raw_input in the tool_call object
|
|
||||||
let raw_input_str = tool_call
|
|
||||||
.get("rawInput")
|
|
||||||
.or_else(|| tool_call.get("raw_input"))
|
|
||||||
.and_then(|v| match v {
|
|
||||||
serde_json::Value::String(s) => Some(s.clone()),
|
|
||||||
serde_json::Value::Null => None,
|
|
||||||
other => Some(other.to_string()),
|
|
||||||
});
|
|
||||||
let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref());
|
|
||||||
|
|
||||||
session.permission_pending = Some(PendingPermission {
|
|
||||||
request_id: request_id.to_string(),
|
|
||||||
tool_description: tool_desc.clone(),
|
|
||||||
options,
|
|
||||||
sent_message_id: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let lang = get_lang(db).await;
|
|
||||||
let prefix = get_prefix(db).await;
|
|
||||||
let body = match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => {
|
|
||||||
format!("Agent 请求权限: {tool_desc}\n\n{prefix}approve 批准 | {prefix}deny 拒绝 | {prefix}approve always 自动批准")
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
format!("Agent requests permission: {tool_desc}\n\n{prefix}approve | {prefix}deny | {prefix}approve always")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let msg = RichMessage {
|
|
||||||
title: Some(match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => "权限请求".to_string(),
|
|
||||||
_ => "Permission Request".to_string(),
|
|
||||||
}),
|
|
||||||
body,
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Warning,
|
|
||||||
};
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"turn_complete" => {
|
|
||||||
let stop_reason = payload
|
|
||||||
.get("stop_reason")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let agent_type = payload
|
|
||||||
.get("agent_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown");
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.get_mut(connection_id) {
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
let conv_id = session.conversation_id;
|
|
||||||
let content = std::mem::take(&mut session.content_buffer);
|
|
||||||
let tool_count = session.tool_calls.len();
|
|
||||||
session.tool_calls.clear();
|
|
||||||
session.last_flushed = Instant::now();
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let lang = get_lang(db).await;
|
|
||||||
let body = format_completion(&content, tool_count, lang);
|
|
||||||
|
|
||||||
let msg = RichMessage::info(body)
|
|
||||||
.with_title(match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => "任务完成",
|
|
||||||
_ => "Turn Complete",
|
|
||||||
})
|
|
||||||
.with_field("Agent", agent_type)
|
|
||||||
.with_field(
|
|
||||||
match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => "结束原因",
|
|
||||||
_ => "Stop Reason",
|
|
||||||
},
|
|
||||||
localize_stop_reason(stop_reason, lang),
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
|
|
||||||
if stop_reason == "end_turn" {
|
|
||||||
let _ = conversation_service::update_status(
|
|
||||||
db,
|
|
||||||
conv_id,
|
|
||||||
crate::db::entities::conversation::ConversationStatus::Completed,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"error" => {
|
|
||||||
let message = payload
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown error");
|
|
||||||
let agent_type = payload
|
|
||||||
.get("agent_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown");
|
|
||||||
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.remove(connection_id) {
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
let sender_id = session.sender_id.clone();
|
|
||||||
let conv_id = session.conversation_id;
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let lang = get_lang(db).await;
|
|
||||||
let msg = RichMessage {
|
|
||||||
title: Some(match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => "Agent 错误".to_string(),
|
|
||||||
_ => "Agent Error".to_string(),
|
|
||||||
}),
|
|
||||||
body: format!("[{agent_type}] {message}"),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
};
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
|
|
||||||
let _ = conversation_service::update_status(
|
|
||||||
db,
|
|
||||||
conv_id,
|
|
||||||
crate::db::entities::conversation::ConversationStatus::Cancelled,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"status_changed" => {
|
|
||||||
let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
if status == "disconnected" || status == "error" {
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
if let Some(session) = guard.remove(connection_id) {
|
|
||||||
let channel_id = session.channel_id;
|
|
||||||
let sender_id = session.sender_id.clone();
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn flush_progress(
|
|
||||||
bridge: &Arc<Mutex<SessionBridge>>,
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
db: &DatabaseConnection,
|
|
||||||
) {
|
|
||||||
let lang = get_lang(db).await;
|
|
||||||
let updates: Vec<(i32, String)> = {
|
|
||||||
let mut guard = bridge.lock().await;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for session in guard.all_sessions_mut() {
|
|
||||||
if !session.content_buffer.is_empty()
|
|
||||||
&& session.last_flushed.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS)
|
|
||||||
{
|
|
||||||
session.last_flushed = Instant::now();
|
|
||||||
let last_tool = session.tool_calls.last().cloned();
|
|
||||||
let agent_label = session.agent_type.to_string();
|
|
||||||
let mut status = super::i18n::agent_responding(lang, &agent_label);
|
|
||||||
if let Some(tool) = last_tool {
|
|
||||||
status.push_str(&format!(" | {tool}"));
|
|
||||||
}
|
|
||||||
out.push((session.channel_id, status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
};
|
|
||||||
|
|
||||||
for (channel_id, text) in updates {
|
|
||||||
let msg = RichMessage::info(text);
|
|
||||||
let _ = manager.send_to_channel(channel_id, &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String {
|
|
||||||
if content.is_empty() {
|
|
||||||
return match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => format!("(无文本输出, {tool_count} 次工具调用)"),
|
|
||||||
_ => format!("(No text output, {tool_count} tool calls)"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if content.len() <= MAX_MESSAGE_LEN {
|
|
||||||
let mut body = content.to_string();
|
|
||||||
if tool_count > 0 {
|
|
||||||
body.push_str(&format!(
|
|
||||||
"\n\n[{} {}]",
|
|
||||||
tool_count,
|
|
||||||
match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => "次工具调用",
|
|
||||||
_ => "tool calls",
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate long content (use char boundaries to avoid panic on multi-byte)
|
|
||||||
let head_end = content
|
|
||||||
.char_indices()
|
|
||||||
.nth(500)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(content.len());
|
|
||||||
let head = &content[..head_end];
|
|
||||||
let tail_start = content
|
|
||||||
.char_indices()
|
|
||||||
.rev()
|
|
||||||
.nth(499)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let tail = &content[tail_start..];
|
|
||||||
|
|
||||||
match lang {
|
|
||||||
Lang::ZhCn | Lang::ZhTw => {
|
|
||||||
format!(
|
|
||||||
"{head}\n\n...\n\n{tail}\n\n[完整回复: {} 字符, {tool_count} 次工具调用]",
|
|
||||||
content.len()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
format!(
|
|
||||||
"{head}\n\n...\n\n{tail}\n\n[Full response: {} chars, {tool_count} tool calls]",
|
|
||||||
content.len()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn localize_stop_reason(reason: &str, lang: Lang) -> String {
|
|
||||||
match lang {
|
|
||||||
Lang::ZhCn => match reason {
|
|
||||||
"end_turn" => "正常结束",
|
|
||||||
"cancelled" => "已取消",
|
|
||||||
"max_tokens" => "达到最大长度",
|
|
||||||
"stop_sequence" => "遇到停止序列",
|
|
||||||
"error" => "错误",
|
|
||||||
"timeout" => "超时",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::ZhTw => match reason {
|
|
||||||
"end_turn" => "正常結束",
|
|
||||||
"cancelled" => "已取消",
|
|
||||||
"max_tokens" => "達到最大長度",
|
|
||||||
"stop_sequence" => "遇到停止序列",
|
|
||||||
"error" => "錯誤",
|
|
||||||
"timeout" => "逾時",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Ja => match reason {
|
|
||||||
"end_turn" => "正常終了",
|
|
||||||
"cancelled" => "キャンセル",
|
|
||||||
"max_tokens" => "最大トークン数到達",
|
|
||||||
"stop_sequence" => "停止シーケンス",
|
|
||||||
"error" => "エラー",
|
|
||||||
"timeout" => "タイムアウト",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Ko => match reason {
|
|
||||||
"end_turn" => "정상 종료",
|
|
||||||
"cancelled" => "취소됨",
|
|
||||||
"max_tokens" => "최대 길이 도달",
|
|
||||||
"stop_sequence" => "정지 시퀀스",
|
|
||||||
"error" => "오류",
|
|
||||||
"timeout" => "시간 초과",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Es => match reason {
|
|
||||||
"end_turn" => "Finalizado",
|
|
||||||
"cancelled" => "Cancelado",
|
|
||||||
"max_tokens" => "Longitud máxima alcanzada",
|
|
||||||
"error" => "Error",
|
|
||||||
"timeout" => "Tiempo agotado",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::De => match reason {
|
|
||||||
"end_turn" => "Abgeschlossen",
|
|
||||||
"cancelled" => "Abgebrochen",
|
|
||||||
"max_tokens" => "Maximale Länge erreicht",
|
|
||||||
"error" => "Fehler",
|
|
||||||
"timeout" => "Zeitüberschreitung",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Fr => match reason {
|
|
||||||
"end_turn" => "Terminé",
|
|
||||||
"cancelled" => "Annulé",
|
|
||||||
"max_tokens" => "Longueur maximale atteinte",
|
|
||||||
"error" => "Erreur",
|
|
||||||
"timeout" => "Délai dépassé",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Pt => match reason {
|
|
||||||
"end_turn" => "Concluído",
|
|
||||||
"cancelled" => "Cancelado",
|
|
||||||
"max_tokens" => "Comprimento máximo atingido",
|
|
||||||
"error" => "Erro",
|
|
||||||
"timeout" => "Tempo esgotado",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::Ar => match reason {
|
|
||||||
"end_turn" => "اكتمل",
|
|
||||||
"cancelled" => "ملغى",
|
|
||||||
"max_tokens" => "تم بلوغ الحد الأقصى",
|
|
||||||
"error" => "خطأ",
|
|
||||||
"timeout" => "انتهت المهلة",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
Lang::En => match reason {
|
|
||||||
"end_turn" => "Completed",
|
|
||||||
"cancelled" => "Cancelled",
|
|
||||||
"max_tokens" => "Max length reached",
|
|
||||||
"stop_sequence" => "Stop sequence",
|
|
||||||
"error" => "Error",
|
|
||||||
"timeout" => "Timeout",
|
|
||||||
other => other,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a concise detail string from a tool call's `raw_input` JSON.
|
|
||||||
///
|
|
||||||
/// Returns a formatted string like `"Read: src/main.rs"` or `"Bash: npm test"`.
|
|
||||||
/// Falls back to the original title if no detail can be extracted.
|
|
||||||
fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String {
|
|
||||||
let parsed = raw_input.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
|
|
||||||
|
|
||||||
let normalized_title = title.to_lowercase().replace([' ', '-'], "_");
|
|
||||||
|
|
||||||
if let Some(ref obj) = parsed {
|
|
||||||
// File operations: read, edit, write, delete
|
|
||||||
if let Some(path) = obj
|
|
||||||
.get("file_path")
|
|
||||||
.or_else(|| obj.get("path"))
|
|
||||||
.or_else(|| obj.get("notebook_path"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
let short = short_path(path);
|
|
||||||
let label = match normalized_title.as_str() {
|
|
||||||
s if s.contains("write") => "Write",
|
|
||||||
s if s.contains("edit") || s.contains("change") || s.contains("update") => "Edit",
|
|
||||||
s if s.contains("delete") => "Delete",
|
|
||||||
_ => "Read",
|
|
||||||
};
|
|
||||||
return format!("{label}: {short}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bash / shell commands
|
|
||||||
if let Some(cmd) = obj
|
|
||||||
.get("command")
|
|
||||||
.or_else(|| obj.get("cmd"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
let short = truncate_str(cmd.lines().next().unwrap_or(cmd), 80);
|
|
||||||
return format!("Bash: {short}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grep / search
|
|
||||||
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
|
|
||||||
let path = obj.get("path").and_then(|v| v.as_str());
|
|
||||||
return if let Some(p) = path {
|
|
||||||
format!(
|
|
||||||
"Grep: \"{}\" in {}",
|
|
||||||
truncate_str(pattern, 40),
|
|
||||||
short_path(p)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("Grep: \"{}\"", truncate_str(pattern, 60))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Glob
|
|
||||||
if let Some(pat) = obj.get("glob").and_then(|v| v.as_str()) {
|
|
||||||
return format!("Glob: {pat}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent / task
|
|
||||||
if obj.get("subagent_type").is_some()
|
|
||||||
|| obj.get("task_id").is_some()
|
|
||||||
|| obj.get("subject").is_some()
|
|
||||||
{
|
|
||||||
let desc = obj
|
|
||||||
.get("description")
|
|
||||||
.or_else(|| obj.get("subject"))
|
|
||||||
.or_else(|| obj.get("prompt"))
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
if let Some(d) = desc {
|
|
||||||
return format!("Agent: {}", truncate_str(d, 60));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web fetch
|
|
||||||
if let Some(url) = obj.get("url").and_then(|v| v.as_str()) {
|
|
||||||
return format!("Fetch: {}", truncate_str(url, 80));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web search
|
|
||||||
if let Some(query) = obj.get("query").and_then(|v| v.as_str()) {
|
|
||||||
return format!("Search: {}", truncate_str(query, 60));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TodoWrite
|
|
||||||
if obj.get("todos").is_some() {
|
|
||||||
return "TodoWrite".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: if raw_input is a plain string (e.g. a bare command), use it directly
|
|
||||||
if let Some(raw) = raw_input {
|
|
||||||
if !raw.starts_with('{') && !raw.starts_with('[') {
|
|
||||||
let short = truncate_str(raw.lines().next().unwrap_or(raw), 80);
|
|
||||||
if normalized_title.contains("bash")
|
|
||||||
|| normalized_title.contains("shell")
|
|
||||||
|| normalized_title.contains("exec")
|
|
||||||
{
|
|
||||||
return format!("Bash: {short}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn short_path(path: &str) -> &str {
|
|
||||||
// Show last 2 path components at most, or the full path if short enough
|
|
||||||
if path.len() <= 60 {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
let parts: Vec<&str> = path.rsplitn(3, '/').collect();
|
|
||||||
if parts.len() >= 2 {
|
|
||||||
// e.g. "src/main.rs" from "/very/long/path/src/main.rs"
|
|
||||||
let tail = &path[path.len() - parts[0].len() - parts[1].len() - 1..];
|
|
||||||
if tail.len() < path.len() {
|
|
||||||
return tail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_str(s: &str, max: usize) -> String {
|
|
||||||
if s.chars().count() <= max {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
|
|
||||||
format!("{truncated}...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use super::error::ChatChannelError;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ChatChannelBackend: Send + Sync + 'static {
|
|
||||||
fn channel_type(&self) -> ChannelType;
|
|
||||||
|
|
||||||
/// Start the receiving loop. `command_tx` forwards incoming IM messages
|
|
||||||
/// to the central command dispatcher.
|
|
||||||
async fn start(
|
|
||||||
&self,
|
|
||||||
command_tx: mpsc::Sender<IncomingCommand>,
|
|
||||||
) -> Result<(), ChatChannelError>;
|
|
||||||
|
|
||||||
/// Stop the backend connection gracefully.
|
|
||||||
async fn stop(&self) -> Result<(), ChatChannelError>;
|
|
||||||
|
|
||||||
/// Current connection status.
|
|
||||||
async fn status(&self) -> ChannelConnectionStatus;
|
|
||||||
|
|
||||||
/// Send a plain text message.
|
|
||||||
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError>;
|
|
||||||
|
|
||||||
/// Send a rich/structured message (Telegram Markdown / Lark Card).
|
|
||||||
async fn send_rich_message(
|
|
||||||
&self,
|
|
||||||
message: &RichMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError>;
|
|
||||||
|
|
||||||
/// [Phase 2] Send an interactive message with action buttons.
|
|
||||||
/// Default implementation degrades to send_rich_message.
|
|
||||||
async fn send_interactive_message(
|
|
||||||
&self,
|
|
||||||
message: &InteractiveMessage,
|
|
||||||
) -> Result<SentMessageId, ChatChannelError> {
|
|
||||||
self.send_rich_message(&message.to_rich_fallback()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [Phase 2] Update an already-sent message (e.g., permission status change).
|
|
||||||
async fn update_message(
|
|
||||||
&self,
|
|
||||||
_message_id: &SentMessageId,
|
|
||||||
_message: &RichMessage,
|
|
||||||
) -> Result<(), ChatChannelError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test the connection (used by "Test Connection" button in UI).
|
|
||||||
async fn test_connection(&self) -> Result<(), ChatChannelError>;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ChannelType {
|
|
||||||
Lark,
|
|
||||||
Telegram,
|
|
||||||
Weixin,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-channel strong typed configs ──
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct TelegramConfig {
|
|
||||||
pub chat_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct LarkConfig {
|
|
||||||
pub app_id: String,
|
|
||||||
pub chat_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct WeixinConfig {
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ChannelType {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
ChannelType::Lark => write!(f, "lark"),
|
|
||||||
ChannelType::Telegram => write!(f, "telegram"),
|
|
||||||
ChannelType::Weixin => write!(f, "weixin"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ChannelConnectionStatus {
|
|
||||||
Connected,
|
|
||||||
Connecting,
|
|
||||||
Disconnected,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SentMessageId(pub String);
|
|
||||||
|
|
||||||
pub struct IncomingCommand {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub sender_id: String,
|
|
||||||
pub command_text: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum MessageLevel {
|
|
||||||
Info,
|
|
||||||
Warning,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RichMessage {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub body: String,
|
|
||||||
pub fields: Vec<(String, String)>,
|
|
||||||
pub level: MessageLevel,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RichMessage {
|
|
||||||
pub fn info(body: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: None,
|
|
||||||
body: body.into(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Info,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(body: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: None,
|
|
||||||
body: body.into(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
level: MessageLevel::Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
|
||||||
self.title = Some(title.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
|
||||||
self.fields.push((key.into(), value.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_plain_text(&self) -> String {
|
|
||||||
let mut text = String::new();
|
|
||||||
if let Some(title) = &self.title {
|
|
||||||
text.push_str(title);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
text.push_str(&self.body);
|
|
||||||
for (key, value) in &self.fields {
|
|
||||||
text.push_str(&format!("\n{}: {}", key, value));
|
|
||||||
}
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 2 forward-compatible types ──
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ButtonStyle {
|
|
||||||
Primary,
|
|
||||||
Danger,
|
|
||||||
Default,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MessageButton {
|
|
||||||
pub id: String,
|
|
||||||
pub label: String,
|
|
||||||
pub style: ButtonStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InteractiveMessage {
|
|
||||||
pub base: RichMessage,
|
|
||||||
pub buttons: Vec<MessageButton>,
|
|
||||||
pub callback_context: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InteractiveMessage {
|
|
||||||
pub fn to_rich_fallback(&self) -> RichMessage {
|
|
||||||
let mut msg = self.base.clone();
|
|
||||||
if !self.buttons.is_empty() {
|
|
||||||
let button_text: Vec<String> = self
|
|
||||||
.buttons
|
|
||||||
.iter()
|
|
||||||
.map(|b| format!("[{}]", b.label))
|
|
||||||
.collect();
|
|
||||||
msg.body
|
|
||||||
.push_str(&format!("\n\n{}", button_text.join(" ")));
|
|
||||||
}
|
|
||||||
msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
use crate::app_error::AppCommandError;
|
|
||||||
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
|
|
||||||
use crate::chat_channel::manager::ChatChannelManager;
|
|
||||||
use crate::chat_channel::types::ChannelType;
|
|
||||||
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
|
|
||||||
use crate::db::AppDatabase;
|
|
||||||
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared core functions (used by both Tauri commands and web handlers)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn list_chat_channels_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
) -> Result<Vec<ChatChannelInfo>, AppCommandError> {
|
|
||||||
let rows = chat_channel_service::list_all(&db.conn)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(rows.into_iter().map(ChatChannelInfo::from).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_chat_channel_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
name: String,
|
|
||||||
channel_type: String,
|
|
||||||
config_json: String,
|
|
||||||
enabled: bool,
|
|
||||||
daily_report_enabled: bool,
|
|
||||||
daily_report_time: Option<String>,
|
|
||||||
) -> Result<ChatChannelInfo, AppCommandError> {
|
|
||||||
// Validate channel_type
|
|
||||||
let _: ChannelType = serde_json::from_value(serde_json::Value::String(channel_type.clone()))
|
|
||||||
.map_err(|_| {
|
|
||||||
AppCommandError::invalid_input(format!("Invalid channel type: {channel_type}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let model = chat_channel_service::create(
|
|
||||||
&db.conn,
|
|
||||||
name,
|
|
||||||
channel_type,
|
|
||||||
config_json,
|
|
||||||
enabled,
|
|
||||||
daily_report_enabled,
|
|
||||||
daily_report_time,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(ChatChannelInfo::from(model))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn update_chat_channel_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
id: i32,
|
|
||||||
name: Option<String>,
|
|
||||||
enabled: Option<bool>,
|
|
||||||
config_json: Option<String>,
|
|
||||||
event_filter_json: Option<Option<String>>,
|
|
||||||
daily_report_enabled: Option<bool>,
|
|
||||||
daily_report_time: Option<Option<String>>,
|
|
||||||
) -> Result<ChatChannelInfo, AppCommandError> {
|
|
||||||
let model = chat_channel_service::update(
|
|
||||||
&db.conn,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
enabled,
|
|
||||||
config_json,
|
|
||||||
event_filter_json,
|
|
||||||
daily_report_enabled,
|
|
||||||
daily_report_time,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(ChatChannelInfo::from(model))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_chat_channel_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
// Disconnect running backend before deleting from DB (prevents orphaned task)
|
|
||||||
let _ = manager.remove_channel(id).await;
|
|
||||||
chat_channel_service::delete(&db.conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
let _ = crate::keyring_store::delete_channel_token(id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect_chat_channel_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
let model = chat_channel_service::get_by_id(&db.conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?
|
|
||||||
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
|
|
||||||
|
|
||||||
let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
|
|
||||||
model.channel_type.clone(),
|
|
||||||
))
|
|
||||||
.map_err(|_| {
|
|
||||||
AppCommandError::configuration_invalid(format!(
|
|
||||||
"Invalid channel type: {}",
|
|
||||||
model.channel_type
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
|
|
||||||
AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
|
|
||||||
eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring");
|
|
||||||
AppCommandError::configuration_missing("Token not set")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"[connect_chat_channel] channel {id}: creating {channel_type} backend, config={}",
|
|
||||||
model.config_json
|
|
||||||
);
|
|
||||||
|
|
||||||
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
|
|
||||||
manager
|
|
||||||
.add_channel(id, model.name, channel_type, backend)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("[connect_chat_channel] channel {id}: add_channel failed: {e}");
|
|
||||||
AppCommandError::from(e)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
eprintln!("[connect_chat_channel] channel {id}: connected successfully");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn test_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
|
|
||||||
let model = chat_channel_service::get_by_id(&db.conn, id)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?
|
|
||||||
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
|
|
||||||
|
|
||||||
let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
|
|
||||||
model.channel_type.clone(),
|
|
||||||
))
|
|
||||||
.map_err(|_| {
|
|
||||||
AppCommandError::configuration_invalid(format!(
|
|
||||||
"Invalid channel type: {}",
|
|
||||||
model.channel_type
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
|
|
||||||
AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let token = crate::keyring_store::get_channel_token(id)
|
|
||||||
.ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?;
|
|
||||||
|
|
||||||
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
|
|
||||||
backend
|
|
||||||
.test_connection()
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_chat_channel_token_core(channel_id: i32, token: &str) -> Result<(), AppCommandError> {
|
|
||||||
crate::keyring_store::set_channel_token(channel_id, token)
|
|
||||||
.map_err(|e| AppCommandError::io_error("Failed to save token").with_detail(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_chat_channel_has_token_core(channel_id: i32) -> Result<bool, AppCommandError> {
|
|
||||||
Ok(crate::keyring_store::get_channel_token(channel_id).is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_chat_channel_token_core(channel_id: i32) -> Result<(), AppCommandError> {
|
|
||||||
crate::keyring_store::delete_channel_token(channel_id)
|
|
||||||
.map_err(|e| AppCommandError::io_error("Failed to delete token").with_detail(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disconnect_chat_channel_core(
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
manager
|
|
||||||
.remove_channel(id)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_channel_status_core(
|
|
||||||
manager: &ChatChannelManager,
|
|
||||||
) -> Result<Vec<ChannelStatusInfo>, AppCommandError> {
|
|
||||||
Ok(manager.get_status().await)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_chat_channel_messages_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
channel_id: i32,
|
|
||||||
limit: Option<u64>,
|
|
||||||
offset: Option<u64>,
|
|
||||||
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
|
|
||||||
let limit = limit.unwrap_or(50);
|
|
||||||
let offset = offset.unwrap_or(0);
|
|
||||||
let rows =
|
|
||||||
chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(ChatChannelMessageLogInfo::from)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
|
|
||||||
const DEFAULT_COMMAND_PREFIX: &str = "/";
|
|
||||||
|
|
||||||
pub async fn get_chat_command_prefix_core(db: &AppDatabase) -> Result<String, AppCommandError> {
|
|
||||||
let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(val.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_command_prefix_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
prefix: String,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
let trimmed = prefix.trim();
|
|
||||||
if trimmed.is_empty() || trimmed.len() > 3 || trimmed.chars().any(|c| c.is_alphanumeric()) {
|
|
||||||
return Err(AppCommandError::invalid_input(
|
|
||||||
"Prefix must be 1-3 non-alphanumeric characters",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
crate::db::service::app_metadata_service::upsert_value(&db.conn, COMMAND_PREFIX_KEY, trimmed)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
|
|
||||||
|
|
||||||
pub async fn get_chat_message_language_core(db: &AppDatabase) -> Result<String, AppCommandError> {
|
|
||||||
let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(val.unwrap_or_else(|| "en".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_message_language_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
language: String,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
// Validate language code
|
|
||||||
let valid = [
|
|
||||||
"en", "zh-cn", "zh-tw", "ja", "ko", "es", "de", "fr", "pt", "ar",
|
|
||||||
];
|
|
||||||
let lang_lower = language.to_lowercase();
|
|
||||||
if !valid.contains(&lang_lower.as_str()) {
|
|
||||||
return Err(AppCommandError::invalid_input(format!(
|
|
||||||
"Unsupported language: {language}. Supported: {}",
|
|
||||||
valid.join(", ")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
crate::db::service::app_metadata_service::upsert_value(
|
|
||||||
&db.conn,
|
|
||||||
MESSAGE_LANGUAGE_KEY,
|
|
||||||
&lang_lower,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const EVENT_FILTER_KEY: &str = "chat_event_filter";
|
|
||||||
|
|
||||||
pub async fn get_chat_event_filter_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
) -> Result<Option<Vec<String>>, AppCommandError> {
|
|
||||||
let val = crate::db::service::app_metadata_service::get_value(&db.conn, EVENT_FILTER_KEY)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
match val {
|
|
||||||
Some(json) => {
|
|
||||||
// Parse as Option<Vec<String>> to correctly handle stored "null"
|
|
||||||
let filter: Option<Vec<String>> = serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
|
|
||||||
Ok(filter)
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_event_filter_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
filter: Option<Vec<String>>,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
match filter {
|
|
||||||
Some(arr) => {
|
|
||||||
let json = serde_json::to_string(&arr)
|
|
||||||
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
|
|
||||||
crate::db::service::app_metadata_service::upsert_value(
|
|
||||||
&db.conn,
|
|
||||||
EVENT_FILTER_KEY,
|
|
||||||
&json,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// null means all events enabled — remove the key
|
|
||||||
crate::db::service::app_metadata_service::upsert_value(
|
|
||||||
&db.conn,
|
|
||||||
EVENT_FILTER_KEY,
|
|
||||||
"null",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WeChat QR code auth
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn weixin_get_qrcode_core() -> Result<WeixinQrcodeInfo, AppCommandError> {
|
|
||||||
crate::chat_channel::backends::weixin::weixin_get_qrcode()
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn weixin_check_qrcode_core(
|
|
||||||
db: &AppDatabase,
|
|
||||||
channel_id: i32,
|
|
||||||
qrcode: &str,
|
|
||||||
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
|
|
||||||
let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode)
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::from)?;
|
|
||||||
|
|
||||||
// On confirmed: save token + update config with base_url
|
|
||||||
if result.status == "confirmed" {
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}",
|
|
||||||
result
|
|
||||||
.bot_token
|
|
||||||
.as_deref()
|
|
||||||
.map(|t| if t.len() > 8 { &t[..8] } else { t })
|
|
||||||
.unwrap_or("None"),
|
|
||||||
result.base_url.as_deref().unwrap_or("None"),
|
|
||||||
);
|
|
||||||
if let Some(ref token) = result.bot_token {
|
|
||||||
save_chat_channel_token_core(channel_id, token)?;
|
|
||||||
eprintln!("[Weixin] Token saved for channel {channel_id}");
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(ref base_url) = result.base_url {
|
|
||||||
let config_json = serde_json::json!({ "base_url": base_url }).to_string();
|
|
||||||
update_chat_channel_core(
|
|
||||||
db,
|
|
||||||
channel_id,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(config_json),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
eprintln!("[Weixin] Config updated with base_url for channel {channel_id}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return only the status — never expose bot_token to the frontend
|
|
||||||
Ok(WeixinQrcodeStatusPublic {
|
|
||||||
status: result.status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tauri commands (use tauri::State for injection)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_chat_channels(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
) -> Result<Vec<ChatChannelInfo>, AppCommandError> {
|
|
||||||
list_chat_channels_core(&db).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn create_chat_channel(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
name: String,
|
|
||||||
channel_type: String,
|
|
||||||
config_json: String,
|
|
||||||
enabled: bool,
|
|
||||||
daily_report_enabled: bool,
|
|
||||||
daily_report_time: Option<String>,
|
|
||||||
) -> Result<ChatChannelInfo, AppCommandError> {
|
|
||||||
create_chat_channel_core(
|
|
||||||
&db,
|
|
||||||
name,
|
|
||||||
channel_type,
|
|
||||||
config_json,
|
|
||||||
enabled,
|
|
||||||
daily_report_enabled,
|
|
||||||
daily_report_time,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_chat_channel(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
id: i32,
|
|
||||||
name: Option<String>,
|
|
||||||
enabled: Option<bool>,
|
|
||||||
config_json: Option<String>,
|
|
||||||
event_filter_json: Option<Option<String>>,
|
|
||||||
daily_report_enabled: Option<bool>,
|
|
||||||
daily_report_time: Option<Option<String>>,
|
|
||||||
) -> Result<ChatChannelInfo, AppCommandError> {
|
|
||||||
update_chat_channel_core(
|
|
||||||
&db,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
enabled,
|
|
||||||
config_json,
|
|
||||||
event_filter_json,
|
|
||||||
daily_report_enabled,
|
|
||||||
daily_report_time,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_chat_channel(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
manager: tauri::State<'_, ChatChannelManager>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
delete_chat_channel_core(&db, &manager, id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_chat_channel_token(
|
|
||||||
channel_id: i32,
|
|
||||||
token: String,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
save_chat_channel_token_core(channel_id, &token)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_chat_channel_has_token(channel_id: i32) -> Result<bool, AppCommandError> {
|
|
||||||
get_chat_channel_has_token_core(channel_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_chat_channel_token(channel_id: i32) -> Result<(), AppCommandError> {
|
|
||||||
delete_chat_channel_token_core(channel_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn connect_chat_channel(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
manager: tauri::State<'_, ChatChannelManager>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
connect_chat_channel_core(&db, &manager, id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn disconnect_chat_channel(
|
|
||||||
manager: tauri::State<'_, ChatChannelManager>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
disconnect_chat_channel_core(&manager, id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn test_chat_channel(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
test_chat_channel_core(&db, id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_chat_channel_status(
|
|
||||||
manager: tauri::State<'_, ChatChannelManager>,
|
|
||||||
) -> Result<Vec<ChannelStatusInfo>, AppCommandError> {
|
|
||||||
get_chat_channel_status_core(&manager).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_chat_channel_messages(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
channel_id: i32,
|
|
||||||
limit: Option<u64>,
|
|
||||||
offset: Option<u64>,
|
|
||||||
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
|
|
||||||
list_chat_channel_messages_core(&db, channel_id, limit, offset).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_chat_command_prefix(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
) -> Result<String, AppCommandError> {
|
|
||||||
get_chat_command_prefix_core(&db).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_chat_command_prefix(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
prefix: String,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
set_chat_command_prefix_core(&db, prefix).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_chat_event_filter(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
) -> Result<Option<Vec<String>>, AppCommandError> {
|
|
||||||
get_chat_event_filter_core(&db).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_chat_event_filter(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
filter: Option<Vec<String>>,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
set_chat_event_filter_core(&db, filter).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_chat_message_language(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
) -> Result<String, AppCommandError> {
|
|
||||||
get_chat_message_language_core(&db).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_chat_message_language(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
language: String,
|
|
||||||
) -> Result<(), AppCommandError> {
|
|
||||||
set_chat_message_language_core(&db, language).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, AppCommandError> {
|
|
||||||
weixin_get_qrcode_core().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn weixin_check_qrcode(
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
channel_id: i32,
|
|
||||||
qrcode: String,
|
|
||||||
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
|
|
||||||
weixin_check_qrcode_core(&db, channel_id, &qrcode).await
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod chat_channel;
|
|
||||||
pub mod conversations;
|
pub mod conversations;
|
||||||
pub mod experts;
|
pub mod experts;
|
||||||
pub mod folder_commands;
|
pub mod folder_commands;
|
||||||
@@ -8,7 +7,6 @@ pub mod mcp;
|
|||||||
pub mod model_provider;
|
pub mod model_provider;
|
||||||
#[cfg(feature = "tauri-runtime")]
|
#[cfg(feature = "tauri-runtime")]
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod project_boot;
|
|
||||||
pub mod quick_messages;
|
pub mod quick_messages;
|
||||||
pub mod system_settings;
|
pub mod system_settings;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Package manager detection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct PackageManagerInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub installed: bool,
|
|
||||||
pub version: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn detect_one(name: &str) -> PackageManagerInfo {
|
|
||||||
let program = match name {
|
|
||||||
"bun" => "bun",
|
|
||||||
"pnpm" => "pnpm",
|
|
||||||
"yarn" => "yarn",
|
|
||||||
_ => "npm",
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = crate::process::tokio_command(program)
|
|
||||||
.arg("--version")
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(output) if output.status.success() => {
|
|
||||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
||||||
PackageManagerInfo {
|
|
||||||
name: name.to_string(),
|
|
||||||
installed: true,
|
|
||||||
version: Some(version),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => PackageManagerInfo {
|
|
||||||
name: name.to_string(),
|
|
||||||
installed: false,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
||||||
pub async fn detect_package_manager(name: String) -> PackageManagerInfo {
|
|
||||||
detect_one(&name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Project creation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
||||||
pub async fn create_shadcn_project(
|
|
||||||
project_name: String,
|
|
||||||
template: String,
|
|
||||||
preset_code: String,
|
|
||||||
package_manager: String,
|
|
||||||
target_dir: String,
|
|
||||||
) -> Result<String, AppCommandError> {
|
|
||||||
let project_name = project_name.trim().to_string();
|
|
||||||
let template = template.trim().to_string();
|
|
||||||
let preset_code = preset_code.trim().to_string();
|
|
||||||
let package_manager = package_manager.trim().to_string();
|
|
||||||
let target_dir = target_dir.trim().to_string();
|
|
||||||
|
|
||||||
if project_name.is_empty() {
|
|
||||||
return Err(AppCommandError::invalid_input("Project name is required"));
|
|
||||||
}
|
|
||||||
if template.is_empty() {
|
|
||||||
return Err(AppCommandError::invalid_input("Template is required"));
|
|
||||||
}
|
|
||||||
if target_dir.is_empty() {
|
|
||||||
return Err(AppCommandError::invalid_input(
|
|
||||||
"Target directory is required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let full_path = PathBuf::from(&target_dir).join(&project_name);
|
|
||||||
let full_path_str = full_path.to_string_lossy().to_string();
|
|
||||||
|
|
||||||
// Check if directory already exists and is non-empty
|
|
||||||
if full_path.exists() {
|
|
||||||
let is_empty = full_path
|
|
||||||
.read_dir()
|
|
||||||
.map(|mut entries| entries.next().is_none())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !is_empty {
|
|
||||||
return Err(AppCommandError::already_exists(
|
|
||||||
"Target directory already exists and is not empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the command based on package manager
|
|
||||||
let (program, prefix_args): (&str, Vec<&str>) = match package_manager.as_str() {
|
|
||||||
"pnpm" => ("pnpm", vec!["dlx"]),
|
|
||||||
"yarn" => ("yarn", vec!["dlx"]),
|
|
||||||
"bun" => ("bunx", vec![]),
|
|
||||||
_ => ("npx", vec![]),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmd = crate::process::tokio_command(program);
|
|
||||||
cmd.args(&prefix_args);
|
|
||||||
cmd.args([
|
|
||||||
"shadcn@latest",
|
|
||||||
"init",
|
|
||||||
"-n",
|
|
||||||
&project_name,
|
|
||||||
"-t",
|
|
||||||
&template,
|
|
||||||
"-p",
|
|
||||||
&preset_code,
|
|
||||||
"-y",
|
|
||||||
]);
|
|
||||||
cmd.current_dir(&target_dir);
|
|
||||||
|
|
||||||
// Log the full command for debugging
|
|
||||||
let cmd_display = format!(
|
|
||||||
"{} {} shadcn@latest init -n {} -t {} -p {} -y (cwd={})",
|
|
||||||
program,
|
|
||||||
prefix_args.join(" "),
|
|
||||||
project_name,
|
|
||||||
template,
|
|
||||||
preset_code,
|
|
||||||
target_dir
|
|
||||||
);
|
|
||||||
eprintln!("[ProjectBoot] executing: {cmd_display}");
|
|
||||||
|
|
||||||
let output = cmd.output().await.map_err(|e| {
|
|
||||||
eprintln!("[ProjectBoot] spawn error: {e}");
|
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
|
||||||
AppCommandError::dependency_missing(format!(
|
|
||||||
"{program} is not installed. Please install Node.js first."
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
AppCommandError::external_command(
|
|
||||||
"Failed to execute project creation command",
|
|
||||||
e.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"[ProjectBoot] exit={} stdout_len={} stderr_len={}",
|
|
||||||
output.status,
|
|
||||||
stdout.len(),
|
|
||||||
stderr.len()
|
|
||||||
);
|
|
||||||
if !stdout.is_empty() {
|
|
||||||
eprintln!("[ProjectBoot] stdout: {stdout}");
|
|
||||||
}
|
|
||||||
if !stderr.is_empty() {
|
|
||||||
eprintln!("[ProjectBoot] stderr: {stderr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let mut detail = String::new();
|
|
||||||
if !stderr.is_empty() {
|
|
||||||
detail.push_str(&stderr);
|
|
||||||
}
|
|
||||||
if !stdout.is_empty() {
|
|
||||||
if !detail.is_empty() {
|
|
||||||
detail.push('\n');
|
|
||||||
}
|
|
||||||
detail.push_str(&stdout);
|
|
||||||
}
|
|
||||||
if detail.is_empty() {
|
|
||||||
detail = format!("Command exited with status: {}", output.status);
|
|
||||||
}
|
|
||||||
return Err(AppCommandError::external_command(
|
|
||||||
"Project creation command failed",
|
|
||||||
detail,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(full_path_str)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
|
||||||
#[sea_orm(table_name = "chat_channel")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
|
||||||
pub channel_type: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub config_json: String,
|
|
||||||
pub event_filter_json: Option<String>,
|
|
||||||
pub daily_report_enabled: bool,
|
|
||||||
pub daily_report_time: Option<String>,
|
|
||||||
pub created_at: DateTimeUtc,
|
|
||||||
pub updated_at: DateTimeUtc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(has_many = "super::chat_channel_message_log::Entity")]
|
|
||||||
MessageLogs,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::chat_channel_message_log::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::MessageLogs.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
|
||||||
#[sea_orm(table_name = "chat_channel_message_log")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub direction: String,
|
|
||||||
pub message_type: String,
|
|
||||||
pub content_preview: String,
|
|
||||||
pub status: String,
|
|
||||||
pub error_detail: Option<String>,
|
|
||||||
pub created_at: DateTimeUtc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::chat_channel::Entity",
|
|
||||||
from = "Column::ChannelId",
|
|
||||||
to = "super::chat_channel::Column::Id"
|
|
||||||
)]
|
|
||||||
ChatChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::chat_channel::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::ChatChannel.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
|
||||||
#[sea_orm(table_name = "chat_channel_sender_context")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub sender_id: String,
|
|
||||||
pub current_folder_id: Option<i32>,
|
|
||||||
pub current_agent_type: Option<String>,
|
|
||||||
pub current_conversation_id: Option<i32>,
|
|
||||||
pub current_connection_id: Option<String>,
|
|
||||||
pub auto_approve: bool,
|
|
||||||
pub created_at: DateTimeUtc,
|
|
||||||
pub updated_at: DateTimeUtc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::chat_channel::Entity",
|
|
||||||
from = "Column::ChannelId",
|
|
||||||
to = "super::chat_channel::Column::Id"
|
|
||||||
)]
|
|
||||||
ChatChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::chat_channel::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::ChatChannel.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
pub mod agent_setting;
|
pub mod agent_setting;
|
||||||
pub mod app_metadata;
|
pub mod app_metadata;
|
||||||
pub mod chat_channel;
|
|
||||||
pub mod chat_channel_message_log;
|
|
||||||
pub mod chat_channel_sender_context;
|
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod folder_command;
|
pub mod folder_command;
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
pub use super::agent_setting::Entity as AgentSetting;
|
pub use super::agent_setting::Entity as AgentSetting;
|
||||||
pub use super::app_metadata::Entity as AppMetadata;
|
pub use super::app_metadata::Entity as AppMetadata;
|
||||||
pub use super::chat_channel::Entity as ChatChannel;
|
|
||||||
pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog;
|
|
||||||
pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext;
|
|
||||||
pub use super::conversation::Entity as Conversation;
|
pub use super::conversation::Entity as Conversation;
|
||||||
pub use super::folder::Entity as Folder;
|
pub use super::folder::Entity as Folder;
|
||||||
pub use super::folder_command::Entity as FolderCommand;
|
pub use super::folder_command::Entity as FolderCommand;
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use sea_orm::prelude::DateTimeUtc;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
|
||||||
QueryFilter, QueryOrder, Set,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::entities::chat_channel_message_log;
|
|
||||||
use crate::db::error::DbError;
|
|
||||||
|
|
||||||
pub async fn create_log(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
direction: &str,
|
|
||||||
message_type: &str,
|
|
||||||
content_preview: &str,
|
|
||||||
status: &str,
|
|
||||||
error_detail: Option<String>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
let active = chat_channel_message_log::ActiveModel {
|
|
||||||
id: NotSet,
|
|
||||||
channel_id: Set(channel_id),
|
|
||||||
direction: Set(direction.to_string()),
|
|
||||||
message_type: Set(message_type.to_string()),
|
|
||||||
content_preview: Set(truncate_preview(content_preview)),
|
|
||||||
status: Set(status.to_string()),
|
|
||||||
error_detail: Set(error_detail),
|
|
||||||
created_at: Set(Utc::now()),
|
|
||||||
};
|
|
||||||
active.insert(conn).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_by_channel(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
limit: u64,
|
|
||||||
offset: u64,
|
|
||||||
) -> Result<Vec<chat_channel_message_log::Model>, DbError> {
|
|
||||||
use sea_orm::PaginatorTrait;
|
|
||||||
Ok(chat_channel_message_log::Entity::find()
|
|
||||||
.filter(chat_channel_message_log::Column::ChannelId.eq(channel_id))
|
|
||||||
.order_by_desc(chat_channel_message_log::Column::CreatedAt)
|
|
||||||
.paginate(conn, limit)
|
|
||||||
.fetch_page(offset / limit)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cleanup_old_logs(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
older_than: DateTimeUtc,
|
|
||||||
) -> Result<u64, DbError> {
|
|
||||||
let result = chat_channel_message_log::Entity::delete_many()
|
|
||||||
.filter(chat_channel_message_log::Column::CreatedAt.lt(older_than))
|
|
||||||
.exec(conn)
|
|
||||||
.await?;
|
|
||||||
Ok(result.rows_affected)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_preview(s: &str) -> String {
|
|
||||||
if s.len() <= 200 {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
let mut end = 200;
|
|
||||||
while !s.is_char_boundary(end) && end > 0 {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
format!("{}...", &s[..end])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
|
||||||
IntoActiveModel, QueryFilter, QueryOrder, Set,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::entities::chat_channel;
|
|
||||||
use crate::db::error::DbError;
|
|
||||||
|
|
||||||
pub async fn create(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
name: String,
|
|
||||||
channel_type: String,
|
|
||||||
config_json: String,
|
|
||||||
enabled: bool,
|
|
||||||
daily_report_enabled: bool,
|
|
||||||
daily_report_time: Option<String>,
|
|
||||||
) -> Result<chat_channel::Model, DbError> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let active = chat_channel::ActiveModel {
|
|
||||||
id: NotSet,
|
|
||||||
name: Set(name),
|
|
||||||
channel_type: Set(channel_type),
|
|
||||||
enabled: Set(enabled),
|
|
||||||
config_json: Set(config_json),
|
|
||||||
event_filter_json: Set(None),
|
|
||||||
daily_report_enabled: Set(daily_report_enabled),
|
|
||||||
daily_report_time: Set(daily_report_time),
|
|
||||||
created_at: Set(now),
|
|
||||||
updated_at: Set(now),
|
|
||||||
};
|
|
||||||
Ok(active.insert(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn update(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
id: i32,
|
|
||||||
name: Option<String>,
|
|
||||||
enabled: Option<bool>,
|
|
||||||
config_json: Option<String>,
|
|
||||||
event_filter_json: Option<Option<String>>,
|
|
||||||
daily_report_enabled: Option<bool>,
|
|
||||||
daily_report_time: Option<Option<String>>,
|
|
||||||
) -> Result<chat_channel::Model, DbError> {
|
|
||||||
let model = chat_channel::Entity::find_by_id(id)
|
|
||||||
.one(conn)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DbError::Migration(format!("chat channel not found: {id}")))?;
|
|
||||||
|
|
||||||
let mut active = model.into_active_model();
|
|
||||||
if let Some(v) = name {
|
|
||||||
active.name = Set(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = enabled {
|
|
||||||
active.enabled = Set(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = config_json {
|
|
||||||
active.config_json = Set(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = event_filter_json {
|
|
||||||
active.event_filter_json = Set(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = daily_report_enabled {
|
|
||||||
active.daily_report_enabled = Set(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = daily_report_time {
|
|
||||||
active.daily_report_time = Set(v);
|
|
||||||
}
|
|
||||||
active.updated_at = Set(Utc::now());
|
|
||||||
Ok(active.update(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
|
|
||||||
chat_channel::Entity::delete_by_id(id).exec(conn).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_by_id(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<Option<chat_channel::Model>, DbError> {
|
|
||||||
Ok(chat_channel::Entity::find_by_id(id).one(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_all(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
|
|
||||||
Ok(chat_channel::Entity::find()
|
|
||||||
.order_by_asc(chat_channel::Column::Id)
|
|
||||||
.all(conn)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_enabled(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
|
|
||||||
Ok(chat_channel::Entity::find()
|
|
||||||
.filter(chat_channel::Column::Enabled.eq(true))
|
|
||||||
.order_by_asc(chat_channel::Column::Id)
|
|
||||||
.all(conn)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
pub mod agent_setting_service;
|
pub mod agent_setting_service;
|
||||||
pub mod app_metadata_service;
|
pub mod app_metadata_service;
|
||||||
pub mod chat_channel_message_log_service;
|
|
||||||
pub mod chat_channel_service;
|
|
||||||
pub mod conversation_service;
|
pub mod conversation_service;
|
||||||
pub mod folder_command_service;
|
pub mod folder_command_service;
|
||||||
pub mod folder_service;
|
pub mod folder_service;
|
||||||
pub mod import_service;
|
pub mod import_service;
|
||||||
pub mod model_provider_service;
|
pub mod model_provider_service;
|
||||||
pub mod quick_message_service;
|
pub mod quick_message_service;
|
||||||
pub mod sender_context_service;
|
|
||||||
pub mod tab_service;
|
pub mod tab_service;
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
|
||||||
IntoActiveModel, QueryFilter, Set,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::entities::chat_channel_sender_context;
|
|
||||||
use crate::db::error::DbError;
|
|
||||||
|
|
||||||
pub async fn get_or_create(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
let existing = chat_channel_sender_context::Entity::find()
|
|
||||||
.filter(chat_channel_sender_context::Column::ChannelId.eq(channel_id))
|
|
||||||
.filter(chat_channel_sender_context::Column::SenderId.eq(sender_id))
|
|
||||||
.one(conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(model) = existing {
|
|
||||||
return Ok(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now();
|
|
||||||
let active = chat_channel_sender_context::ActiveModel {
|
|
||||||
id: NotSet,
|
|
||||||
channel_id: Set(channel_id),
|
|
||||||
sender_id: Set(sender_id.to_string()),
|
|
||||||
current_folder_id: Set(None),
|
|
||||||
current_agent_type: Set(None),
|
|
||||||
current_conversation_id: Set(None),
|
|
||||||
current_connection_id: Set(None),
|
|
||||||
auto_approve: Set(false),
|
|
||||||
created_at: Set(now),
|
|
||||||
updated_at: Set(now),
|
|
||||||
};
|
|
||||||
Ok(active.insert(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_folder(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
folder_id: Option<i32>,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
let model = get_or_create(conn, channel_id, sender_id).await?;
|
|
||||||
let mut active = model.into_active_model();
|
|
||||||
active.current_folder_id = Set(folder_id);
|
|
||||||
active.updated_at = Set(Utc::now());
|
|
||||||
Ok(active.update(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_agent(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
agent_type: Option<String>,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
let model = get_or_create(conn, channel_id, sender_id).await?;
|
|
||||||
let mut active = model.into_active_model();
|
|
||||||
active.current_agent_type = Set(agent_type);
|
|
||||||
active.updated_at = Set(Utc::now());
|
|
||||||
Ok(active.update(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_session(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
conversation_id: Option<i32>,
|
|
||||||
connection_id: Option<String>,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
let model = get_or_create(conn, channel_id, sender_id).await?;
|
|
||||||
let mut active = model.into_active_model();
|
|
||||||
active.current_conversation_id = Set(conversation_id);
|
|
||||||
active.current_connection_id = Set(connection_id);
|
|
||||||
active.updated_at = Set(Utc::now());
|
|
||||||
Ok(active.update(conn).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clear_session(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
update_session(conn, channel_id, sender_id, None, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_auto_approve(
|
|
||||||
conn: &DatabaseConnection,
|
|
||||||
channel_id: i32,
|
|
||||||
sender_id: &str,
|
|
||||||
auto_approve: bool,
|
|
||||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
|
||||||
let model = get_or_create(conn, channel_id, sender_id).await?;
|
|
||||||
let mut active = model.into_active_model();
|
|
||||||
active.auto_approve = Set(auto_approve);
|
|
||||||
active.updated_at = Set(Utc::now());
|
|
||||||
Ok(active.update(conn).await?)
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,6 @@ fn token_key(account_id: &str) -> String {
|
|||||||
format!("github-token:{}", account_id)
|
format!("github-token:{}", account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_token_key(channel_id: i32) -> String {
|
|
||||||
format!("chat-channel:{}", channel_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tauri mode: OS keyring ──
|
// ── Tauri mode: OS keyring ──
|
||||||
|
|
||||||
@@ -91,50 +88,3 @@ pub fn delete_token(account_id: &str) -> Result<(), String> {
|
|||||||
write_tokens(&tokens)
|
write_tokens(&tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat channel token helpers ──
|
|
||||||
// Reuse the same storage mechanism (keyring or file) with a different key prefix.
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id))
|
|
||||||
.map_err(|e| format!("keyring init error: {e}"))?;
|
|
||||||
entry
|
|
||||||
.set_password(token)
|
|
||||||
.map_err(|e| format!("keyring set error: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
pub fn get_channel_token(channel_id: i32) -> Option<String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)).ok()?;
|
|
||||||
entry.get_password().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
pub fn delete_channel_token(channel_id: i32) -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id))
|
|
||||||
.map_err(|e| format!("keyring init error: {e}"))?;
|
|
||||||
match entry.delete_credential() {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
|
||||||
Err(e) => Err(format!("keyring delete error: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "tauri-runtime"))]
|
|
||||||
pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> {
|
|
||||||
let mut tokens = read_tokens();
|
|
||||||
tokens.insert(channel_token_key(channel_id), token.to_string());
|
|
||||||
write_tokens(&tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "tauri-runtime"))]
|
|
||||||
pub fn get_channel_token(channel_id: i32) -> Option<String> {
|
|
||||||
read_tokens().get(&channel_token_key(channel_id)).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "tauri-runtime"))]
|
|
||||||
pub fn delete_channel_token(channel_id: i32) -> Result<(), String> {
|
|
||||||
let mut tokens = read_tokens();
|
|
||||||
tokens.remove(&channel_token_key(channel_id));
|
|
||||||
write_tokens(&tokens)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-79
@@ -1,7 +1,6 @@
|
|||||||
mod acp;
|
mod acp;
|
||||||
mod app_error;
|
mod app_error;
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
pub mod chat_channel;
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod git_credential;
|
pub mod git_credential;
|
||||||
@@ -30,11 +29,10 @@ mod tauri_app {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
use crate::acp::manager::ConnectionManager;
|
||||||
use crate::chat_channel::manager::ChatChannelManager;
|
|
||||||
use crate::commands::{
|
use crate::commands::{
|
||||||
acp as acp_commands, chat_channel as chat_channel_commands, conversations,
|
acp as acp_commands, conversations,
|
||||||
experts as experts_commands, folder_commands, folders, mcp as mcp_commands,
|
experts as experts_commands, folder_commands, folders, mcp as mcp_commands,
|
||||||
model_provider as model_provider_commands, notification, project_boot,
|
model_provider as model_provider_commands, notification,
|
||||||
quick_messages as quick_messages_commands, system_settings,
|
quick_messages as quick_messages_commands, system_settings,
|
||||||
terminal as terminal_commands, version_control, windows,
|
terminal as terminal_commands, version_control, windows,
|
||||||
workspace_state as workspace_state_commands,
|
workspace_state as workspace_state_commands,
|
||||||
@@ -106,10 +104,7 @@ mod tauri_app {
|
|||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.manage(ConnectionManager::new())
|
.manage(ConnectionManager::new())
|
||||||
.manage(TerminalManager::new())
|
.manage(TerminalManager::new())
|
||||||
.manage(ChatChannelManager::new())
|
|
||||||
.manage(windows::SettingsWindowState::new())
|
.manage(windows::SettingsWindowState::new())
|
||||||
.manage(windows::CommitWindowState::new())
|
|
||||||
.manage(windows::MergeWindowState::new())
|
|
||||||
.manage(web::WebServerState::new())
|
.manage(web::WebServerState::new())
|
||||||
.manage(std::sync::Arc::new(
|
.manage(std::sync::Arc::new(
|
||||||
web::event_bridge::WebEventBroadcaster::new(),
|
web::event_bridge::WebEventBroadcaster::new(),
|
||||||
@@ -170,22 +165,7 @@ mod tauri_app {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start chat channel background tasks
|
// Single-window workspace: ensure the main window exists.
|
||||||
{
|
|
||||||
let ccm = app.state::<ChatChannelManager>();
|
|
||||||
let broadcaster =
|
|
||||||
app.state::<std::sync::Arc<web::event_bridge::WebEventBroadcaster>>();
|
|
||||||
let db_conn = app.state::<db::AppDatabase>().conn.clone();
|
|
||||||
let ccm_ref = ccm.clone_ref();
|
|
||||||
let br = broadcaster.inner().clone();
|
|
||||||
let cm = app.state::<ConnectionManager>().clone_ref();
|
|
||||||
let emitter = web::event_bridge::EventEmitter::Tauri(app.handle().clone());
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
ccm_ref.start_background(br, db_conn, cm, emitter).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-window workspace: ensure the main window exists.
|
|
||||||
// Workspace state (open folders, opened tabs, active tab) is
|
// Workspace state (open folders, opened tabs, active tab) is
|
||||||
// restored by the frontend via `list_open_folder_details` /
|
// restored by the frontend via `list_open_folder_details` /
|
||||||
// `list_opened_tabs` inside the main window.
|
// `list_opened_tabs` inside the main window.
|
||||||
@@ -217,35 +197,6 @@ mod tauri_app {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if label.starts_with("commit-")
|
|
||||||
&& matches!(
|
|
||||||
event,
|
|
||||||
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let app = window.app_handle();
|
|
||||||
if let Some(state) = app.try_state::<windows::CommitWindowState>() {
|
|
||||||
windows::restore_window_after_commit(app, &state, &label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if label.starts_with("merge-")
|
|
||||||
&& matches!(
|
|
||||||
event,
|
|
||||||
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let app = window.app_handle();
|
|
||||||
if let Some(state) = app.try_state::<windows::MergeWindowState>() {
|
|
||||||
windows::restore_window_after_merge(app, &state, &label);
|
|
||||||
}
|
|
||||||
let app_clone = window.app_handle().clone();
|
|
||||||
let label_clone = label.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
windows::cleanup_dangling_merge(&app_clone, &label_clone).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
|
if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
|
||||||
let app = window.app_handle();
|
let app = window.app_handle();
|
||||||
if let Some(cm) = app.try_state::<ConnectionManager>() {
|
if let Some(cm) = app.try_state::<ConnectionManager>() {
|
||||||
@@ -352,16 +303,9 @@ mod tauri_app {
|
|||||||
folders::git_log,
|
folders::git_log,
|
||||||
folders::git_commit_branches,
|
folders::git_commit_branches,
|
||||||
windows::open_folder_window,
|
windows::open_folder_window,
|
||||||
windows::open_commit_window,
|
|
||||||
windows::open_settings_window,
|
windows::open_settings_window,
|
||||||
windows::open_merge_window,
|
|
||||||
windows::open_stash_window,
|
|
||||||
windows::open_push_window,
|
|
||||||
windows::open_project_boot_window,
|
|
||||||
windows::update_traffic_light_position,
|
windows::update_traffic_light_position,
|
||||||
windows::update_appearance_mode,
|
windows::update_appearance_mode,
|
||||||
project_boot::detect_package_manager,
|
|
||||||
project_boot::create_shadcn_project,
|
|
||||||
system_settings::get_system_proxy_settings,
|
system_settings::get_system_proxy_settings,
|
||||||
system_settings::update_system_proxy_settings,
|
system_settings::update_system_proxy_settings,
|
||||||
system_settings::get_system_language_settings,
|
system_settings::get_system_language_settings,
|
||||||
@@ -440,26 +384,6 @@ mod tauri_app {
|
|||||||
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,
|
||||||
chat_channel_commands::list_chat_channels,
|
|
||||||
chat_channel_commands::create_chat_channel,
|
|
||||||
chat_channel_commands::update_chat_channel,
|
|
||||||
chat_channel_commands::delete_chat_channel,
|
|
||||||
chat_channel_commands::save_chat_channel_token,
|
|
||||||
chat_channel_commands::get_chat_channel_has_token,
|
|
||||||
chat_channel_commands::delete_chat_channel_token,
|
|
||||||
chat_channel_commands::connect_chat_channel,
|
|
||||||
chat_channel_commands::disconnect_chat_channel,
|
|
||||||
chat_channel_commands::test_chat_channel,
|
|
||||||
chat_channel_commands::get_chat_channel_status,
|
|
||||||
chat_channel_commands::list_chat_channel_messages,
|
|
||||||
chat_channel_commands::get_chat_command_prefix,
|
|
||||||
chat_channel_commands::set_chat_command_prefix,
|
|
||||||
chat_channel_commands::get_chat_event_filter,
|
|
||||||
chat_channel_commands::set_chat_event_filter,
|
|
||||||
chat_channel_commands::get_chat_message_language,
|
|
||||||
chat_channel_commands::set_chat_message_language,
|
|
||||||
chat_channel_commands::weixin_get_qrcode,
|
|
||||||
chat_channel_commands::weixin_check_qrcode,
|
|
||||||
model_provider_commands::list_model_providers,
|
model_provider_commands::list_model_providers,
|
||||||
model_provider_commands::create_model_provider,
|
model_provider_commands::create_model_provider,
|
||||||
model_provider_commands::update_model_provider,
|
model_provider_commands::update_model_provider,
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatChannelInfo {
|
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
|
||||||
pub channel_type: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub config_json: String,
|
|
||||||
pub event_filter_json: Option<String>,
|
|
||||||
pub daily_report_enabled: bool,
|
|
||||||
pub daily_report_time: Option<String>,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChannelStatusInfo {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub name: String,
|
|
||||||
pub channel_type: String,
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatChannelMessageLogInfo {
|
|
||||||
pub id: i32,
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub direction: String,
|
|
||||||
pub message_type: String,
|
|
||||||
pub content_preview: String,
|
|
||||||
pub status: String,
|
|
||||||
pub error_detail: Option<String>,
|
|
||||||
pub created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<crate::db::entities::chat_channel::Model> for ChatChannelInfo {
|
|
||||||
fn from(m: crate::db::entities::chat_channel::Model) -> Self {
|
|
||||||
Self {
|
|
||||||
id: m.id,
|
|
||||||
name: m.name,
|
|
||||||
channel_type: m.channel_type,
|
|
||||||
enabled: m.enabled,
|
|
||||||
config_json: m.config_json,
|
|
||||||
event_filter_json: m.event_filter_json,
|
|
||||||
daily_report_enabled: m.daily_report_enabled,
|
|
||||||
daily_report_time: m.daily_report_time,
|
|
||||||
created_at: m.created_at.to_rfc3339(),
|
|
||||||
updated_at: m.updated_at.to_rfc3339(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<crate::db::entities::chat_channel_message_log::Model> for ChatChannelMessageLogInfo {
|
|
||||||
fn from(m: crate::db::entities::chat_channel_message_log::Model) -> Self {
|
|
||||||
Self {
|
|
||||||
id: m.id,
|
|
||||||
channel_id: m.channel_id,
|
|
||||||
direction: m.direction,
|
|
||||||
message_type: m.message_type,
|
|
||||||
content_preview: m.content_preview,
|
|
||||||
status: m.status,
|
|
||||||
error_detail: m.error_detail,
|
|
||||||
created_at: m.created_at.to_rfc3339(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod chat_channel;
|
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
@@ -9,7 +8,6 @@ pub mod system;
|
|||||||
|
|
||||||
pub use agent::AgentType;
|
pub use agent::AgentType;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
||||||
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{extract::Extension, Json};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
|
||||||
use crate::app_state::AppState;
|
|
||||||
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
|
|
||||||
use crate::commands::chat_channel as cc_commands;
|
|
||||||
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Param structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CreateChatChannelParams {
|
|
||||||
pub name: String,
|
|
||||||
pub channel_type: String,
|
|
||||||
pub config_json: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub daily_report_enabled: bool,
|
|
||||||
pub daily_report_time: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateChatChannelParams {
|
|
||||||
pub id: i32,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub enabled: Option<bool>,
|
|
||||||
pub config_json: Option<String>,
|
|
||||||
pub event_filter_json: Option<Option<String>>,
|
|
||||||
pub daily_report_enabled: Option<bool>,
|
|
||||||
pub daily_report_time: Option<Option<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ChannelIdParams {
|
|
||||||
pub id: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SaveTokenParams {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ChannelIdOnlyParams {
|
|
||||||
pub channel_id: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ListMessagesParams {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub limit: Option<u64>,
|
|
||||||
pub offset: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn list_chat_channels(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
) -> Result<Json<Vec<ChatChannelInfo>>, AppCommandError> {
|
|
||||||
let result = cc_commands::list_chat_channels_core(&state.db).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<CreateChatChannelParams>,
|
|
||||||
) -> Result<Json<ChatChannelInfo>, AppCommandError> {
|
|
||||||
let result = cc_commands::create_chat_channel_core(
|
|
||||||
&state.db,
|
|
||||||
params.name,
|
|
||||||
params.channel_type,
|
|
||||||
params.config_json,
|
|
||||||
params.enabled,
|
|
||||||
params.daily_report_enabled,
|
|
||||||
params.daily_report_time,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<UpdateChatChannelParams>,
|
|
||||||
) -> Result<Json<ChatChannelInfo>, AppCommandError> {
|
|
||||||
let result = cc_commands::update_chat_channel_core(
|
|
||||||
&state.db,
|
|
||||||
params.id,
|
|
||||||
params.name,
|
|
||||||
params.enabled,
|
|
||||||
params.config_json,
|
|
||||||
params.event_filter_json,
|
|
||||||
params.daily_report_enabled,
|
|
||||||
params.daily_report_time,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<ChannelIdParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::delete_chat_channel_core(&state.db, &state.chat_channel_manager, params.id)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_chat_channel_token(
|
|
||||||
Json(params): Json<SaveTokenParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::save_chat_channel_token_core(params.channel_id, ¶ms.token)?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_channel_has_token(
|
|
||||||
Json(params): Json<ChannelIdOnlyParams>,
|
|
||||||
) -> Result<Json<bool>, AppCommandError> {
|
|
||||||
let has = cc_commands::get_chat_channel_has_token_core(params.channel_id)?;
|
|
||||||
Ok(Json(has))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_chat_channel_token(
|
|
||||||
Json(params): Json<ChannelIdOnlyParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::delete_chat_channel_token_core(params.channel_id)?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<ChannelIdParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::connect_chat_channel_core(&state.db, &state.chat_channel_manager, params.id)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disconnect_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<ChannelIdParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::disconnect_chat_channel_core(&state.chat_channel_manager, params.id).await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn test_chat_channel(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<ChannelIdParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::test_chat_channel_core(&state.db, params.id).await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_channel_status(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
) -> Result<Json<Vec<ChannelStatusInfo>>, AppCommandError> {
|
|
||||||
let result = cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_chat_channel_messages(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<ListMessagesParams>,
|
|
||||||
) -> Result<Json<Vec<ChatChannelMessageLogInfo>>, AppCommandError> {
|
|
||||||
let result = cc_commands::list_chat_channel_messages_core(
|
|
||||||
&state.db,
|
|
||||||
params.channel_id,
|
|
||||||
params.limit,
|
|
||||||
params.offset,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SetCommandPrefixParams {
|
|
||||||
pub prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_command_prefix(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
) -> Result<Json<String>, AppCommandError> {
|
|
||||||
let result = cc_commands::get_chat_command_prefix_core(&state.db).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_command_prefix(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<SetCommandPrefixParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::set_chat_command_prefix_core(&state.db, params.prefix).await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SetEventFilterParams {
|
|
||||||
pub filter: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_event_filter(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
) -> Result<Json<Option<Vec<String>>>, AppCommandError> {
|
|
||||||
let result = cc_commands::get_chat_event_filter_core(&state.db).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_event_filter(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<SetEventFilterParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::set_chat_event_filter_core(&state.db, params.filter).await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_chat_message_language(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
) -> Result<Json<String>, AppCommandError> {
|
|
||||||
let result = cc_commands::get_chat_message_language_core(&state.db).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SetMessageLanguageParams {
|
|
||||||
pub language: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_chat_message_language(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<SetMessageLanguageParams>,
|
|
||||||
) -> Result<Json<()>, AppCommandError> {
|
|
||||||
cc_commands::set_chat_message_language_core(&state.db, params.language).await?;
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WeChat QR code auth
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn weixin_get_qrcode() -> Result<Json<WeixinQrcodeInfo>, AppCommandError> {
|
|
||||||
let result = cc_commands::weixin_get_qrcode_core().await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct WeixinCheckQrcodeParams {
|
|
||||||
pub channel_id: i32,
|
|
||||||
pub qrcode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn weixin_check_qrcode(
|
|
||||||
Extension(state): Extension<Arc<AppState>>,
|
|
||||||
Json(params): Json<WeixinCheckQrcodeParams>,
|
|
||||||
) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
|
|
||||||
let result =
|
|
||||||
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode).await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod chat_channel;
|
|
||||||
pub mod conversations;
|
pub mod conversations;
|
||||||
mod error;
|
mod error;
|
||||||
pub mod experts;
|
pub mod experts;
|
||||||
@@ -9,7 +8,6 @@ pub mod folders;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod model_provider;
|
pub mod model_provider;
|
||||||
pub mod project_boot;
|
|
||||||
pub mod quick_messages;
|
pub mod quick_messages;
|
||||||
pub mod system_settings;
|
pub mod system_settings;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
use axum::Json;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
|
||||||
use crate::commands::project_boot as pb_commands;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Param structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct DetectPackageManagerParams {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CreateShadcnProjectParams {
|
|
||||||
pub project_name: String,
|
|
||||||
pub template: String,
|
|
||||||
pub preset_code: String,
|
|
||||||
pub package_manager: String,
|
|
||||||
pub target_dir: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn detect_package_manager(
|
|
||||||
Json(params): Json<DetectPackageManagerParams>,
|
|
||||||
) -> Json<pb_commands::PackageManagerInfo> {
|
|
||||||
let info = pb_commands::detect_package_manager(params.name).await;
|
|
||||||
Json(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_shadcn_project(
|
|
||||||
Json(params): Json<CreateShadcnProjectParams>,
|
|
||||||
) -> Result<Json<String>, AppCommandError> {
|
|
||||||
let result = pb_commands::create_shadcn_project(
|
|
||||||
params.project_name,
|
|
||||||
params.template,
|
|
||||||
params.preset_code,
|
|
||||||
params.package_manager,
|
|
||||||
params.target_dir,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
@@ -458,7 +458,6 @@ pub async fn start_web_server(
|
|||||||
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
|
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
|
||||||
data_dir: app.path().app_data_dir().unwrap_or_default(),
|
data_dir: app.path().app_data_dir().unwrap_or_default(),
|
||||||
web_server_state: WebServerState::new(), // placeholder; not used by handlers
|
web_server_state: WebServerState::new(), // placeholder; not used by handlers
|
||||||
chat_channel_manager: crate::app_state::default_chat_channel_manager(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let router = router::build_router(app_state, token.clone(), static_dir);
|
let router = router::build_router(app_state, token.clone(), static_dir);
|
||||||
|
|||||||
@@ -150,22 +150,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
"/open_settings_window",
|
"/open_settings_window",
|
||||||
post(handlers::folders::open_settings_window),
|
post(handlers::folders::open_settings_window),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/open_commit_window",
|
|
||||||
post(handlers::folders::open_commit_window),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/open_merge_window",
|
|
||||||
post(handlers::folders::open_merge_window),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/open_stash_window",
|
|
||||||
post(handlers::folders::open_stash_window),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/open_push_window",
|
|
||||||
post(handlers::folders::open_push_window),
|
|
||||||
)
|
|
||||||
// ─── Git (pure) ───
|
// ─── Git (pure) ───
|
||||||
.route("/git_status", post(handlers::git::git_status))
|
.route("/git_status", post(handlers::git::git_status))
|
||||||
.route("/git_init", post(handlers::git::git_init))
|
.route("/git_init", post(handlers::git::git_init))
|
||||||
@@ -507,15 +491,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
"/experts_open_central_dir",
|
"/experts_open_central_dir",
|
||||||
post(handlers::experts::experts_open_central_dir),
|
post(handlers::experts::experts_open_central_dir),
|
||||||
)
|
)
|
||||||
// ─── Project boot ───
|
|
||||||
.route(
|
|
||||||
"/detect_package_manager",
|
|
||||||
post(handlers::project_boot::detect_package_manager),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/create_shadcn_project",
|
|
||||||
post(handlers::project_boot::create_shadcn_project),
|
|
||||||
)
|
|
||||||
// ─── Web Server ───
|
// ─── Web Server ───
|
||||||
.route(
|
.route(
|
||||||
"/get_web_server_status",
|
"/get_web_server_status",
|
||||||
@@ -533,87 +508,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
"/check_app_update",
|
"/check_app_update",
|
||||||
post(handlers::web_server::check_app_update),
|
post(handlers::web_server::check_app_update),
|
||||||
)
|
)
|
||||||
// ─── Chat Channels ───
|
|
||||||
.route(
|
|
||||||
"/list_chat_channels",
|
|
||||||
post(handlers::chat_channel::list_chat_channels),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/create_chat_channel",
|
|
||||||
post(handlers::chat_channel::create_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/update_chat_channel",
|
|
||||||
post(handlers::chat_channel::update_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/delete_chat_channel",
|
|
||||||
post(handlers::chat_channel::delete_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/save_chat_channel_token",
|
|
||||||
post(handlers::chat_channel::save_chat_channel_token),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/get_chat_channel_has_token",
|
|
||||||
post(handlers::chat_channel::get_chat_channel_has_token),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/delete_chat_channel_token",
|
|
||||||
post(handlers::chat_channel::delete_chat_channel_token),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/connect_chat_channel",
|
|
||||||
post(handlers::chat_channel::connect_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/disconnect_chat_channel",
|
|
||||||
post(handlers::chat_channel::disconnect_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/test_chat_channel",
|
|
||||||
post(handlers::chat_channel::test_chat_channel),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/get_chat_channel_status",
|
|
||||||
post(handlers::chat_channel::get_chat_channel_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/list_chat_channel_messages",
|
|
||||||
post(handlers::chat_channel::list_chat_channel_messages),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/get_chat_command_prefix",
|
|
||||||
post(handlers::chat_channel::get_chat_command_prefix),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/set_chat_command_prefix",
|
|
||||||
post(handlers::chat_channel::set_chat_command_prefix),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/get_chat_event_filter",
|
|
||||||
post(handlers::chat_channel::get_chat_event_filter),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/set_chat_event_filter",
|
|
||||||
post(handlers::chat_channel::set_chat_event_filter),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/get_chat_message_language",
|
|
||||||
post(handlers::chat_channel::get_chat_message_language),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/set_chat_message_language",
|
|
||||||
post(handlers::chat_channel::set_chat_message_language),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/weixin_get_qrcode",
|
|
||||||
post(handlers::chat_channel::weixin_get_qrcode),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/weixin_check_qrcode",
|
|
||||||
post(handlers::chat_channel::weixin_check_qrcode),
|
|
||||||
)
|
|
||||||
// ─── Model Providers ───
|
// ─── Model Providers ───
|
||||||
.route(
|
.route(
|
||||||
"/list_model_providers",
|
"/list_model_providers",
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
const getCurrentWindow = async () => {
|
|
||||||
const m = await import("@tauri-apps/api/window")
|
|
||||||
return m.getCurrentWindow()
|
|
||||||
}
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { CommitWorkspace } from "@/components/layout/commit-dialog"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
|
||||||
import { getFolder } from "@/lib/api"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
|
||||||
import type { FolderDetail } from "@/lib/types"
|
|
||||||
import { GitCredentialProvider } from "@/contexts/git-credential-context"
|
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
|
||||||
|
|
||||||
interface FolderLoadState {
|
|
||||||
loadedId: number | null
|
|
||||||
folder: FolderDetail | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitPageInner() {
|
|
||||||
const t = useTranslations("CommitPage")
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [state, setState] = useState<FolderLoadState>({
|
|
||||||
loadedId: null,
|
|
||||||
folder: null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
|
||||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
|
||||||
const hasValidFolderId = normalizedFolderId > 0
|
|
||||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
|
||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
|
||||||
|
|
||||||
const closeWindow = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const win = await getCurrentWindow()
|
|
||||||
await win.close()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[CommitPage] failed to close window:", err)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasValidFolderId) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
getFolder(normalizedFolderId)
|
|
||||||
.then((detail) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: detail,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: null,
|
|
||||||
error: toErrorMessage(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [hasValidFolderId, normalizedFolderId])
|
|
||||||
|
|
||||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${pageTitle} - codeg`
|
|
||||||
}, [pageTitle])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GitCredentialProvider>
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
||||||
<AppTitleBar
|
|
||||||
center={
|
|
||||||
<div className="text-sm font-semibold tracking-tight">
|
|
||||||
{t("title")}
|
|
||||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex-1 min-h-0 p-3">
|
|
||||||
{!hasValidFolderId ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{t("invalidFolderId")}
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("loadingRepo")}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : folder ? (
|
|
||||||
<CommitWorkspace
|
|
||||||
folderPath={folder.path}
|
|
||||||
folderId={folder.id}
|
|
||||||
onCommitted={closeWindow}
|
|
||||||
onCancel={closeWindow}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AppToaster
|
|
||||||
position="bottom-right"
|
|
||||||
duration={TOAST_DURATION_MS}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</GitCredentialProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CommitPage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<CommitPageInner />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, 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)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = "Login - codeg"
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Desktop users skip login entirely
|
|
||||||
if (isDesktop()) {
|
|
||||||
router.replace("/workspace")
|
|
||||||
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("/workspace")
|
|
||||||
} 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-base md: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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
const getCurrentWindow = async () => {
|
|
||||||
const m = await import("@tauri-apps/api/window")
|
|
||||||
return m.getCurrentWindow()
|
|
||||||
}
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { MergeWorkspace } from "@/components/merge/merge-workspace"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
|
||||||
import { getFolder } from "@/lib/api"
|
|
||||||
import type { FolderDetail } from "@/lib/types"
|
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
|
||||||
|
|
||||||
interface FolderLoadState {
|
|
||||||
loadedId: number | null
|
|
||||||
folder: FolderDetail | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function MergePageInner() {
|
|
||||||
const t = useTranslations("MergePage")
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [state, setState] = useState<FolderLoadState>({
|
|
||||||
loadedId: null,
|
|
||||||
folder: null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
|
||||||
const operation = searchParams.get("operation") ?? "merge"
|
|
||||||
const upstreamCommit = searchParams.get("upstreamCommit") ?? undefined
|
|
||||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
|
||||||
const hasValidFolderId = normalizedFolderId > 0
|
|
||||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
|
||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
|
||||||
|
|
||||||
const closeWindow = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const win = await getCurrentWindow()
|
|
||||||
await win.close()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MergePage] failed to close window:", err)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasValidFolderId) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
getFolder(normalizedFolderId)
|
|
||||||
.then((detail) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: detail,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: null,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [hasValidFolderId, normalizedFolderId])
|
|
||||||
|
|
||||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${pageTitle} - codeg`
|
|
||||||
}, [pageTitle])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
||||||
<AppTitleBar
|
|
||||||
center={
|
|
||||||
<div className="text-sm font-semibold tracking-tight">
|
|
||||||
{t("title")}
|
|
||||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex-1 min-h-0 p-3">
|
|
||||||
{!hasValidFolderId ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{t("invalidFolderId")}
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("loadingRepo")}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : folder ? (
|
|
||||||
<MergeWorkspace
|
|
||||||
folderId={normalizedFolderId}
|
|
||||||
folderPath={folder.path}
|
|
||||||
operation={operation}
|
|
||||||
upstreamCommit={upstreamCommit}
|
|
||||||
onCompleted={closeWindow}
|
|
||||||
onAborted={closeWindow}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AppToaster
|
|
||||||
position="bottom-right"
|
|
||||||
duration={TOAST_DURATION_MS}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MergePage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<MergePageInner />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense, useEffect } from "react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
|
||||||
import { ProjectBootWorkspace } from "@/components/project-boot/project-boot-workspace"
|
|
||||||
|
|
||||||
function ProjectBootPageInner() {
|
|
||||||
const t = useTranslations("ProjectBoot")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${t("title")} - codeg`
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
||||||
<AppTitleBar
|
|
||||||
center={
|
|
||||||
<div className="text-sm font-semibold tracking-tight">
|
|
||||||
{t("title")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="min-h-0 flex-1">
|
|
||||||
<ProjectBootWorkspace />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AppToaster position="bottom-right" duration={6000} closeButton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectBootPage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<ProjectBootPageInner />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import { GitCredentialProvider } from "@/contexts/git-credential-context"
|
|
||||||
|
|
||||||
export default function PushLayout({ children }: { children: ReactNode }) {
|
|
||||||
return <GitCredentialProvider>{children}</GitCredentialProvider>
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
const getCurrentWindow = async () => {
|
|
||||||
const m = await import("@tauri-apps/api/window")
|
|
||||||
return m.getCurrentWindow()
|
|
||||||
}
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { PushWorkspace } from "@/components/layout/push-workspace"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
|
||||||
import { getFolder } from "@/lib/api"
|
|
||||||
import type { FolderDetail } from "@/lib/types"
|
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
|
||||||
|
|
||||||
interface FolderLoadState {
|
|
||||||
loadedId: number | null
|
|
||||||
folder: FolderDetail | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function PushPageInner() {
|
|
||||||
const t = useTranslations("Folder.pushWindow")
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [state, setState] = useState<FolderLoadState>({
|
|
||||||
loadedId: null,
|
|
||||||
folder: null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const closeWindow = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const win = await getCurrentWindow()
|
|
||||||
await win.close()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[PushPage] failed to close window:", err)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
|
||||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
|
||||||
const hasValidFolderId = normalizedFolderId > 0
|
|
||||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
|
||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasValidFolderId) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
getFolder(normalizedFolderId)
|
|
||||||
.then((detail) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: detail,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: null,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [hasValidFolderId, normalizedFolderId])
|
|
||||||
|
|
||||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${pageTitle} - codeg`
|
|
||||||
}, [pageTitle])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
||||||
<AppTitleBar
|
|
||||||
center={
|
|
||||||
<div className="text-sm font-semibold tracking-tight">
|
|
||||||
{t("title")}
|
|
||||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="min-h-0 flex-1">
|
|
||||||
{!hasValidFolderId ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
Invalid folder ID
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : folder ? (
|
|
||||||
<PushWorkspace
|
|
||||||
folderPath={folder.path}
|
|
||||||
folderName={folder.name}
|
|
||||||
folderId={folder.id}
|
|
||||||
onPushed={closeWindow}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AppToaster
|
|
||||||
position="bottom-right"
|
|
||||||
duration={TOAST_DURATION_MS}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PushPage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<PushPageInner />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChatChannelSettings } from "@/components/settings/chat-channel-settings"
|
|
||||||
|
|
||||||
export default function SettingsChatChannelsPage() {
|
|
||||||
return <ChatChannelSettings />
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
|
||||||
import { getFolder } from "@/lib/api"
|
|
||||||
import type { FolderDetail } from "@/lib/types"
|
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 6000
|
|
||||||
|
|
||||||
interface FolderLoadState {
|
|
||||||
loadedId: number | null
|
|
||||||
folder: FolderDetail | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function StashPageInner() {
|
|
||||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [state, setState] = useState<FolderLoadState>({
|
|
||||||
loadedId: null,
|
|
||||||
folder: null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
|
||||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
|
||||||
const hasValidFolderId = normalizedFolderId > 0
|
|
||||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
|
||||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
|
||||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasValidFolderId) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
getFolder(normalizedFolderId)
|
|
||||||
.then((detail) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: detail,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setState({
|
|
||||||
loadedId: normalizedFolderId,
|
|
||||||
folder: null,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [hasValidFolderId, normalizedFolderId])
|
|
||||||
|
|
||||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${pageTitle} - codeg`
|
|
||||||
}, [pageTitle])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
||||||
<AppTitleBar
|
|
||||||
center={
|
|
||||||
<div className="text-sm font-semibold tracking-tight">
|
|
||||||
{t("title")}
|
|
||||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="min-h-0 flex-1">
|
|
||||||
{!hasValidFolderId ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
Invalid folder ID
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : folder ? (
|
|
||||||
<StashWorkspace folderPath={folder.path} />
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<AppToaster
|
|
||||||
position="bottom-right"
|
|
||||||
duration={TOAST_DURATION_MS}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StashPage() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<StashPageInner />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Palette,
|
Palette,
|
||||||
Plus,
|
Plus,
|
||||||
Rocket,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||||
@@ -34,7 +33,6 @@ import { useTaskContext } from "@/contexts/task-context"
|
|||||||
import { useZoomLevel } from "@/hooks/use-appearance"
|
import { useZoomLevel } from "@/hooks/use-appearance"
|
||||||
import {
|
import {
|
||||||
importLocalConversations,
|
importLocalConversations,
|
||||||
openProjectBootWindow,
|
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
updateConversationStatus,
|
updateConversationStatus,
|
||||||
updateFolderColor,
|
updateFolderColor,
|
||||||
@@ -964,14 +962,6 @@ export function SidebarConversationList({
|
|||||||
[openFolder]
|
[openFolder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleProjectBoot = useCallback(() => {
|
|
||||||
openProjectBootWindow().catch((err) => {
|
|
||||||
console.error(
|
|
||||||
"[SidebarConversationList] failed to open project boot:",
|
|
||||||
err
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const showEmptyWorkspaceActions =
|
const showEmptyWorkspaceActions =
|
||||||
folders.length === 0 && conversations.length === 0
|
folders.length === 0 && conversations.length === 0
|
||||||
@@ -1016,15 +1006,6 @@ export function SidebarConversationList({
|
|||||||
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{tFolderDropdown("cloneRepository")}
|
{tFolderDropdown("cloneRepository")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full max-w-[14rem] justify-start"
|
|
||||||
onClick={handleProjectBoot}
|
|
||||||
>
|
|
||||||
<Rocket className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{tFolderDropdown("projectBoot")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -1122,10 +1103,6 @@ export function SidebarConversationList({
|
|||||||
<GitBranch className="h-4 w-4" />
|
<GitBranch className="h-4 w-4" />
|
||||||
{tFolderDropdown("cloneRepository")}
|
{tFolderDropdown("cloneRepository")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={handleProjectBoot}>
|
|
||||||
<Rocket className="h-4 w-4" />
|
|
||||||
{tFolderDropdown("projectBoot")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
gitStatus,
|
gitStatus,
|
||||||
readFileForEdit,
|
readFileForEdit,
|
||||||
readFilePreview,
|
readFilePreview,
|
||||||
openCommitWindow,
|
|
||||||
renameFileTreeEntry,
|
renameFileTreeEntry,
|
||||||
saveFileCopy,
|
saveFileCopy,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
@@ -444,7 +443,6 @@ interface RenderNodeProps {
|
|||||||
onOpenFilePreview: (path: string) => void
|
onOpenFilePreview: (path: string) => void
|
||||||
onOpenFileDiff: (path: string) => void
|
onOpenFileDiff: (path: string) => void
|
||||||
onOpenDirDiff: (path: string) => void
|
onOpenDirDiff: (path: string) => void
|
||||||
onOpenCommitWindow: () => void
|
|
||||||
onRequestCompareWithBranch: (target: FileActionTarget) => void
|
onRequestCompareWithBranch: (target: FileActionTarget) => void
|
||||||
onRequestRollback: (target: FileActionTarget) => void
|
onRequestRollback: (target: FileActionTarget) => void
|
||||||
onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise<void>
|
onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise<void>
|
||||||
@@ -470,7 +468,6 @@ function RenderNode({
|
|||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
onOpenFileDiff,
|
onOpenFileDiff,
|
||||||
onOpenDirDiff,
|
onOpenDirDiff,
|
||||||
onOpenCommitWindow,
|
|
||||||
onRequestCompareWithBranch,
|
onRequestCompareWithBranch,
|
||||||
onRequestRollback,
|
onRequestRollback,
|
||||||
onOpenDirInTerminal,
|
onOpenDirInTerminal,
|
||||||
@@ -563,12 +560,6 @@ function RenderNode({
|
|||||||
{t("git")}
|
{t("git")}
|
||||||
</ContextMenuSubTrigger>
|
</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => onOpenCommitWindow()}
|
|
||||||
disabled={isGitMenuDisabled}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => onRequestAddToVcs(node)}
|
onSelect={() => onRequestAddToVcs(node)}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -690,7 +681,6 @@ function RenderNode({
|
|||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
onOpenFileDiff={onOpenFileDiff}
|
onOpenFileDiff={onOpenFileDiff}
|
||||||
onOpenDirDiff={onOpenDirDiff}
|
onOpenDirDiff={onOpenDirDiff}
|
||||||
onOpenCommitWindow={onOpenCommitWindow}
|
|
||||||
onRequestCompareWithBranch={onRequestCompareWithBranch}
|
onRequestCompareWithBranch={onRequestCompareWithBranch}
|
||||||
onRequestRollback={onRequestRollback}
|
onRequestRollback={onRequestRollback}
|
||||||
onOpenDirInTerminal={onOpenDirInTerminal}
|
onOpenDirInTerminal={onOpenDirInTerminal}
|
||||||
@@ -729,12 +719,6 @@ function RenderNode({
|
|||||||
{t("git")}
|
{t("git")}
|
||||||
</ContextMenuSubTrigger>
|
</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => onOpenCommitWindow()}
|
|
||||||
disabled={isGitMenuDisabled}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => onRequestAddToVcs(node)}
|
onSelect={() => onRequestAddToVcs(node)}
|
||||||
disabled={isGitMenuDisabled}
|
disabled={isGitMenuDisabled}
|
||||||
@@ -1342,16 +1326,6 @@ export function FileTreeTab() {
|
|||||||
[createTerminalInDirectory, t]
|
[createTerminalInDirectory, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleOpenCommitWindow = useCallback(() => {
|
|
||||||
if (!folder) return
|
|
||||||
openCommitWindow(folder.id).catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
toast.error(t("toasts.openCommitWindowFailed"), {
|
|
||||||
description: message,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [folder, t])
|
|
||||||
|
|
||||||
const handleRequestCreate = useCallback(
|
const handleRequestCreate = useCallback(
|
||||||
(parentPath: string, kind: "file" | "dir") => {
|
(parentPath: string, kind: "file" | "dir") => {
|
||||||
setCreateParentPath(parentPath)
|
setCreateParentPath(parentPath)
|
||||||
@@ -2182,7 +2156,6 @@ export function FileTreeTab() {
|
|||||||
mode: "overview",
|
mode: "overview",
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onOpenCommitWindow={handleOpenCommitWindow}
|
|
||||||
onRequestCompareWithBranch={
|
onRequestCompareWithBranch={
|
||||||
handleRequestCompareWithBranch
|
handleRequestCompareWithBranch
|
||||||
}
|
}
|
||||||
@@ -2218,12 +2191,6 @@ export function FileTreeTab() {
|
|||||||
{t("git")}
|
{t("git")}
|
||||||
</ContextMenuSubTrigger>
|
</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => handleOpenCommitWindow()}
|
|
||||||
disabled={!gitEnabled}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => void handleAddToVcs(rootTarget)}
|
onSelect={() => void handleAddToVcs(rootTarget)}
|
||||||
disabled={!gitEnabled}
|
disabled={!gitEnabled}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
gitAddFiles,
|
gitAddFiles,
|
||||||
gitRollbackFile,
|
gitRollbackFile,
|
||||||
gitStatus,
|
gitStatus,
|
||||||
openCommitWindow,
|
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { joinFsPath } from "@/lib/path-utils"
|
import { joinFsPath } from "@/lib/path-utils"
|
||||||
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
||||||
@@ -561,15 +560,6 @@ export function GitChangesTab() {
|
|||||||
setExpandedUntrackedPaths(new Set())
|
setExpandedUntrackedPaths(new Set())
|
||||||
}, [allUntrackedDirectoryPaths, untrackedCanExpand])
|
}, [allUntrackedDirectoryPaths, untrackedCanExpand])
|
||||||
|
|
||||||
const handleOpenCommitWindow = useCallback(() => {
|
|
||||||
if (!folder) return
|
|
||||||
openCommitWindow(folder.id).catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
toast.error(t("toasts.openCommitWindowFailed"), {
|
|
||||||
description: message,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [folder, t])
|
|
||||||
const handleAttachToSession = useCallback(
|
const handleAttachToSession = useCallback(
|
||||||
(relativePath: string) => {
|
(relativePath: string) => {
|
||||||
if (!activeSessionTabId || !folder?.path) return
|
if (!activeSessionTabId || !folder?.path) return
|
||||||
@@ -845,13 +835,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFolder>
|
</FileTreeFolder>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
||||||
@@ -928,13 +911,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFile>
|
</FileTreeFile>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
disabled={!canOpenCurrentFile}
|
disabled={!canOpenCurrentFile}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -983,7 +959,6 @@ export function GitChangesTab() {
|
|||||||
[
|
[
|
||||||
canAttachToSession,
|
canAttachToSession,
|
||||||
handleAttachToSession,
|
handleAttachToSession,
|
||||||
handleOpenCommitWindow,
|
|
||||||
handleRequestDelete,
|
handleRequestDelete,
|
||||||
handleRequestRollback,
|
handleRequestRollback,
|
||||||
openFilePreview,
|
openFilePreview,
|
||||||
@@ -1017,13 +992,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFolder>
|
</FileTreeFolder>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
||||||
@@ -1096,13 +1064,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFile>
|
</FileTreeFile>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void openFilePreview(file.path)
|
void openFilePreview(file.path)
|
||||||
@@ -1155,7 +1116,6 @@ export function GitChangesTab() {
|
|||||||
[
|
[
|
||||||
canAttachToSession,
|
canAttachToSession,
|
||||||
handleAttachToSession,
|
handleAttachToSession,
|
||||||
handleOpenCommitWindow,
|
|
||||||
handleAddToVcs,
|
handleAddToVcs,
|
||||||
handleRequestDelete,
|
handleRequestDelete,
|
||||||
handleRequestRollback,
|
handleRequestRollback,
|
||||||
@@ -1265,13 +1225,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFolder>
|
</FileTreeFolder>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void openWorkingTreeDiff(".", {
|
void openWorkingTreeDiff(".", {
|
||||||
@@ -1374,13 +1327,6 @@ export function GitChangesTab() {
|
|||||||
</FileTreeFolder>
|
</FileTreeFolder>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
handleOpenCommitWindow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.commitCode")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void openWorkingTreeDiff(".", {
|
void openWorkingTreeDiff(".", {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
GitCompare,
|
GitCompare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Upload,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Commit,
|
Commit,
|
||||||
@@ -88,7 +87,6 @@ import {
|
|||||||
gitLog,
|
gitLog,
|
||||||
gitNewBranch,
|
gitNewBranch,
|
||||||
gitReset,
|
gitReset,
|
||||||
openPushWindow,
|
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import type {
|
import type {
|
||||||
GitBranchList,
|
GitBranchList,
|
||||||
@@ -1385,20 +1383,6 @@ export function GitLogTab() {
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
{tCommon("refresh")}
|
{tCommon("refresh")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
if (!folder) return
|
|
||||||
openPushWindow(folder.id).catch((err) => {
|
|
||||||
const msg = toErrorMessage(err)
|
|
||||||
toast.error(t("toasts.openPushWindowFailed"), {
|
|
||||||
description: msg,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload className="size-3.5" />
|
|
||||||
{tCommon("push")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
@@ -1415,20 +1399,6 @@ export function GitLogTab() {
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
{tCommon("refresh")}
|
{tCommon("refresh")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
if (!folder) return
|
|
||||||
openPushWindow(folder.id).catch((err) => {
|
|
||||||
const msg = toErrorMessage(err)
|
|
||||||
toast.error(t("toasts.openPushWindowFailed"), {
|
|
||||||
description: msg,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload className="size-3.5" />
|
|
||||||
{tCommon("push")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
ArchiveRestore,
|
|
||||||
Archive,
|
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -11,7 +9,6 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitBranchPlus,
|
GitBranchPlus,
|
||||||
GitCommitHorizontal,
|
|
||||||
GitFork,
|
GitFork,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
GitPullRequestArrow,
|
GitPullRequestArrow,
|
||||||
@@ -19,7 +16,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -73,16 +69,11 @@ import {
|
|||||||
gitRebase,
|
gitRebase,
|
||||||
gitDeleteBranch,
|
gitDeleteBranch,
|
||||||
gitDeleteRemoteBranch,
|
gitDeleteRemoteBranch,
|
||||||
openCommitWindow,
|
|
||||||
openPushWindow,
|
|
||||||
openStashWindow,
|
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { openFileDialog, subscribe } from "@/lib/platform"
|
import { openFileDialog, subscribe } from "@/lib/platform"
|
||||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||||
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
|
||||||
import { StashDialog } from "@/components/layout/stash-dialog"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
import type { GitBranchList } from "@/lib/types"
|
||||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||||
import { useTaskContext } from "@/contexts/task-context"
|
import { useTaskContext } from "@/contexts/task-context"
|
||||||
@@ -146,8 +137,6 @@ export function BranchDropdown() {
|
|||||||
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
||||||
const [worktreePath, setWorktreePath] = useState("")
|
const [worktreePath, setWorktreePath] = useState("")
|
||||||
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
||||||
const [stashDialogOpen, setStashDialogOpen] = useState(false)
|
|
||||||
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
|
|
||||||
const taskSeq = useRef(0)
|
const taskSeq = useRef(0)
|
||||||
|
|
||||||
const worktreeBranchSet = useMemo(
|
const worktreeBranchSet = useMemo(
|
||||||
@@ -361,7 +350,6 @@ export function BranchDropdown() {
|
|||||||
() => gitMerge(folderPath, branchName),
|
() => gitMerge(folderPath, branchName),
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result.conflict?.has_conflicts) {
|
if (result.conflict?.has_conflicts) {
|
||||||
setConflictInfo(result.conflict)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (result.merged_commits === 0) {
|
if (result.merged_commits === 0) {
|
||||||
@@ -377,7 +365,6 @@ export function BranchDropdown() {
|
|||||||
() => gitRebase(folderPath, branchName),
|
() => gitRebase(folderPath, branchName),
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result.conflict?.has_conflicts) {
|
if (result.conflict?.has_conflicts) {
|
||||||
setConflictInfo(result.conflict)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -618,7 +605,6 @@ export function BranchDropdown() {
|
|||||||
}),
|
}),
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result.conflict?.has_conflicts) {
|
if (result.conflict?.has_conflicts) {
|
||||||
setConflictInfo(result.conflict)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (result.updated_files === 0) {
|
if (result.updated_files === 0) {
|
||||||
@@ -649,41 +635,6 @@ export function BranchDropdown() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={loading}
|
|
||||||
onSelect={() => {
|
|
||||||
if (!folderId) return
|
|
||||||
setDropdownOpen(false)
|
|
||||||
openCommitWindow(folderId).catch((err) => {
|
|
||||||
const title = t("toasts.openCommitWindowFailed")
|
|
||||||
const msg = toErrorMessage(err)
|
|
||||||
pushAlert("error", title, msg)
|
|
||||||
toast.error(title, { description: msg })
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
|
||||||
{t("openCommitWindow")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={loading}
|
|
||||||
onSelect={() => {
|
|
||||||
if (!folderId) return
|
|
||||||
setDropdownOpen(false)
|
|
||||||
openPushWindow(folderId).catch((err) => {
|
|
||||||
const title = t("toasts.openPushWindowFailed")
|
|
||||||
const msg = toErrorMessage(err)
|
|
||||||
pushAlert("error", title, msg)
|
|
||||||
toast.error(title, { description: msg })
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
|
||||||
{t("pushCode")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -704,32 +655,6 @@ export function BranchDropdown() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={loading}
|
|
||||||
onSelect={() => {
|
|
||||||
setDropdownOpen(false)
|
|
||||||
setStashDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Archive className="h-3.5 w-3.5" />
|
|
||||||
{t("stashChanges")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={loading}
|
|
||||||
onSelect={() => {
|
|
||||||
if (!folderId) return
|
|
||||||
openStashWindow(folderId).catch((err) => {
|
|
||||||
const msg = toErrorMessage(err)
|
|
||||||
pushAlert("error", t("stashPop"), msg)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArchiveRestore className="h-3.5 w-3.5" />
|
|
||||||
{t("stashPop")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -937,21 +862,6 @@ export function BranchDropdown() {
|
|||||||
folderPath={folderPath}
|
folderPath={folderPath}
|
||||||
onSaved={() => loadAllBranches()}
|
onSaved={() => loadAllBranches()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConflictDialog
|
|
||||||
conflictInfo={conflictInfo}
|
|
||||||
folderId={folderId}
|
|
||||||
folderPath={folderPath}
|
|
||||||
onClose={() => setConflictInfo(null)}
|
|
||||||
onResolved={refresh}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StashDialog
|
|
||||||
open={stashDialogOpen}
|
|
||||||
folderPath={folderPath}
|
|
||||||
onClose={() => setStashDialogOpen(false)}
|
|
||||||
onStashed={refresh}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { subscribe } from "@/lib/platform"
|
|
||||||
import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import {
|
|
||||||
gitListConflicts,
|
|
||||||
gitAbortOperation,
|
|
||||||
gitContinueOperation,
|
|
||||||
openMergeWindow,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import type { GitConflictInfo } from "@/lib/types"
|
|
||||||
|
|
||||||
interface ConflictDialogProps {
|
|
||||||
conflictInfo: GitConflictInfo | null
|
|
||||||
folderId: number
|
|
||||||
folderPath: string
|
|
||||||
onClose: () => void
|
|
||||||
onResolved: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConflictDialog({
|
|
||||||
conflictInfo,
|
|
||||||
folderId,
|
|
||||||
folderPath,
|
|
||||||
onClose,
|
|
||||||
onResolved,
|
|
||||||
}: ConflictDialogProps) {
|
|
||||||
const t = useTranslations("Folder.branchDropdown.conflict")
|
|
||||||
const [conflictedFiles, setConflictedFiles] = useState<string[]>([])
|
|
||||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
|
||||||
const [aborting, setAborting] = useState(false)
|
|
||||||
const [completing, setCompleting] = useState(false)
|
|
||||||
const [done, setDone] = useState(false)
|
|
||||||
|
|
||||||
const open = conflictInfo !== null
|
|
||||||
const operation = conflictInfo?.operation ?? "merge"
|
|
||||||
|
|
||||||
// Initialize conflict files from conflictInfo
|
|
||||||
useEffect(() => {
|
|
||||||
if (conflictInfo) {
|
|
||||||
setConflictedFiles(conflictInfo.conflicted_files)
|
|
||||||
setResolvedFiles(new Set())
|
|
||||||
setDone(false)
|
|
||||||
}
|
|
||||||
}, [conflictInfo])
|
|
||||||
|
|
||||||
// Refresh conflict list to detect resolved files
|
|
||||||
const refreshConflicts = useCallback(async () => {
|
|
||||||
if (!folderPath || !open) return
|
|
||||||
try {
|
|
||||||
const remaining = await gitListConflicts(folderPath)
|
|
||||||
const nowResolved = new Set(
|
|
||||||
conflictedFiles.filter((f) => !remaining.includes(f))
|
|
||||||
)
|
|
||||||
setResolvedFiles(nowResolved)
|
|
||||||
} catch {
|
|
||||||
// ignore refresh errors
|
|
||||||
}
|
|
||||||
}, [folderPath, open, conflictedFiles])
|
|
||||||
|
|
||||||
// Listen for merge events from the merge window
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
let unlistenResolved: (() => void) | null = null
|
|
||||||
let unlistenCompleted: (() => void) | null = null
|
|
||||||
let unlistenAborted: (() => void) | null = null
|
|
||||||
|
|
||||||
subscribe<{ folder_id: number; file: string }>(
|
|
||||||
"folder://merge-conflict-resolved",
|
|
||||||
(payload) => {
|
|
||||||
if (payload.folder_id !== folderId) return
|
|
||||||
setResolvedFiles((prev) => new Set([...prev, payload.file]))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((fn) => {
|
|
||||||
unlistenResolved = fn
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => {
|
|
||||||
if (payload.folder_id !== folderId) return
|
|
||||||
setDone(true)
|
|
||||||
onResolved()
|
|
||||||
onClose()
|
|
||||||
})
|
|
||||||
.then((fn) => {
|
|
||||||
unlistenCompleted = fn
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
// Merge was aborted (user clicked abort in merge window, or window closed)
|
|
||||||
// Reset resolved state since abort reverts all changes
|
|
||||||
subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => {
|
|
||||||
if (payload.folder_id !== folderId) return
|
|
||||||
setDone(true)
|
|
||||||
setResolvedFiles(new Set())
|
|
||||||
onClose()
|
|
||||||
})
|
|
||||||
.then((fn) => {
|
|
||||||
unlistenAborted = fn
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlistenResolved?.()
|
|
||||||
unlistenCompleted?.()
|
|
||||||
unlistenAborted?.()
|
|
||||||
}
|
|
||||||
}, [open, folderId, onResolved, onClose])
|
|
||||||
|
|
||||||
// Periodically refresh conflict status (skip for pull — merge is aborted
|
|
||||||
// until the merge tool re-starts it, so git index has no conflicts yet)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || operation === "pull") return
|
|
||||||
const interval = setInterval(refreshConflicts, 3000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [open, operation, refreshConflicts])
|
|
||||||
|
|
||||||
const allResolved =
|
|
||||||
conflictedFiles.length > 0 &&
|
|
||||||
conflictedFiles.every((f) => resolvedFiles.has(f))
|
|
||||||
|
|
||||||
async function handleOpenMergeTool() {
|
|
||||||
try {
|
|
||||||
await openMergeWindow(folderId, operation, conflictInfo?.upstream_commit)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(String(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAbort() {
|
|
||||||
// For pull operations, the merge was already aborted during conflict
|
|
||||||
// detection, so there's nothing to abort — just close the dialog.
|
|
||||||
if (operation === "pull") {
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAborting(true)
|
|
||||||
try {
|
|
||||||
await gitAbortOperation(folderPath, operation)
|
|
||||||
toast.success(t("abortSuccess"))
|
|
||||||
onClose()
|
|
||||||
onResolved()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(String(err))
|
|
||||||
} finally {
|
|
||||||
setAborting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleComplete() {
|
|
||||||
if (done) return
|
|
||||||
setCompleting(true)
|
|
||||||
try {
|
|
||||||
await gitContinueOperation(folderPath, operation)
|
|
||||||
toast.success(t("completeSuccess"))
|
|
||||||
onResolved()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(String(err))
|
|
||||||
} finally {
|
|
||||||
setCompleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
||||||
{t("title")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>{t("description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="max-h-60">
|
|
||||||
<div className="space-y-1 pr-3">
|
|
||||||
{conflictedFiles.map((file) => {
|
|
||||||
const isResolved = resolvedFiles.has(file)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file}
|
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
{isResolved ? (
|
|
||||||
<Check className="h-3.5 w-3.5 shrink-0 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<FileWarning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isResolved
|
|
||||||
? "text-muted-foreground line-through"
|
|
||||||
: "text-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{file}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAbort}
|
|
||||||
disabled={aborting || completing}
|
|
||||||
>
|
|
||||||
{aborting && (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
)}
|
|
||||||
{t("abort")}
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleOpenMergeTool}
|
|
||||||
disabled={aborting || completing}
|
|
||||||
>
|
|
||||||
{t("openMergeTool")}
|
|
||||||
</Button>
|
|
||||||
{allResolved && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleComplete}
|
|
||||||
disabled={completing || aborting || done}
|
|
||||||
>
|
|
||||||
{completing && (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
)}
|
|
||||||
{t("completeMerge")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { FolderOpen, FolderPlus, GitBranch, Rocket } from "lucide-react"
|
import { FolderOpen, FolderPlus, GitBranch } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { openProjectBootWindow } from "@/lib/api"
|
|
||||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||||
import { CloneDialog } from "@/components/layout/clone-dialog"
|
import { CloneDialog } from "@/components/layout/clone-dialog"
|
||||||
@@ -29,7 +28,9 @@ export function NewFolderDropdown() {
|
|||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
if (selected) {
|
if (selected) {
|
||||||
await openFolder(Array.isArray(selected) ? selected[0] : selected)
|
openFolder(selected as string).catch((err) => {
|
||||||
|
console.error("[NewFolderDropdown] failed to open folder:", err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setBrowserOpen(true)
|
setBrowserOpen(true)
|
||||||
@@ -40,13 +41,8 @@ export function NewFolderDropdown() {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
variant="ghost"
|
<FolderPlus className="h-4 w-4" />
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 hover:text-foreground/80"
|
|
||||||
title={t("openFolder")}
|
|
||||||
>
|
|
||||||
<FolderPlus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="min-w-56" align="start">
|
<DropdownMenuContent className="min-w-56" align="start">
|
||||||
@@ -58,10 +54,6 @@ export function NewFolderDropdown() {
|
|||||||
<GitBranch className="h-3.5 w-3.5 shrink-0" />
|
<GitBranch className="h-3.5 w-3.5 shrink-0" />
|
||||||
{t("cloneRepository")}
|
{t("cloneRepository")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => openProjectBootWindow()}>
|
|
||||||
<Rocket className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{t("projectBoot")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||||
|
|||||||
@@ -1,724 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactElement } from "react"
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
ChevronsDownUp,
|
|
||||||
ChevronsUpDown,
|
|
||||||
CloudOff,
|
|
||||||
GitBranch,
|
|
||||||
Loader2,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import {
|
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable"
|
|
||||||
import {
|
|
||||||
FileTree,
|
|
||||||
FileTreeFile,
|
|
||||||
FileTreeFolder,
|
|
||||||
} from "@/components/ai-elements/file-tree"
|
|
||||||
import {
|
|
||||||
Commit,
|
|
||||||
CommitContent,
|
|
||||||
CommitFileAdditions,
|
|
||||||
CommitFileChanges,
|
|
||||||
CommitFileDeletions,
|
|
||||||
CommitFileIcon,
|
|
||||||
CommitFileInfo,
|
|
||||||
CommitFilePath,
|
|
||||||
CommitFiles,
|
|
||||||
CommitFileStatus,
|
|
||||||
CommitHash,
|
|
||||||
CommitHeader,
|
|
||||||
CommitInfo,
|
|
||||||
CommitMessage,
|
|
||||||
CommitMetadata,
|
|
||||||
CommitTimestamp,
|
|
||||||
} from "@/components/ai-elements/commit"
|
|
||||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/api"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
|
||||||
import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
|
|
||||||
import {
|
|
||||||
useGitCredential,
|
|
||||||
type GitRemoteHint,
|
|
||||||
} from "@/contexts/git-credential-context"
|
|
||||||
|
|
||||||
// --- File tree types & builder (same as aux-panel-git-log-tab) ---
|
|
||||||
|
|
||||||
type CommitFileTreeDirNode = {
|
|
||||||
kind: "dir"
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
children: CommitFileTreeNode[]
|
|
||||||
fileCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommitFileTreeFileNode = {
|
|
||||||
kind: "file"
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
change: GitLogFileChange
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommitFileTreeNode = CommitFileTreeDirNode | CommitFileTreeFileNode
|
|
||||||
|
|
||||||
interface MutableCommitFileTreeDirNode {
|
|
||||||
kind: "dir"
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
children: Map<string, MutableCommitFileTreeDirNode | CommitFileTreeFileNode>
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePathSegments(path: string): string[] {
|
|
||||||
const normalized = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
|
||||||
if (!normalized) return []
|
|
||||||
return normalized.split("/").filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSortedTreeNodes(
|
|
||||||
dir: MutableCommitFileTreeDirNode
|
|
||||||
): CommitFileTreeNode[] {
|
|
||||||
return Array.from(dir.children.values())
|
|
||||||
.map<CommitFileTreeNode>((node) => {
|
|
||||||
if (node.kind === "file") return node
|
|
||||||
return {
|
|
||||||
kind: "dir" as const,
|
|
||||||
fileCount: 0,
|
|
||||||
name: node.name,
|
|
||||||
path: node.path,
|
|
||||||
children: toSortedTreeNodes(node),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function compressAndAnnotateDir(
|
|
||||||
node: CommitFileTreeDirNode
|
|
||||||
): CommitFileTreeDirNode {
|
|
||||||
let compressedChildren: CommitFileTreeNode[] = node.children.map((child) => {
|
|
||||||
if (child.kind === "file") return child
|
|
||||||
return compressAndAnnotateDir(child)
|
|
||||||
})
|
|
||||||
|
|
||||||
let fileCount = compressedChildren.reduce((count, child) => {
|
|
||||||
if (child.kind === "file") return count + 1
|
|
||||||
return count + child.fileCount
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
let nextNode: CommitFileTreeDirNode = {
|
|
||||||
...node,
|
|
||||||
children: compressedChildren,
|
|
||||||
fileCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
while (
|
|
||||||
nextNode.children.length === 1 &&
|
|
||||||
nextNode.children[0].kind === "dir"
|
|
||||||
) {
|
|
||||||
const onlyChild = nextNode.children[0]
|
|
||||||
nextNode = {
|
|
||||||
kind: "dir",
|
|
||||||
name: `${nextNode.name}/${onlyChild.name}`,
|
|
||||||
path: onlyChild.path,
|
|
||||||
children: onlyChild.children,
|
|
||||||
fileCount: onlyChild.fileCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compressedChildren = nextNode.children
|
|
||||||
fileCount = compressedChildren.reduce((count, child) => {
|
|
||||||
if (child.kind === "file") return count + 1
|
|
||||||
return count + child.fileCount
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...nextNode,
|
|
||||||
children: compressedChildren,
|
|
||||||
fileCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCommitFileTree(files: GitLogFileChange[]): CommitFileTreeNode[] {
|
|
||||||
const root: MutableCommitFileTreeDirNode = {
|
|
||||||
kind: "dir",
|
|
||||||
name: "",
|
|
||||||
path: "",
|
|
||||||
children: new Map(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const change of files) {
|
|
||||||
const segments = normalizePathSegments(change.path)
|
|
||||||
if (segments.length === 0) continue
|
|
||||||
|
|
||||||
let current = root
|
|
||||||
for (const [index, segment] of segments.entries()) {
|
|
||||||
const nodePath = segments.slice(0, index + 1).join("/")
|
|
||||||
const isLeaf = index === segments.length - 1
|
|
||||||
|
|
||||||
if (isLeaf) {
|
|
||||||
current.children.set(`file:${nodePath}`, {
|
|
||||||
kind: "file",
|
|
||||||
name: segment,
|
|
||||||
path: nodePath,
|
|
||||||
change,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirKey = `dir:${nodePath}`
|
|
||||||
const existing = current.children.get(dirKey)
|
|
||||||
if (existing && existing.kind === "dir") {
|
|
||||||
current = existing
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDir: MutableCommitFileTreeDirNode = {
|
|
||||||
kind: "dir",
|
|
||||||
name: segment,
|
|
||||||
path: nodePath,
|
|
||||||
children: new Map(),
|
|
||||||
}
|
|
||||||
current.children.set(dirKey, nextDir)
|
|
||||||
current = nextDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedNodes = toSortedTreeNodes(root)
|
|
||||||
return sortedNodes.map((node) => {
|
|
||||||
if (node.kind === "file") return node
|
|
||||||
return compressAndAnnotateDir(node)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectExpandedDirectoryPaths(
|
|
||||||
nodes: CommitFileTreeNode[],
|
|
||||||
expanded = new Set<string>()
|
|
||||||
): Set<string> {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.kind !== "dir") continue
|
|
||||||
expanded.add(node.path)
|
|
||||||
collectExpandedDirectoryPaths(node.children, expanded)
|
|
||||||
}
|
|
||||||
return expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapFileStatus(
|
|
||||||
status: string
|
|
||||||
): "added" | "modified" | "deleted" | "renamed" {
|
|
||||||
switch (status.toUpperCase().charAt(0)) {
|
|
||||||
case "A":
|
|
||||||
return "added"
|
|
||||||
case "D":
|
|
||||||
return "deleted"
|
|
||||||
case "R":
|
|
||||||
return "renamed"
|
|
||||||
default:
|
|
||||||
return "modified"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(
|
|
||||||
dateStr: string,
|
|
||||||
t: (
|
|
||||||
key:
|
|
||||||
| "time.monthsAgo"
|
|
||||||
| "time.daysAgo"
|
|
||||||
| "time.hoursAgo"
|
|
||||||
| "time.minsAgo"
|
|
||||||
| "time.justNow",
|
|
||||||
values?: { count: number }
|
|
||||||
) => string
|
|
||||||
): string {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
if (Number.isNaN(date.getTime())) return dateStr
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000)
|
|
||||||
const diffHour = Math.floor(diffMin / 60)
|
|
||||||
const diffDay = Math.floor(diffHour / 24)
|
|
||||||
|
|
||||||
if (diffDay > 30) {
|
|
||||||
const diffMonth = Math.floor(diffDay / 30)
|
|
||||||
return t("time.monthsAgo", { count: diffMonth })
|
|
||||||
}
|
|
||||||
if (diffDay > 0) return t("time.daysAgo", { count: diffDay })
|
|
||||||
if (diffHour > 0) return t("time.hoursAgo", { count: diffHour })
|
|
||||||
if (diffMin > 0) return t("time.minsAgo", { count: diffMin })
|
|
||||||
return t("time.justNow", { count: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDate(dateStr: string): Date | null {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main component ---
|
|
||||||
|
|
||||||
interface PushWorkspaceProps {
|
|
||||||
folderPath: string
|
|
||||||
folderName: string
|
|
||||||
folderId?: number | null
|
|
||||||
onPushed?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PushWorkspace({
|
|
||||||
folderPath,
|
|
||||||
folderName,
|
|
||||||
folderId,
|
|
||||||
onPushed,
|
|
||||||
}: PushWorkspaceProps) {
|
|
||||||
const t = useTranslations("Folder.pushWindow")
|
|
||||||
const tLog = useTranslations("Folder.gitLogTab")
|
|
||||||
const { withCredentialRetry } = useGitCredential()
|
|
||||||
|
|
||||||
const [pushInfoData, setPushInfoData] = useState<GitPushInfo | null>(null)
|
|
||||||
const [selectedRemote, setSelectedRemote] = useState<string | null>(null)
|
|
||||||
const [commits, setCommits] = useState<GitLogEntry[]>([])
|
|
||||||
const [hasUpstream, setHasUpstream] = useState(true)
|
|
||||||
const [listLoading, setListLoading] = useState(false)
|
|
||||||
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
|
|
||||||
const [pushing, setPushing] = useState(false)
|
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
|
||||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
|
|
||||||
const [originalContent, setOriginalContent] = useState("")
|
|
||||||
const [modifiedContent, setModifiedContent] = useState("")
|
|
||||||
const [diffLoading, setDiffLoading] = useState(false)
|
|
||||||
|
|
||||||
const unpushedCommits = useMemo(
|
|
||||||
() => commits.filter((c) => c.pushed === false),
|
|
||||||
[commits]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load push info (branch, remotes, tracking remote)
|
|
||||||
useEffect(() => {
|
|
||||||
gitPushInfo(folderPath)
|
|
||||||
.then((info) => {
|
|
||||||
setPushInfoData(info)
|
|
||||||
// Default to tracking remote or first remote
|
|
||||||
const defaultRemote =
|
|
||||||
info.tracking_remote ??
|
|
||||||
(info.remotes.length > 0 ? info.remotes[0].name : null)
|
|
||||||
setSelectedRemote(defaultRemote)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
})
|
|
||||||
}, [folderPath])
|
|
||||||
|
|
||||||
// Deduplicate remotes (git remote -v returns fetch + push entries)
|
|
||||||
const uniqueRemotes = useMemo(() => {
|
|
||||||
if (!pushInfoData) return []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
return pushInfoData.remotes.filter((r) => {
|
|
||||||
if (seen.has(r.name)) return false
|
|
||||||
seen.add(r.name)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}, [pushInfoData])
|
|
||||||
|
|
||||||
const loadCommits = useCallback(
|
|
||||||
async (remote?: string) => {
|
|
||||||
setListLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await gitLog(
|
|
||||||
folderPath,
|
|
||||||
100,
|
|
||||||
undefined,
|
|
||||||
remote ?? undefined
|
|
||||||
)
|
|
||||||
setCommits(result.entries)
|
|
||||||
setHasUpstream(result.has_upstream)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setListLoading(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[folderPath]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reload commits when selected remote changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedRemote !== null) {
|
|
||||||
loadCommits(selectedRemote)
|
|
||||||
}
|
|
||||||
}, [selectedRemote, loadCommits])
|
|
||||||
|
|
||||||
async function handleSelectFile(commitHash: string, file: string) {
|
|
||||||
setSelectedFile(file)
|
|
||||||
setSelectedCommit(commitHash)
|
|
||||||
setDiffLoading(true)
|
|
||||||
try {
|
|
||||||
const [orig, mod] = await Promise.all([
|
|
||||||
gitShowFile(folderPath, file, `${commitHash}~1`).catch(() => ""),
|
|
||||||
gitShowFile(folderPath, file, commitHash).catch(() => ""),
|
|
||||||
])
|
|
||||||
setOriginalContent(orig)
|
|
||||||
setModifiedContent(mod)
|
|
||||||
} catch {
|
|
||||||
setOriginalContent("")
|
|
||||||
setModifiedContent("")
|
|
||||||
} finally {
|
|
||||||
setDiffLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePush() {
|
|
||||||
setPushing(true)
|
|
||||||
try {
|
|
||||||
// Resolve the selected remote's URL for credential matching
|
|
||||||
const remoteUrl = pushInfoData?.remotes.find(
|
|
||||||
(r) => r.name === selectedRemote
|
|
||||||
)?.url
|
|
||||||
const hint: GitRemoteHint = remoteUrl ? { remoteUrl } : { folderPath }
|
|
||||||
await withCredentialRetry(
|
|
||||||
(creds) => gitPush(folderPath, selectedRemote, creds, folderId),
|
|
||||||
hint
|
|
||||||
)
|
|
||||||
onPushed?.()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t("toasts.pushFailed"), {
|
|
||||||
description: toErrorMessage(err),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setPushing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Push target header: branch → remote/branch */}
|
|
||||||
{pushInfoData && (
|
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
||||||
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
{pushInfoData.branch}
|
|
||||||
</span>
|
|
||||||
{uniqueRemotes.length > 0 && (
|
|
||||||
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{uniqueRemotes.length === 0 ? null : uniqueRemotes.length <= 1 ? (
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{selectedRemote ?? "origin"}/{pushInfoData.branch}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Select
|
|
||||||
value={selectedRemote ?? ""}
|
|
||||||
onValueChange={setSelectedRemote}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-auto gap-1 border-none bg-transparent px-1.5 text-sm shadow-none">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{uniqueRemotes.map((r) => (
|
|
||||||
<SelectItem key={r.name} value={r.name}>
|
|
||||||
{r.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
/{pushInfoData.branch}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
|
||||||
{/* Left panel: commit list */}
|
|
||||||
<ResizablePanel defaultSize={35} minSize={25}>
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
{listLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : uniqueRemotes.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center px-4 py-12 text-center text-sm text-muted-foreground whitespace-pre-line">
|
|
||||||
{t("noRemoteConfigured")}
|
|
||||||
</div>
|
|
||||||
) : unpushedCommits.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
|
||||||
{!hasUpstream
|
|
||||||
? t("newBranchNoPushedCommits")
|
|
||||||
: t("noUnpushedCommits")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2 p-2">
|
|
||||||
{unpushedCommits.map((entry) => {
|
|
||||||
const commitKey = entry.full_hash
|
|
||||||
const commitDate = parseDate(entry.date)
|
|
||||||
const isOpen = !!openByCommit[commitKey]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Commit
|
|
||||||
key={commitKey}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setOpenByCommit((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[commitKey]: open,
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CommitHeader>
|
|
||||||
<CommitInfo className="min-w-0">
|
|
||||||
<CommitMessage className="line-clamp-1 leading-snug">
|
|
||||||
{entry.message}
|
|
||||||
</CommitMessage>
|
|
||||||
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="inline-flex shrink-0"
|
|
||||||
title={t("unpushed")}
|
|
||||||
>
|
|
||||||
<CloudOff
|
|
||||||
className="text-amber-500"
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{entry.author}</span>
|
|
||||||
<CommitTimestamp
|
|
||||||
className="shrink-0"
|
|
||||||
date={commitDate ?? new Date()}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(entry.date, tLog)}
|
|
||||||
</CommitTimestamp>
|
|
||||||
<CommitHash className="text-primary/70">
|
|
||||||
{entry.hash}
|
|
||||||
</CommitHash>
|
|
||||||
</CommitMetadata>
|
|
||||||
</CommitInfo>
|
|
||||||
</CommitHeader>
|
|
||||||
<CommitContent>
|
|
||||||
{entry.files.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{tLog("noFileChangeDetails")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<PushCommitFilesTree
|
|
||||||
commitHash={entry.full_hash}
|
|
||||||
files={entry.files}
|
|
||||||
folderName={folderName}
|
|
||||||
onSelectFile={(file) =>
|
|
||||||
handleSelectFile(entry.full_hash, file)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CommitContent>
|
|
||||||
</Commit>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Push button */}
|
|
||||||
<div className="border-t p-2">
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
disabled={
|
|
||||||
pushing ||
|
|
||||||
uniqueRemotes.length === 0 ||
|
|
||||||
(hasUpstream && unpushedCommits.length === 0)
|
|
||||||
}
|
|
||||||
onClick={handlePush}
|
|
||||||
>
|
|
||||||
{pushing ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("push")}
|
|
||||||
{unpushedCommits.length > 0 && ` (${unpushedCommits.length})`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle />
|
|
||||||
|
|
||||||
{/* Right panel: diff viewer */}
|
|
||||||
<ResizablePanel defaultSize={65} minSize={40}>
|
|
||||||
{diffLoading ? (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : selectedFile && selectedCommit ? (
|
|
||||||
<DiffViewer
|
|
||||||
original={originalContent}
|
|
||||||
modified={modifiedContent}
|
|
||||||
originalLabel={`${selectedCommit.slice(0, 7)}~ (${t("before")})`}
|
|
||||||
modifiedLabel={`${selectedCommit.slice(0, 7)} (${t("after")})`}
|
|
||||||
language={languageFromPath(selectedFile)}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{t("selectFileToViewDiff")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Commit Files Tree for Push Window ---
|
|
||||||
|
|
||||||
function PushCommitFilesTree({
|
|
||||||
commitHash,
|
|
||||||
files,
|
|
||||||
folderName,
|
|
||||||
onSelectFile,
|
|
||||||
}: {
|
|
||||||
commitHash: string
|
|
||||||
files: GitLogFileChange[]
|
|
||||||
folderName: string
|
|
||||||
onSelectFile: (file: string) => void
|
|
||||||
}) {
|
|
||||||
const tLog = useTranslations("Folder.gitLogTab")
|
|
||||||
const rootPath = "__push_file_tree_root__"
|
|
||||||
const treeNodes = useMemo(() => buildCommitFileTree(files), [files])
|
|
||||||
const allDirectoryPaths = useMemo(() => {
|
|
||||||
const paths = collectExpandedDirectoryPaths(treeNodes)
|
|
||||||
paths.add(rootPath)
|
|
||||||
return paths
|
|
||||||
}, [treeNodes])
|
|
||||||
const [expandedPaths, setExpandedPaths] =
|
|
||||||
useState<Set<string>>(allDirectoryPaths)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setExpandedPaths(allDirectoryPaths)
|
|
||||||
}, [allDirectoryPaths])
|
|
||||||
|
|
||||||
const canExpandAll = useMemo(() => {
|
|
||||||
if (allDirectoryPaths.size === 0) return false
|
|
||||||
for (const path of allDirectoryPaths) {
|
|
||||||
if (!expandedPaths.has(path)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}, [allDirectoryPaths, expandedPaths])
|
|
||||||
|
|
||||||
const canCollapseAll = expandedPaths.size > 0
|
|
||||||
|
|
||||||
const toggleExpanded = useCallback(() => {
|
|
||||||
if (canExpandAll) {
|
|
||||||
setExpandedPaths(new Set(allDirectoryPaths))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExpandedPaths(new Set())
|
|
||||||
}, [allDirectoryPaths, canExpandAll])
|
|
||||||
|
|
||||||
const renderNode = (node: CommitFileTreeNode): ReactElement => {
|
|
||||||
if (node.kind === "dir") {
|
|
||||||
return (
|
|
||||||
<FileTreeFolder
|
|
||||||
key={node.path}
|
|
||||||
path={node.path}
|
|
||||||
name={node.name}
|
|
||||||
suffix={`(${node.fileCount})`}
|
|
||||||
suffixClassName="text-muted-foreground/45"
|
|
||||||
title={node.path}
|
|
||||||
>
|
|
||||||
{node.children.map(renderNode)}
|
|
||||||
</FileTreeFolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = node.change
|
|
||||||
return (
|
|
||||||
<FileTreeFile
|
|
||||||
key={`${commitHash}:${file.path}`}
|
|
||||||
className="w-full min-w-0 cursor-pointer"
|
|
||||||
name={node.name}
|
|
||||||
onClick={() => onSelectFile(file.path)}
|
|
||||||
path={node.path}
|
|
||||||
title={file.path}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<span className="size-4 shrink-0" />
|
|
||||||
<CommitFileInfo className="flex-1 min-w-0 gap-1.5">
|
|
||||||
<CommitFileStatus status={mapFileStatus(file.status)}>
|
|
||||||
{file.status}
|
|
||||||
</CommitFileStatus>
|
|
||||||
<CommitFileIcon />
|
|
||||||
<CommitFilePath title={file.path}>{node.name}</CommitFilePath>
|
|
||||||
</CommitFileInfo>
|
|
||||||
<CommitFileChanges>
|
|
||||||
<CommitFileAdditions count={file.additions} />
|
|
||||||
<CommitFileDeletions count={file.deletions} />
|
|
||||||
</CommitFileChanges>
|
|
||||||
</>
|
|
||||||
</FileTreeFile>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{tLog("filesTitle")}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-5"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
disabled={!canExpandAll && !canCollapseAll}
|
|
||||||
title={
|
|
||||||
canExpandAll ? tLog("expandAllFiles") : tLog("collapseAllFiles")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{canExpandAll ? (
|
|
||||||
<ChevronsUpDown className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronsDownUp className="size-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CommitFiles>
|
|
||||||
<FileTree
|
|
||||||
className="max-h-[32rem] overflow-auto rounded-md border-border/60 bg-transparent text-xs [&>div]:p-1"
|
|
||||||
expanded={expandedPaths}
|
|
||||||
onExpandedChange={setExpandedPaths}
|
|
||||||
>
|
|
||||||
<FileTreeFolder
|
|
||||||
path={rootPath}
|
|
||||||
name={folderName}
|
|
||||||
suffix={`(${files.length})`}
|
|
||||||
suffixClassName="text-muted-foreground/45"
|
|
||||||
title={folderName}
|
|
||||||
>
|
|
||||||
{treeNodes.map(renderNode)}
|
|
||||||
</FileTreeFolder>
|
|
||||||
</FileTree>
|
|
||||||
</CommitFiles>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { gitStashPush } from "@/lib/api"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
|
||||||
|
|
||||||
interface StashDialogProps {
|
|
||||||
open: boolean
|
|
||||||
folderPath: string
|
|
||||||
onClose: () => void
|
|
||||||
onStashed: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StashDialog({
|
|
||||||
open,
|
|
||||||
folderPath,
|
|
||||||
onClose,
|
|
||||||
onStashed,
|
|
||||||
}: StashDialogProps) {
|
|
||||||
const t = useTranslations("Folder.branchDropdown.stashDialog")
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
const [keepIndex, setKeepIndex] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
if (loading) return
|
|
||||||
setMessage("")
|
|
||||||
setKeepIndex(false)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStash() {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
await gitStashPush(folderPath, message.trim() || undefined, keepIndex)
|
|
||||||
toast.success(t("success"))
|
|
||||||
setMessage("")
|
|
||||||
setKeepIndex(false)
|
|
||||||
onStashed()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t("error"), { description: toErrorMessage(err) })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("title")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="stash-message">{t("messageLabel")}</Label>
|
|
||||||
<Input
|
|
||||||
id="stash-message"
|
|
||||||
placeholder={t("messagePlaceholder")}
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !loading) {
|
|
||||||
handleStash()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="keep-index"
|
|
||||||
checked={keepIndex}
|
|
||||||
onCheckedChange={setKeepIndex}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="keep-index">{t("keepIndex")}</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleStash} disabled={loading}>
|
|
||||||
{loading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
||||||
{t("stash")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
||||||
import { Archive, ArchiveRestore, ChevronRight, Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible"
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import {
|
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable"
|
|
||||||
import {
|
|
||||||
FileTree,
|
|
||||||
FileTreeFile,
|
|
||||||
FileTreeFolder,
|
|
||||||
} from "@/components/ai-elements/file-tree"
|
|
||||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
|
||||||
import {
|
|
||||||
gitStashList,
|
|
||||||
gitStashShow,
|
|
||||||
gitStashApply,
|
|
||||||
gitStashDrop,
|
|
||||||
gitShowFile,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
|
||||||
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// --- File tree types & builder (same pattern as commit-dialog) ---
|
|
||||||
|
|
||||||
interface TreeFileNode {
|
|
||||||
kind: "file"
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
entry: GitStatusEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeDirNode {
|
|
||||||
kind: "dir"
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
children: TreeNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreeNode = TreeFileNode | TreeDirNode
|
|
||||||
|
|
||||||
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
|
|
||||||
type BuildDir = {
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
dirs: Map<string, BuildDir>
|
|
||||||
files: TreeFileNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const root: BuildDir = {
|
|
||||||
name: "",
|
|
||||||
path: "",
|
|
||||||
dirs: new Map(),
|
|
||||||
files: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const parts = entry.file.split("/").filter(Boolean)
|
|
||||||
if (parts.length === 0) continue
|
|
||||||
|
|
||||||
let current = root
|
|
||||||
let currentPath = ""
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i += 1) {
|
|
||||||
const part = parts[i]
|
|
||||||
const isLeaf = i === parts.length - 1
|
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
|
||||||
|
|
||||||
if (isLeaf) {
|
|
||||||
current.files.push({
|
|
||||||
kind: "file",
|
|
||||||
name: part,
|
|
||||||
path: currentPath,
|
|
||||||
entry,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const found = current.dirs.get(part)
|
|
||||||
if (found) {
|
|
||||||
current = found
|
|
||||||
} else {
|
|
||||||
const next: BuildDir = {
|
|
||||||
name: part,
|
|
||||||
path: currentPath,
|
|
||||||
dirs: new Map(),
|
|
||||||
files: [],
|
|
||||||
}
|
|
||||||
current.dirs.set(part, next)
|
|
||||||
current = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNodes(nodes: TreeNode[]) {
|
|
||||||
return nodes.sort((a, b) => {
|
|
||||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNodes(dir: BuildDir): TreeNode[] {
|
|
||||||
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
|
|
||||||
kind: "dir",
|
|
||||||
name: child.name,
|
|
||||||
path: child.path,
|
|
||||||
children: toNodes(child),
|
|
||||||
}))
|
|
||||||
return sortNodes([...dirs, ...dir.files])
|
|
||||||
}
|
|
||||||
|
|
||||||
return toNodes(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectDirPaths(entries: GitStatusEntry[]) {
|
|
||||||
const paths = new Set<string>()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const parts = entry.file.split("/").filter(Boolean)
|
|
||||||
if (parts.length < 2) continue
|
|
||||||
let p = ""
|
|
||||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
||||||
p = p ? `${p}/${parts[i]}` : parts[i]
|
|
||||||
paths.add(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status: string) {
|
|
||||||
switch (status.charAt(0).toUpperCase()) {
|
|
||||||
case "A":
|
|
||||||
return "text-green-500"
|
|
||||||
case "D":
|
|
||||||
return "text-red-500"
|
|
||||||
case "M":
|
|
||||||
return "text-blue-500"
|
|
||||||
default:
|
|
||||||
return "text-muted-foreground"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main component ---
|
|
||||||
|
|
||||||
interface StashWorkspaceProps {
|
|
||||||
folderPath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StashWorkspace({ folderPath }: StashWorkspaceProps) {
|
|
||||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
|
||||||
|
|
||||||
const [stashes, setStashes] = useState<GitStashEntry[]>([])
|
|
||||||
const [expandedStash, setExpandedStash] = useState<string | null>(null)
|
|
||||||
const [stashFiles, setStashFiles] = useState<
|
|
||||||
Record<string, GitStatusEntry[]>
|
|
||||||
>({})
|
|
||||||
const [filesLoading, setFilesLoading] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
|
||||||
const [selectedStashRef, setSelectedStashRef] = useState<string | null>(null)
|
|
||||||
const [originalContent, setOriginalContent] = useState("")
|
|
||||||
const [modifiedContent, setModifiedContent] = useState("")
|
|
||||||
|
|
||||||
const [listLoading, setListLoading] = useState(false)
|
|
||||||
const [diffLoading, setDiffLoading] = useState(false)
|
|
||||||
const [actionLoading, setActionLoading] = useState(false)
|
|
||||||
|
|
||||||
const loadStashes = useCallback(async () => {
|
|
||||||
setListLoading(true)
|
|
||||||
try {
|
|
||||||
const list = await gitStashList(folderPath)
|
|
||||||
setStashes(list)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setListLoading(false)
|
|
||||||
}
|
|
||||||
}, [folderPath])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStashes()
|
|
||||||
}, [loadStashes])
|
|
||||||
|
|
||||||
async function handleToggleStash(stashRef: string) {
|
|
||||||
if (expandedStash === stashRef) {
|
|
||||||
setExpandedStash(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExpandedStash(stashRef)
|
|
||||||
|
|
||||||
if (!stashFiles[stashRef]) {
|
|
||||||
setFilesLoading(stashRef)
|
|
||||||
try {
|
|
||||||
const fileList = await gitStashShow(folderPath, stashRef)
|
|
||||||
setStashFiles((prev) => ({ ...prev, [stashRef]: fileList }))
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setFilesLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSelectFile(stashRef: string, file: string) {
|
|
||||||
setSelectedFile(file)
|
|
||||||
setSelectedStashRef(stashRef)
|
|
||||||
setDiffLoading(true)
|
|
||||||
try {
|
|
||||||
const [orig, mod] = await Promise.all([
|
|
||||||
gitShowFile(folderPath, file, stashRef + "^").catch(() => ""),
|
|
||||||
gitShowFile(folderPath, file, stashRef).catch(() => ""),
|
|
||||||
])
|
|
||||||
setOriginalContent(orig)
|
|
||||||
setModifiedContent(mod)
|
|
||||||
} catch {
|
|
||||||
setOriginalContent("")
|
|
||||||
setModifiedContent("")
|
|
||||||
} finally {
|
|
||||||
setDiffLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApply(stashRef: string) {
|
|
||||||
setActionLoading(true)
|
|
||||||
try {
|
|
||||||
await gitStashApply(folderPath, stashRef)
|
|
||||||
toast.success(t("applySuccess"))
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDrop(stashRef: string) {
|
|
||||||
setActionLoading(true)
|
|
||||||
try {
|
|
||||||
await gitStashDrop(folderPath, stashRef)
|
|
||||||
toast.success(t("dropSuccess"))
|
|
||||||
if (expandedStash === stashRef) {
|
|
||||||
setExpandedStash(null)
|
|
||||||
}
|
|
||||||
if (selectedStashRef === stashRef) {
|
|
||||||
setSelectedFile(null)
|
|
||||||
setSelectedStashRef(null)
|
|
||||||
setOriginalContent("")
|
|
||||||
setModifiedContent("")
|
|
||||||
}
|
|
||||||
setStashFiles((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[stashRef]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
await loadStashes()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render file tree nodes
|
|
||||||
function renderNode(node: TreeNode, stashRef: string): React.ReactNode {
|
|
||||||
if (node.kind === "dir") {
|
|
||||||
return (
|
|
||||||
<FileTreeFolder key={node.path} name={node.name} path={node.path}>
|
|
||||||
{node.children.map((child) => renderNode(child, stashRef))}
|
|
||||||
</FileTreeFolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu key={node.path}>
|
|
||||||
<ContextMenuTrigger>
|
|
||||||
<FileTreeFile
|
|
||||||
name={node.name}
|
|
||||||
path={node.path}
|
|
||||||
className="gap-1 px-1.5 py-1"
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate text-left" title={node.path}>
|
|
||||||
{node.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-5 shrink-0 text-right text-xs font-bold",
|
|
||||||
statusColor(node.entry.status)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.entry.status.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</FileTreeFile>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => handleSelectFile(stashRef, node.path)}
|
|
||||||
>
|
|
||||||
{t("viewDiff")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
|
||||||
{/* Left panel: stash cards */}
|
|
||||||
<ResizablePanel defaultSize={35} minSize={25}>
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
{listLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : stashes.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
|
||||||
{t("noStashes")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2 p-2">
|
|
||||||
{stashes.map((stash) => (
|
|
||||||
<StashCard
|
|
||||||
key={stash.ref_name}
|
|
||||||
stash={stash}
|
|
||||||
isExpanded={expandedStash === stash.ref_name}
|
|
||||||
isLoadingFiles={filesLoading === stash.ref_name}
|
|
||||||
actionLoading={actionLoading}
|
|
||||||
files={stashFiles[stash.ref_name]}
|
|
||||||
selectedFile={
|
|
||||||
selectedStashRef === stash.ref_name ? selectedFile : null
|
|
||||||
}
|
|
||||||
onToggle={() => handleToggleStash(stash.ref_name)}
|
|
||||||
onApply={() => handleApply(stash.ref_name)}
|
|
||||||
onDrop={() => handleDrop(stash.ref_name)}
|
|
||||||
onSelectFile={(file) =>
|
|
||||||
handleSelectFile(stash.ref_name, file)
|
|
||||||
}
|
|
||||||
renderNode={(node) => renderNode(node, stash.ref_name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle />
|
|
||||||
|
|
||||||
{/* Right panel: diff viewer */}
|
|
||||||
<ResizablePanel defaultSize={65} minSize={40}>
|
|
||||||
{diffLoading ? (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : selectedFile && selectedStashRef ? (
|
|
||||||
<DiffViewer
|
|
||||||
original={originalContent}
|
|
||||||
modified={modifiedContent}
|
|
||||||
originalLabel={`${selectedStashRef}^ (${t("original")})`}
|
|
||||||
modifiedLabel={`${selectedStashRef} (${t("modified")})`}
|
|
||||||
language={languageFromPath(selectedFile)}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{t("selectFile")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stash Card Component ---
|
|
||||||
|
|
||||||
interface StashCardProps {
|
|
||||||
stash: GitStashEntry
|
|
||||||
isExpanded: boolean
|
|
||||||
isLoadingFiles: boolean
|
|
||||||
actionLoading: boolean
|
|
||||||
files?: GitStatusEntry[]
|
|
||||||
selectedFile: string | null
|
|
||||||
onToggle: () => void
|
|
||||||
onApply: () => void
|
|
||||||
onDrop: () => void
|
|
||||||
onSelectFile: (file: string) => void
|
|
||||||
renderNode: (node: TreeNode) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function StashCard({
|
|
||||||
stash,
|
|
||||||
isExpanded,
|
|
||||||
isLoadingFiles,
|
|
||||||
actionLoading,
|
|
||||||
files,
|
|
||||||
selectedFile,
|
|
||||||
onToggle,
|
|
||||||
onApply,
|
|
||||||
onDrop,
|
|
||||||
onSelectFile,
|
|
||||||
renderNode,
|
|
||||||
}: StashCardProps) {
|
|
||||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
|
||||||
const [confirmApplyOpen, setConfirmApplyOpen] = useState(false)
|
|
||||||
const tree = useMemo(() => (files ? buildFileTree(files) : []), [files])
|
|
||||||
|
|
||||||
const defaultExpanded = useMemo(
|
|
||||||
() => (files ? collectDirPaths(files) : new Set<string>()),
|
|
||||||
[files]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger>
|
|
||||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
|
||||||
<div className="group rounded-lg border bg-card">
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded-t-lg px-3 py-2 text-left text-sm transition-colors group-hover:bg-muted/50"
|
|
||||||
disabled={actionLoading}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
|
|
||||||
isExpanded && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
{stash.ref_name}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{stash.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 text-[10px] text-muted-foreground/70">
|
|
||||||
<span>{stash.branch}</span>
|
|
||||||
<span>{stash.date}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
|
||||||
title={t("apply") as string}
|
|
||||||
onClick={() => setConfirmApplyOpen(true)}
|
|
||||||
disabled={actionLoading}
|
|
||||||
>
|
|
||||||
<ArchiveRestore className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="border-t">
|
|
||||||
{/* File tree */}
|
|
||||||
{isLoadingFiles ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : tree.length > 0 ? (
|
|
||||||
<div className="px-2 pb-2">
|
|
||||||
<FileTree
|
|
||||||
defaultExpanded={defaultExpanded}
|
|
||||||
selectedPath={selectedFile ?? undefined}
|
|
||||||
onSelect={onSelectFile}
|
|
||||||
className="border-0 bg-transparent"
|
|
||||||
>
|
|
||||||
{tree.map(renderNode)}
|
|
||||||
</FileTree>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem onClick={() => setConfirmApplyOpen(true)}>
|
|
||||||
{t("apply")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem variant="destructive" onClick={onDrop}>
|
|
||||||
{t("drop")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
|
|
||||||
<AlertDialog open={confirmApplyOpen} onOpenChange={setConfirmApplyOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("apply")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("confirmApply", { ref: stash.ref_name })}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
setConfirmApplyOpen(false)
|
|
||||||
onApply()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("apply")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
export interface ConflictRegion {
|
|
||||||
/** Line number (1-based) of <<<<<<< marker */
|
|
||||||
startLine: number
|
|
||||||
/** Line number (1-based) of ======= marker */
|
|
||||||
separatorLine: number
|
|
||||||
/** Line number (1-based) of >>>>>>> marker */
|
|
||||||
endLine: number
|
|
||||||
/** Content from the ours (local/HEAD) side */
|
|
||||||
oursContent: string
|
|
||||||
/** Content from the theirs (remote/incoming) side */
|
|
||||||
theirsContent: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse git conflict markers from file content.
|
|
||||||
* Returns an array of conflict regions sorted by line number.
|
|
||||||
*/
|
|
||||||
export function parseConflictMarkers(content: string): ConflictRegion[] {
|
|
||||||
const lines = content.split("\n")
|
|
||||||
const regions: ConflictRegion[] = []
|
|
||||||
|
|
||||||
let i = 0
|
|
||||||
while (i < lines.length) {
|
|
||||||
if (lines[i].startsWith("<<<<<<<")) {
|
|
||||||
const startLine = i + 1 // 1-based
|
|
||||||
let separatorLine = -1
|
|
||||||
let endLine = -1
|
|
||||||
const oursLines: string[] = []
|
|
||||||
const theirsLines: string[] = []
|
|
||||||
let inOurs = true
|
|
||||||
|
|
||||||
let j = i + 1
|
|
||||||
while (j < lines.length) {
|
|
||||||
if (lines[j].startsWith("=======") && separatorLine === -1) {
|
|
||||||
separatorLine = j + 1
|
|
||||||
inOurs = false
|
|
||||||
} else if (lines[j].startsWith(">>>>>>>")) {
|
|
||||||
endLine = j + 1
|
|
||||||
break
|
|
||||||
} else if (inOurs) {
|
|
||||||
oursLines.push(lines[j])
|
|
||||||
} else {
|
|
||||||
theirsLines.push(lines[j])
|
|
||||||
}
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (separatorLine !== -1 && endLine !== -1) {
|
|
||||||
regions.push({
|
|
||||||
startLine,
|
|
||||||
separatorLine,
|
|
||||||
endLine,
|
|
||||||
oursContent: oursLines.join("\n"),
|
|
||||||
theirsContent: theirsLines.join("\n"),
|
|
||||||
})
|
|
||||||
i = j + 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a single conflict region by replacing the conflict block
|
|
||||||
* with the chosen content.
|
|
||||||
*/
|
|
||||||
export function resolveConflict(
|
|
||||||
content: string,
|
|
||||||
region: ConflictRegion,
|
|
||||||
choice: "ours" | "theirs" | "both"
|
|
||||||
): string {
|
|
||||||
const lines = content.split("\n")
|
|
||||||
const startIdx = region.startLine - 1
|
|
||||||
const endIdx = region.endLine - 1
|
|
||||||
|
|
||||||
let replacement: string
|
|
||||||
switch (choice) {
|
|
||||||
case "ours":
|
|
||||||
replacement = region.oursContent
|
|
||||||
break
|
|
||||||
case "theirs":
|
|
||||||
replacement = region.theirsContent
|
|
||||||
break
|
|
||||||
case "both":
|
|
||||||
replacement = region.oursContent + "\n" + region.theirsContent
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const replacementLines = replacement === "" ? [] : replacement.split("\n")
|
|
||||||
lines.splice(startIdx, endIdx - startIdx + 1, ...replacementLines)
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if content still has unresolved conflict markers.
|
|
||||||
*/
|
|
||||||
export function hasConflictMarkers(content: string): boolean {
|
|
||||||
return content.includes("<<<<<<<") && content.includes(">>>>>>>")
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
/**
|
|
||||||
* Line-level diff engine for three-way merge.
|
|
||||||
*
|
|
||||||
* Computes diffs between base↔ours and base↔theirs, then aligns
|
|
||||||
* them into MergeHunks classified as left-only, right-only, or conflict.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface DiffHunk {
|
|
||||||
/** Start index in the "old" (base) array, 0-based */
|
|
||||||
baseStart: number
|
|
||||||
/** Number of lines removed from base (0 = pure insertion) */
|
|
||||||
baseCount: number
|
|
||||||
/** Replacement lines from the "new" side */
|
|
||||||
newLines: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HunkStatus = "pending" | "applied" | "ignored"
|
|
||||||
|
|
||||||
export interface MergeHunk {
|
|
||||||
id: string
|
|
||||||
/** Start index in base lines, 0-based */
|
|
||||||
baseStart: number
|
|
||||||
/** Number of base lines covered */
|
|
||||||
baseCount: number
|
|
||||||
/** Diff hunk from ours (left) side, null if unchanged */
|
|
||||||
leftHunk: DiffHunk | null
|
|
||||||
/** Diff hunk from theirs (right) side, null if unchanged */
|
|
||||||
rightHunk: DiffHunk | null
|
|
||||||
type: "left-only" | "right-only" | "conflict"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LCS-based line diff
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the Longest Common Subsequence table for two string arrays.
|
|
||||||
* Returns a 2D array where dp[i][j] = LCS length for a[0..i-1], b[0..j-1].
|
|
||||||
*/
|
|
||||||
function lcsTable(a: string[], b: string[]): number[][] {
|
|
||||||
const m = a.length
|
|
||||||
const n = b.length
|
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
|
||||||
new Array<number>(n + 1).fill(0)
|
|
||||||
)
|
|
||||||
for (let i = 1; i <= m; i++) {
|
|
||||||
for (let j = 1; j <= n; j++) {
|
|
||||||
if (a[i - 1] === b[j - 1]) {
|
|
||||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
||||||
} else {
|
|
||||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dp
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backtrack the LCS table to produce edit operations.
|
|
||||||
* Returns an array of { type, aIdx, bIdx } entries.
|
|
||||||
*/
|
|
||||||
interface EditOp {
|
|
||||||
type: "equal" | "delete" | "insert"
|
|
||||||
aIdx: number // index in a (-1 for insert)
|
|
||||||
bIdx: number // index in b (-1 for delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
|
|
||||||
const ops: EditOp[] = []
|
|
||||||
let i = a.length
|
|
||||||
let j = b.length
|
|
||||||
|
|
||||||
while (i > 0 || j > 0) {
|
|
||||||
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
||||||
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
|
|
||||||
i--
|
|
||||||
j--
|
|
||||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
||||||
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
|
|
||||||
j--
|
|
||||||
} else {
|
|
||||||
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ops.reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute line-level diff hunks between old (a) and new (b) arrays.
|
|
||||||
*/
|
|
||||||
export function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
|
|
||||||
const dp = lcsTable(a, b)
|
|
||||||
const ops = backtrackLCS(a, b, dp)
|
|
||||||
|
|
||||||
const hunks: DiffHunk[] = []
|
|
||||||
let idx = 0
|
|
||||||
|
|
||||||
while (idx < ops.length) {
|
|
||||||
const op = ops[idx]
|
|
||||||
|
|
||||||
if (op.type === "equal") {
|
|
||||||
idx++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start of a change region
|
|
||||||
let baseStart = op.type === "delete" ? op.aIdx : -1
|
|
||||||
let baseCount = 0
|
|
||||||
const newLines: string[] = []
|
|
||||||
|
|
||||||
while (idx < ops.length && ops[idx].type !== "equal") {
|
|
||||||
const cur = ops[idx]
|
|
||||||
if (cur.type === "delete") {
|
|
||||||
if (baseStart === -1) baseStart = cur.aIdx
|
|
||||||
baseCount++
|
|
||||||
} else {
|
|
||||||
// insert
|
|
||||||
if (baseStart === -1) {
|
|
||||||
// Pure insertion — position it at the next base line
|
|
||||||
// Find the previous equal op's aIdx + 1, or 0
|
|
||||||
baseStart = findInsertionPoint(ops, idx)
|
|
||||||
}
|
|
||||||
newLines.push(b[cur.bIdx])
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
hunks.push({ baseStart, baseCount, newLines })
|
|
||||||
}
|
|
||||||
|
|
||||||
return hunks
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For a pure insertion (no deletes in this hunk), determine
|
|
||||||
* where in the base array to anchor it.
|
|
||||||
*/
|
|
||||||
function findInsertionPoint(ops: EditOp[], currentIdx: number): number {
|
|
||||||
// Walk backwards to find the last "equal" or "delete" op
|
|
||||||
for (let k = currentIdx - 1; k >= 0; k--) {
|
|
||||||
if (ops[k].type === "equal" || ops[k].type === "delete") {
|
|
||||||
return ops[k].aIdx + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If nothing found, insert at start
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Three-way merge hunk computation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface RangedHunk {
|
|
||||||
baseStart: number
|
|
||||||
baseEnd: number // exclusive
|
|
||||||
hunk: DiffHunk
|
|
||||||
side: "left" | "right"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given diff hunks from base→ours and base→theirs, produce
|
|
||||||
* a list of MergeHunks sorted by base position.
|
|
||||||
*/
|
|
||||||
export function computeMergeHunks(
|
|
||||||
base: string,
|
|
||||||
ours: string,
|
|
||||||
theirs: string
|
|
||||||
): MergeHunk[] {
|
|
||||||
const baseLines = base.split("\n")
|
|
||||||
const oursLines = ours.split("\n")
|
|
||||||
const theirsLines = theirs.split("\n")
|
|
||||||
|
|
||||||
const leftDiffs = computeLineDiff(baseLines, oursLines)
|
|
||||||
const rightDiffs = computeLineDiff(baseLines, theirsLines)
|
|
||||||
|
|
||||||
// Convert to ranged hunks for overlap detection
|
|
||||||
const ranged: RangedHunk[] = []
|
|
||||||
|
|
||||||
for (const h of leftDiffs) {
|
|
||||||
ranged.push({
|
|
||||||
baseStart: h.baseStart,
|
|
||||||
baseEnd: h.baseStart + Math.max(h.baseCount, 1), // at least 1 for insertions
|
|
||||||
hunk: h,
|
|
||||||
side: "left",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const h of rightDiffs) {
|
|
||||||
ranged.push({
|
|
||||||
baseStart: h.baseStart,
|
|
||||||
baseEnd: h.baseStart + Math.max(h.baseCount, 1),
|
|
||||||
hunk: h,
|
|
||||||
side: "right",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by baseStart, then by side (left first)
|
|
||||||
ranged.sort(
|
|
||||||
(a, b) => a.baseStart - b.baseStart || (a.side === "left" ? -1 : 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Merge overlapping hunks from different sides into conflicts
|
|
||||||
const mergeHunks: MergeHunk[] = []
|
|
||||||
const used = new Set<number>()
|
|
||||||
|
|
||||||
for (let i = 0; i < ranged.length; i++) {
|
|
||||||
if (used.has(i)) continue
|
|
||||||
|
|
||||||
const r = ranged[i]
|
|
||||||
|
|
||||||
// Check for overlapping hunk from the other side
|
|
||||||
let paired: RangedHunk | null = null
|
|
||||||
let pairedIdx = -1
|
|
||||||
|
|
||||||
for (let j = i + 1; j < ranged.length; j++) {
|
|
||||||
if (used.has(j)) continue
|
|
||||||
const s = ranged[j]
|
|
||||||
if (s.side === r.side) continue
|
|
||||||
// Check overlap: ranges [r.baseStart, r.baseEnd) and [s.baseStart, s.baseEnd)
|
|
||||||
if (s.baseStart < r.baseEnd && r.baseStart < s.baseEnd) {
|
|
||||||
paired = s
|
|
||||||
pairedIdx = j
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// If s starts beyond r, no more overlaps possible
|
|
||||||
if (s.baseStart >= r.baseEnd) break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paired && pairedIdx >= 0) {
|
|
||||||
used.add(pairedIdx)
|
|
||||||
|
|
||||||
// Check if both sides made identical changes — treat as non-conflict
|
|
||||||
const leftH = r.side === "left" ? r.hunk : paired.hunk
|
|
||||||
const rightH = r.side === "right" ? r.hunk : paired.hunk
|
|
||||||
|
|
||||||
const identical =
|
|
||||||
leftH.baseStart === rightH.baseStart &&
|
|
||||||
leftH.baseCount === rightH.baseCount &&
|
|
||||||
leftH.newLines.length === rightH.newLines.length &&
|
|
||||||
leftH.newLines.every((line, k) => line === rightH.newLines[k])
|
|
||||||
|
|
||||||
if (identical) {
|
|
||||||
// Both sides made the same change — treat as left-only (auto-applicable)
|
|
||||||
const bStart = Math.min(r.baseStart, paired.baseStart)
|
|
||||||
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
|
||||||
mergeHunks.push({
|
|
||||||
id: `hunk-${mergeHunks.length}`,
|
|
||||||
baseStart: bStart,
|
|
||||||
baseCount: bEnd - bStart,
|
|
||||||
leftHunk: leftH,
|
|
||||||
rightHunk: null,
|
|
||||||
type: "left-only",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Conflict
|
|
||||||
const bStart = Math.min(r.baseStart, paired.baseStart)
|
|
||||||
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
|
||||||
mergeHunks.push({
|
|
||||||
id: `hunk-${mergeHunks.length}`,
|
|
||||||
baseStart: bStart,
|
|
||||||
baseCount: bEnd - bStart,
|
|
||||||
leftHunk: r.side === "left" ? r.hunk : paired.hunk,
|
|
||||||
rightHunk: r.side === "right" ? r.hunk : paired.hunk,
|
|
||||||
type: "conflict",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single-side change
|
|
||||||
mergeHunks.push({
|
|
||||||
id: `hunk-${mergeHunks.length}`,
|
|
||||||
baseStart: r.baseStart,
|
|
||||||
baseCount: r.hunk.baseCount,
|
|
||||||
leftHunk: r.side === "left" ? r.hunk : null,
|
|
||||||
rightHunk: r.side === "right" ? r.hunk : null,
|
|
||||||
type: r.side === "left" ? "left-only" : "right-only",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by baseStart
|
|
||||||
mergeHunks.sort((a, b) => a.baseStart - b.baseStart)
|
|
||||||
|
|
||||||
return mergeHunks
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Result builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface AppliedHunkInfo {
|
|
||||||
id: string
|
|
||||||
side: "left" | "right"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the result content by starting from base and applying
|
|
||||||
* hunks that have been accepted.
|
|
||||||
*
|
|
||||||
* @param base Original base content
|
|
||||||
* @param hunks All merge hunks
|
|
||||||
* @param applied Map of hunk id → which side was applied
|
|
||||||
*/
|
|
||||||
export function buildResult(
|
|
||||||
base: string,
|
|
||||||
hunks: MergeHunk[],
|
|
||||||
applied: Map<string, "left" | "right">
|
|
||||||
): string {
|
|
||||||
const baseLines = base.split("\n")
|
|
||||||
const result: string[] = []
|
|
||||||
let baseIdx = 0
|
|
||||||
|
|
||||||
// Process hunks in order of baseStart
|
|
||||||
const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart)
|
|
||||||
|
|
||||||
for (const hunk of sorted) {
|
|
||||||
// Copy unchanged base lines before this hunk
|
|
||||||
while (baseIdx < hunk.baseStart) {
|
|
||||||
result.push(baseLines[baseIdx])
|
|
||||||
baseIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedSide = applied.get(hunk.id)
|
|
||||||
|
|
||||||
if (appliedSide) {
|
|
||||||
// Apply the chosen side's content
|
|
||||||
const diffHunk = appliedSide === "left" ? hunk.leftHunk : hunk.rightHunk
|
|
||||||
if (diffHunk) {
|
|
||||||
result.push(...diffHunk.newLines)
|
|
||||||
}
|
|
||||||
// Skip over the base lines that were replaced
|
|
||||||
baseIdx = hunk.baseStart + hunk.baseCount
|
|
||||||
} else {
|
|
||||||
// Not applied — keep base content
|
|
||||||
for (let i = 0; i < hunk.baseCount; i++) {
|
|
||||||
if (baseIdx < baseLines.length) {
|
|
||||||
result.push(baseLines[baseIdx])
|
|
||||||
baseIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy remaining base lines
|
|
||||||
while (baseIdx < baseLines.length) {
|
|
||||||
result.push(baseLines[baseIdx])
|
|
||||||
baseIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.join("\n")
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
|
||||||
async function emitEvent(event: string, payload?: unknown) {
|
|
||||||
try {
|
|
||||||
const { emit } = await import("@tauri-apps/api/event")
|
|
||||||
await emit(event, payload)
|
|
||||||
} catch {
|
|
||||||
/* not in Tauri */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
gitListConflicts,
|
|
||||||
gitConflictFileVersions,
|
|
||||||
gitResolveConflict,
|
|
||||||
gitAbortOperation,
|
|
||||||
gitContinueOperation,
|
|
||||||
gitStartPullMerge,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import { languageFromPath } from "@/lib/language-detect"
|
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
|
||||||
import type { GitConflictFileVersions } from "@/lib/types"
|
|
||||||
import { ThreePaneMergeEditor } from "./three-pane-merge-editor"
|
|
||||||
|
|
||||||
interface MergeWorkspaceProps {
|
|
||||||
folderId: number
|
|
||||||
folderPath: string
|
|
||||||
operation: string
|
|
||||||
upstreamCommit?: string
|
|
||||||
onCompleted: () => void
|
|
||||||
onAborted: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MergeWorkspace({
|
|
||||||
folderId,
|
|
||||||
folderPath,
|
|
||||||
operation,
|
|
||||||
upstreamCommit,
|
|
||||||
onCompleted,
|
|
||||||
onAborted,
|
|
||||||
}: MergeWorkspaceProps) {
|
|
||||||
const t = useTranslations("MergePage")
|
|
||||||
const [files, setFiles] = useState<string[]>([])
|
|
||||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
|
||||||
const [versions, setVersions] = useState<GitConflictFileVersions | null>(null)
|
|
||||||
const [loadingVersions, setLoadingVersions] = useState(false)
|
|
||||||
const [resolving, setResolving] = useState(false)
|
|
||||||
const [aborting, setAborting] = useState(false)
|
|
||||||
const [completing, setCompleting] = useState(false)
|
|
||||||
const currentContentRef = useRef<string>("")
|
|
||||||
const [hasUnresolvedConflicts, setHasUnresolvedConflicts] = useState(true)
|
|
||||||
const [preparing, setPreparing] = useState(false)
|
|
||||||
|
|
||||||
// Load conflict files on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadConflicts()
|
|
||||||
}, [folderPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
async function loadConflicts() {
|
|
||||||
try {
|
|
||||||
// For pull operations, the merge was aborted during detection to keep
|
|
||||||
// working tree clean. Re-start the merge to create conflict state.
|
|
||||||
if (operation === "pull") {
|
|
||||||
setPreparing(true)
|
|
||||||
try {
|
|
||||||
await gitStartPullMerge(folderPath, upstreamCommit)
|
|
||||||
} finally {
|
|
||||||
setPreparing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const conflictFiles = await gitListConflicts(folderPath)
|
|
||||||
setFiles(conflictFiles)
|
|
||||||
if (conflictFiles.length > 0 && !selectedFile) {
|
|
||||||
selectFile(conflictFiles[0])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectFile(file: string) {
|
|
||||||
setSelectedFile(file)
|
|
||||||
setLoadingVersions(true)
|
|
||||||
try {
|
|
||||||
const v = await gitConflictFileVersions(folderPath, file)
|
|
||||||
setVersions(v)
|
|
||||||
currentContentRef.current = v.base
|
|
||||||
setHasUnresolvedConflicts(true)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
setVersions(null)
|
|
||||||
} finally {
|
|
||||||
setLoadingVersions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentChange = useCallback((content: string) => {
|
|
||||||
currentContentRef.current = content
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleConflictStatusChange = useCallback((hasUnresolved: boolean) => {
|
|
||||||
setHasUnresolvedConflicts(hasUnresolved)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function handleResolve() {
|
|
||||||
if (!selectedFile) return
|
|
||||||
|
|
||||||
const content = currentContentRef.current
|
|
||||||
if (hasUnresolvedConflicts) {
|
|
||||||
toast.warning(t("unresolvedConflicts"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setResolving(true)
|
|
||||||
try {
|
|
||||||
await gitResolveConflict(folderPath, selectedFile, content)
|
|
||||||
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
|
||||||
|
|
||||||
// Notify parent window
|
|
||||||
await emitEvent("folder://merge-conflict-resolved", {
|
|
||||||
folder_id: folderId,
|
|
||||||
file: selectedFile,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-select next unresolved file
|
|
||||||
const nextUnresolved = files.find(
|
|
||||||
(f) => f !== selectedFile && !resolvedFiles.has(f)
|
|
||||||
)
|
|
||||||
if (nextUnresolved) {
|
|
||||||
selectFile(nextUnresolved)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setResolving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAbort() {
|
|
||||||
setAborting(true)
|
|
||||||
try {
|
|
||||||
await gitAbortOperation(folderPath, operation)
|
|
||||||
toast.success(t("abortSuccess"))
|
|
||||||
await emitEvent("folder://merge-aborted", { folder_id: folderId })
|
|
||||||
onAborted()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setAborting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleComplete() {
|
|
||||||
setCompleting(true)
|
|
||||||
try {
|
|
||||||
await gitContinueOperation(folderPath, operation)
|
|
||||||
toast.success(t("allResolved"))
|
|
||||||
await emitEvent("folder://merge-completed", { folder_id: folderId })
|
|
||||||
onCompleted()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(toErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setCompleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResolved =
|
|
||||||
files.length > 0 && files.every((f) => resolvedFiles.has(f))
|
|
||||||
|
|
||||||
const language = selectedFile ? languageFromPath(selectedFile) : "plaintext"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-2">
|
|
||||||
<ResizablePanelGroup
|
|
||||||
direction="horizontal"
|
|
||||||
className="flex-1 min-h-0 rounded-lg border"
|
|
||||||
>
|
|
||||||
{/* Left sidebar: conflict file list */}
|
|
||||||
<ResizablePanel defaultSize={18} minSize={12} maxSize={30}>
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
|
||||||
{t("conflictFiles")} ({files.length})
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-1">
|
|
||||||
{files.map((file) => {
|
|
||||||
const isResolved = resolvedFiles.has(file)
|
|
||||||
const isSelected = file === selectedFile
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={file}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "hover:bg-accent/50"
|
|
||||||
}`}
|
|
||||||
onClick={() => !isResolved && selectFile(file)}
|
|
||||||
disabled={isResolved}
|
|
||||||
>
|
|
||||||
{isResolved ? (
|
|
||||||
<Check className="h-3 w-3 shrink-0 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<FileWarning className="h-3 w-3 shrink-0 text-amber-500" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`truncate ${isResolved ? "text-muted-foreground line-through" : ""}`}
|
|
||||||
>
|
|
||||||
{file}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{files.length === 0 && (
|
|
||||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
|
||||||
{t("noConflicts")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle />
|
|
||||||
|
|
||||||
{/* Main area: three-pane merge editor */}
|
|
||||||
<ResizablePanel defaultSize={82}>
|
|
||||||
{preparing ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("preparingMerge")}
|
|
||||||
</div>
|
|
||||||
) : loadingVersions ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("loadingFile")}
|
|
||||||
</div>
|
|
||||||
) : versions && selectedFile ? (
|
|
||||||
<ThreePaneMergeEditor
|
|
||||||
key={selectedFile}
|
|
||||||
base={versions.base}
|
|
||||||
ours={versions.ours}
|
|
||||||
theirs={versions.theirs}
|
|
||||||
merged={versions.merged}
|
|
||||||
language={language}
|
|
||||||
onContentChange={handleContentChange}
|
|
||||||
onConflictStatusChange={handleConflictStatusChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{t("selectFile")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAbort}
|
|
||||||
disabled={aborting || completing || resolving}
|
|
||||||
>
|
|
||||||
{aborting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
||||||
<X className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t("abortMerge")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleResolve}
|
|
||||||
disabled={
|
|
||||||
!selectedFile ||
|
|
||||||
resolving ||
|
|
||||||
aborting ||
|
|
||||||
completing ||
|
|
||||||
(selectedFile !== null && resolvedFiles.has(selectedFile))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{resolving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
||||||
<Check className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t("markResolved")}
|
|
||||||
</Button>
|
|
||||||
{allResolved && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleComplete}
|
|
||||||
disabled={completing || aborting}
|
|
||||||
>
|
|
||||||
{completing && (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
)}
|
|
||||||
<CheckCheck className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t("completeMerge")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,769 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import dynamic from "next/dynamic"
|
|
||||||
import type { OnMount } from "@monaco-editor/react"
|
|
||||||
import type { editor as MonacoEditorNs, IRange } from "monaco-editor"
|
|
||||||
import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
|
||||||
import { useZoomLevel } from "@/hooks/use-appearance"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const EDITOR_BASE_FONT_SIZE = 13
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable"
|
|
||||||
import {
|
|
||||||
computeLineDiff,
|
|
||||||
computeMergeHunks,
|
|
||||||
buildResult,
|
|
||||||
type DiffHunk,
|
|
||||||
type MergeHunk,
|
|
||||||
} from "./merge-diff"
|
|
||||||
import { useSyncScroll } from "./use-sync-scroll"
|
|
||||||
|
|
||||||
import "@/lib/monaco-local"
|
|
||||||
|
|
||||||
const MonacoEditor = dynamic(
|
|
||||||
async () => {
|
|
||||||
const mod = await import("@monaco-editor/react")
|
|
||||||
return { default: mod.default }
|
|
||||||
},
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ThreePaneMergeEditorProps {
|
|
||||||
base: string
|
|
||||||
ours: string
|
|
||||||
theirs: string
|
|
||||||
merged: string
|
|
||||||
language?: string
|
|
||||||
className?: string
|
|
||||||
onContentChange?: (content: string) => void
|
|
||||||
onConflictStatusChange?: (hasUnresolved: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreePaneMergeEditor({
|
|
||||||
base,
|
|
||||||
ours,
|
|
||||||
theirs,
|
|
||||||
language = "plaintext",
|
|
||||||
className,
|
|
||||||
onContentChange,
|
|
||||||
onConflictStatusChange,
|
|
||||||
}: ThreePaneMergeEditorProps) {
|
|
||||||
const t = useTranslations("MergePage")
|
|
||||||
const editorTheme = useMonacoThemeSync()
|
|
||||||
const { zoomLevel } = useZoomLevel()
|
|
||||||
const { registerEditor } = useSyncScroll()
|
|
||||||
|
|
||||||
const leftEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const centerEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const rightEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decorations collections
|
|
||||||
const leftDecorationsRef =
|
|
||||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
|
||||||
const centerDecorationsRef =
|
|
||||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
|
||||||
const rightDecorationsRef =
|
|
||||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
|
||||||
|
|
||||||
// Scroll tick counter — incremented on every scroll to trigger gutter re-render
|
|
||||||
const [scrollTick, setScrollTick] = useState(0)
|
|
||||||
|
|
||||||
// Merge state
|
|
||||||
const mergeHunks = useMemo(
|
|
||||||
() => computeMergeHunks(base, ours, theirs),
|
|
||||||
[base, ours, theirs]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Track which hunks have been applied and which side was chosen
|
|
||||||
const [appliedHunks, setAppliedHunks] = useState<
|
|
||||||
Map<string, "left" | "right">
|
|
||||||
>(new Map())
|
|
||||||
|
|
||||||
// Track ignored hunks
|
|
||||||
const [ignoredHunks, setIgnoredHunks] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const onContentChangeRef = useRef(onContentChange)
|
|
||||||
const onConflictStatusChangeRef = useRef(onConflictStatusChange)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onContentChangeRef.current = onContentChange
|
|
||||||
}, [onContentChange])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onConflictStatusChangeRef.current = onConflictStatusChange
|
|
||||||
}, [onConflictStatusChange])
|
|
||||||
|
|
||||||
// Compute diffs for left/right pane decorations
|
|
||||||
const baseLines = useMemo(() => base.split("\n"), [base])
|
|
||||||
const leftDiffs = useMemo(
|
|
||||||
() => computeLineDiff(baseLines, ours.split("\n")),
|
|
||||||
[baseLines, ours]
|
|
||||||
)
|
|
||||||
const rightDiffs = useMemo(
|
|
||||||
() => computeLineDiff(baseLines, theirs.split("\n")),
|
|
||||||
[baseLines, theirs]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build the result content from base + applied hunks
|
|
||||||
const resultContent = useMemo(
|
|
||||||
() => buildResult(base, mergeHunks, appliedHunks),
|
|
||||||
[base, mergeHunks, appliedHunks]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notify parent of content changes
|
|
||||||
useEffect(() => {
|
|
||||||
onContentChangeRef.current?.(resultContent)
|
|
||||||
}, [resultContent])
|
|
||||||
|
|
||||||
// Notify parent of conflict status
|
|
||||||
useEffect(() => {
|
|
||||||
const hasUnresolved = mergeHunks.some(
|
|
||||||
(h) =>
|
|
||||||
h.type === "conflict" &&
|
|
||||||
!appliedHunks.has(h.id) &&
|
|
||||||
!ignoredHunks.has(h.id)
|
|
||||||
)
|
|
||||||
onConflictStatusChangeRef.current?.(hasUnresolved)
|
|
||||||
}, [mergeHunks, appliedHunks, ignoredHunks])
|
|
||||||
|
|
||||||
// Apply hunk handler
|
|
||||||
const applyHunk = useCallback((id: string, side: "left" | "right") => {
|
|
||||||
setAppliedHunks((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
next.set(id, side)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setIgnoredHunks((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Sync center editor content when result changes
|
|
||||||
useEffect(() => {
|
|
||||||
const editor = centerEditorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
const currentValue = editor.getValue()
|
|
||||||
if (currentValue !== resultContent) {
|
|
||||||
const pos = editor.getPosition()
|
|
||||||
editor.setValue(resultContent)
|
|
||||||
if (pos) editor.setPosition(pos)
|
|
||||||
}
|
|
||||||
}, [resultContent])
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Decorations for left (ours) pane
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const applyLeftDecorations = useCallback(
|
|
||||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
|
||||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
|
||||||
const oursLines = ours.split("\n")
|
|
||||||
|
|
||||||
for (const hunk of leftDiffs) {
|
|
||||||
const range = hunkToEditorRange(hunk, leftDiffs, oursLines.length)
|
|
||||||
if (!range) continue
|
|
||||||
|
|
||||||
const cssClass =
|
|
||||||
hunk.baseCount === 0
|
|
||||||
? "merge-hunk-added-bg"
|
|
||||||
: hunk.newLines.length === 0
|
|
||||||
? "merge-hunk-removed-bg"
|
|
||||||
: "merge-hunk-modified-bg"
|
|
||||||
|
|
||||||
decorations.push({
|
|
||||||
range,
|
|
||||||
options: { isWholeLine: true, className: cssClass },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leftDecorationsRef.current) {
|
|
||||||
leftDecorationsRef.current.set(decorations)
|
|
||||||
} else {
|
|
||||||
leftDecorationsRef.current =
|
|
||||||
editor.createDecorationsCollection(decorations)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[leftDiffs, ours]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Decorations for right (theirs) pane
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const applyRightDecorations = useCallback(
|
|
||||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
|
||||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
|
||||||
const theirsLines = theirs.split("\n")
|
|
||||||
|
|
||||||
for (const hunk of rightDiffs) {
|
|
||||||
const range = hunkToEditorRange(hunk, rightDiffs, theirsLines.length)
|
|
||||||
if (!range) continue
|
|
||||||
|
|
||||||
const cssClass =
|
|
||||||
hunk.baseCount === 0
|
|
||||||
? "merge-hunk-added-bg"
|
|
||||||
: hunk.newLines.length === 0
|
|
||||||
? "merge-hunk-removed-bg"
|
|
||||||
: "merge-hunk-modified-bg"
|
|
||||||
|
|
||||||
decorations.push({
|
|
||||||
range,
|
|
||||||
options: { isWholeLine: true, className: cssClass },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rightDecorationsRef.current) {
|
|
||||||
rightDecorationsRef.current.set(decorations)
|
|
||||||
} else {
|
|
||||||
rightDecorationsRef.current =
|
|
||||||
editor.createDecorationsCollection(decorations)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[rightDiffs, theirs]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Decorations for center (result) pane
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const applyCenterDecorations = useCallback(
|
|
||||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
|
||||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
|
||||||
const currentLines = resultContent.split("\n")
|
|
||||||
|
|
||||||
let resultOffset = 0
|
|
||||||
const sortedHunks = [...mergeHunks].sort(
|
|
||||||
(a, b) => a.baseStart - b.baseStart
|
|
||||||
)
|
|
||||||
let lastBaseEnd = 0
|
|
||||||
|
|
||||||
for (const hunk of sortedHunks) {
|
|
||||||
resultOffset += hunk.baseStart - lastBaseEnd
|
|
||||||
|
|
||||||
const isApplied = appliedHunks.has(hunk.id)
|
|
||||||
const isIgnored = ignoredHunks.has(hunk.id)
|
|
||||||
|
|
||||||
let lineCount: number
|
|
||||||
if (isApplied) {
|
|
||||||
const side = appliedHunks.get(hunk.id)!
|
|
||||||
const diffHunk = side === "left" ? hunk.leftHunk : hunk.rightHunk
|
|
||||||
lineCount = diffHunk ? diffHunk.newLines.length : 0
|
|
||||||
} else {
|
|
||||||
lineCount = hunk.baseCount
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineCount > 0) {
|
|
||||||
const startLine = resultOffset + 1
|
|
||||||
const endLine = resultOffset + lineCount
|
|
||||||
|
|
||||||
let cssClass: string
|
|
||||||
if (isApplied) {
|
|
||||||
cssClass = "merge-hunk-applied-bg"
|
|
||||||
} else if (isIgnored) {
|
|
||||||
cssClass = ""
|
|
||||||
} else if (hunk.type === "conflict") {
|
|
||||||
cssClass = "merge-hunk-conflict-bg"
|
|
||||||
} else {
|
|
||||||
cssClass = "merge-hunk-pending-bg"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cssClass) {
|
|
||||||
decorations.push({
|
|
||||||
range: {
|
|
||||||
startLineNumber: startLine,
|
|
||||||
startColumn: 1,
|
|
||||||
endLineNumber: Math.min(endLine, currentLines.length),
|
|
||||||
endColumn: 1,
|
|
||||||
},
|
|
||||||
options: { isWholeLine: true, className: cssClass },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultOffset += lineCount
|
|
||||||
lastBaseEnd = hunk.baseStart + hunk.baseCount
|
|
||||||
}
|
|
||||||
|
|
||||||
if (centerDecorationsRef.current) {
|
|
||||||
centerDecorationsRef.current.set(decorations)
|
|
||||||
} else {
|
|
||||||
centerDecorationsRef.current =
|
|
||||||
editor.createDecorationsCollection(decorations)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mergeHunks, appliedHunks, ignoredHunks, resultContent]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Apply decorations when state changes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
useEffect(() => {
|
|
||||||
if (leftEditorRef.current) {
|
|
||||||
applyLeftDecorations(leftEditorRef.current)
|
|
||||||
}
|
|
||||||
}, [applyLeftDecorations])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (centerEditorRef.current) {
|
|
||||||
applyCenterDecorations(centerEditorRef.current)
|
|
||||||
}
|
|
||||||
}, [applyCenterDecorations])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rightEditorRef.current) {
|
|
||||||
applyRightDecorations(rightEditorRef.current)
|
|
||||||
}
|
|
||||||
}, [applyRightDecorations])
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Editor mount handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const handleLeftMount: OnMount = useCallback(
|
|
||||||
(editor) => {
|
|
||||||
leftEditorRef.current = editor
|
|
||||||
registerEditor(editor, 0)
|
|
||||||
applyLeftDecorations(editor)
|
|
||||||
|
|
||||||
// Also listen to left editor scroll to update gutter
|
|
||||||
editor.onDidScrollChange(() => {
|
|
||||||
setScrollTick((n) => n + 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger initial gutter render after editor is ready
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setScrollTick((n) => n + 1)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[registerEditor, applyLeftDecorations]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCenterMount: OnMount = useCallback(
|
|
||||||
(editor) => {
|
|
||||||
centerEditorRef.current = editor
|
|
||||||
registerEditor(editor, 1)
|
|
||||||
applyCenterDecorations(editor)
|
|
||||||
|
|
||||||
editor.onDidChangeModelContent(() => {
|
|
||||||
const value = editor.getValue()
|
|
||||||
onContentChangeRef.current?.(value)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[registerEditor, applyCenterDecorations]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleRightMount: OnMount = useCallback(
|
|
||||||
(editor) => {
|
|
||||||
rightEditorRef.current = editor
|
|
||||||
registerEditor(editor, 2)
|
|
||||||
applyRightDecorations(editor)
|
|
||||||
|
|
||||||
// Also listen to right editor scroll to update gutter
|
|
||||||
editor.onDidScrollChange(() => {
|
|
||||||
setScrollTick((n) => n + 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger initial gutter render after editor is ready
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setScrollTick((n) => n + 1)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[registerEditor, applyRightDecorations]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Compute gutter arrow items (line numbers only, positions computed at render)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const leftGutterItems = useMemo(() => {
|
|
||||||
const oursLines = ours.split("\n")
|
|
||||||
const items: Array<{
|
|
||||||
hunk: MergeHunk
|
|
||||||
lineNumber: number
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const hunk of mergeHunks) {
|
|
||||||
if (!hunk.leftHunk) continue
|
|
||||||
if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue
|
|
||||||
|
|
||||||
const range = hunkToEditorRange(
|
|
||||||
hunk.leftHunk,
|
|
||||||
leftDiffs,
|
|
||||||
oursLines.length
|
|
||||||
)
|
|
||||||
if (!range) continue
|
|
||||||
|
|
||||||
items.push({ hunk, lineNumber: range.startLineNumber })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}, [mergeHunks, appliedHunks, ignoredHunks, leftDiffs, ours])
|
|
||||||
|
|
||||||
const rightGutterItems = useMemo(() => {
|
|
||||||
const theirsLines = theirs.split("\n")
|
|
||||||
const items: Array<{
|
|
||||||
hunk: MergeHunk
|
|
||||||
lineNumber: number
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const hunk of mergeHunks) {
|
|
||||||
if (!hunk.rightHunk) continue
|
|
||||||
if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue
|
|
||||||
|
|
||||||
const range = hunkToEditorRange(
|
|
||||||
hunk.rightHunk,
|
|
||||||
rightDiffs,
|
|
||||||
theirsLines.length
|
|
||||||
)
|
|
||||||
if (!range) continue
|
|
||||||
|
|
||||||
items.push({ hunk, lineNumber: range.startLineNumber })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}, [mergeHunks, appliedHunks, ignoredHunks, rightDiffs, theirs])
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Toolbar actions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const handleApplyAllNonConflicting = useCallback(() => {
|
|
||||||
setAppliedHunks((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
for (const hunk of mergeHunks) {
|
|
||||||
if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) {
|
|
||||||
next.set(hunk.id, "left")
|
|
||||||
} else if (
|
|
||||||
hunk.type === "right-only" &&
|
|
||||||
hunk.rightHunk &&
|
|
||||||
!next.has(hunk.id)
|
|
||||||
) {
|
|
||||||
next.set(hunk.id, "right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [mergeHunks])
|
|
||||||
|
|
||||||
const handleApplyLeftNonConflicting = useCallback(() => {
|
|
||||||
setAppliedHunks((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
for (const hunk of mergeHunks) {
|
|
||||||
if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) {
|
|
||||||
next.set(hunk.id, "left")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [mergeHunks])
|
|
||||||
|
|
||||||
const handleApplyRightNonConflicting = useCallback(() => {
|
|
||||||
setAppliedHunks((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
for (const hunk of mergeHunks) {
|
|
||||||
if (
|
|
||||||
hunk.type === "right-only" &&
|
|
||||||
hunk.rightHunk &&
|
|
||||||
!next.has(hunk.id)
|
|
||||||
) {
|
|
||||||
next.set(hunk.id, "right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [mergeHunks])
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Statistics
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const unresolvedConflicts = mergeHunks.filter(
|
|
||||||
(h) =>
|
|
||||||
h.type === "conflict" &&
|
|
||||||
!appliedHunks.has(h.id) &&
|
|
||||||
!ignoredHunks.has(h.id)
|
|
||||||
).length
|
|
||||||
const pendingNonConflicts = mergeHunks.filter(
|
|
||||||
(h) =>
|
|
||||||
h.type !== "conflict" &&
|
|
||||||
!appliedHunks.has(h.id) &&
|
|
||||||
!ignoredHunks.has(h.id)
|
|
||||||
).length
|
|
||||||
const totalChanges = mergeHunks.length
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Editor options
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const editorOptions =
|
|
||||||
useMemo<MonacoEditorNs.IStandaloneEditorConstructionOptions>(
|
|
||||||
() => ({
|
|
||||||
fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
automaticLayout: true,
|
|
||||||
lineNumbers: "on",
|
|
||||||
glyphMargin: true,
|
|
||||||
folding: false,
|
|
||||||
wordWrap: "off",
|
|
||||||
overviewRulerLanes: 0,
|
|
||||||
}),
|
|
||||||
[zoomLevel]
|
|
||||||
)
|
|
||||||
|
|
||||||
const readonlyOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
...editorOptions,
|
|
||||||
readOnly: true,
|
|
||||||
domReadOnly: true,
|
|
||||||
}),
|
|
||||||
[editorOptions]
|
|
||||||
)
|
|
||||||
|
|
||||||
const loadingEl = (
|
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
|
||||||
Loading editor...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center border-b bg-muted/50 px-3 py-1.5">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground">
|
|
||||||
{t("localVersion")}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
|
|
||||||
{t("result")}
|
|
||||||
{unresolvedConflicts > 0 && (
|
|
||||||
<span className="text-red-500">
|
|
||||||
({unresolvedConflicts}{" "}
|
|
||||||
{unresolvedConflicts === 1 ? "conflict" : "conflicts"})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{pendingNonConflicts > 0 && (
|
|
||||||
<span className="text-amber-500">
|
|
||||||
({pendingNonConflicts} pending)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{totalChanges > 0 &&
|
|
||||||
unresolvedConflicts === 0 &&
|
|
||||||
pendingNonConflicts === 0 && (
|
|
||||||
<span className="text-green-500">
|
|
||||||
<CheckCheck className="inline h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{pendingNonConflicts > 0 && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-5 px-1.5 text-[10px]"
|
|
||||||
onClick={handleApplyLeftNonConflicting}
|
|
||||||
>
|
|
||||||
<ArrowRight className="mr-0.5 h-2.5 w-2.5" />
|
|
||||||
{t("applyLeftNonConflicting")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-5 px-1.5 text-[10px]"
|
|
||||||
onClick={handleApplyAllNonConflicting}
|
|
||||||
>
|
|
||||||
{t("applyAllNonConflicting")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-5 px-1.5 text-[10px]"
|
|
||||||
onClick={handleApplyRightNonConflicting}
|
|
||||||
>
|
|
||||||
{t("applyRightNonConflicting")}
|
|
||||||
<ArrowLeft className="ml-0.5 h-2.5 w-2.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-muted-foreground">
|
|
||||||
{t("remoteVersion")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */}
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
|
||||||
{/* Left: Ours (local) + arrow gutter */}
|
|
||||||
<ResizablePanel defaultSize={34} minSize={15}>
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<MonacoEditor
|
|
||||||
value={ours}
|
|
||||||
language={language}
|
|
||||||
theme={editorTheme}
|
|
||||||
beforeMount={defineMonacoThemes}
|
|
||||||
onMount={handleLeftMount}
|
|
||||||
loading={loadingEl}
|
|
||||||
options={readonlyOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ArrowGutter
|
|
||||||
items={leftGutterItems}
|
|
||||||
direction="right"
|
|
||||||
editorRef={leftEditorRef}
|
|
||||||
scrollTick={scrollTick}
|
|
||||||
onApply={(id) => applyHunk(id, "left")}
|
|
||||||
title={t("acceptLocal")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle />
|
|
||||||
|
|
||||||
{/* Center: Result (editable) */}
|
|
||||||
<ResizablePanel defaultSize={32} minSize={15}>
|
|
||||||
<MonacoEditor
|
|
||||||
defaultValue={base}
|
|
||||||
language={language}
|
|
||||||
theme={editorTheme}
|
|
||||||
beforeMount={defineMonacoThemes}
|
|
||||||
onMount={handleCenterMount}
|
|
||||||
loading={loadingEl}
|
|
||||||
options={editorOptions}
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle />
|
|
||||||
|
|
||||||
{/* Right: arrow gutter + Theirs (remote) */}
|
|
||||||
<ResizablePanel defaultSize={34} minSize={15}>
|
|
||||||
<div className="flex h-full">
|
|
||||||
<ArrowGutter
|
|
||||||
items={rightGutterItems}
|
|
||||||
direction="left"
|
|
||||||
editorRef={rightEditorRef}
|
|
||||||
scrollTick={scrollTick}
|
|
||||||
onApply={(id) => applyHunk(id, "right")}
|
|
||||||
title={t("acceptRemote")}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<MonacoEditor
|
|
||||||
value={theirs}
|
|
||||||
language={language}
|
|
||||||
theme={editorTheme}
|
|
||||||
beforeMount={defineMonacoThemes}
|
|
||||||
onMount={handleRightMount}
|
|
||||||
loading={loadingEl}
|
|
||||||
options={readonlyOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Arrow Gutter Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ArrowGutterProps {
|
|
||||||
items: Array<{ hunk: MergeHunk; lineNumber: number }>
|
|
||||||
direction: "left" | "right"
|
|
||||||
editorRef: React.RefObject<MonacoEditorNs.IStandaloneCodeEditor | null>
|
|
||||||
scrollTick: number // triggers re-render on scroll
|
|
||||||
onApply: (hunkId: string) => void
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArrowGutter({
|
|
||||||
items,
|
|
||||||
direction,
|
|
||||||
editorRef,
|
|
||||||
scrollTick,
|
|
||||||
onApply,
|
|
||||||
title,
|
|
||||||
}: ArrowGutterProps) {
|
|
||||||
const editor = editorRef.current
|
|
||||||
|
|
||||||
const positioned = useMemo(() => {
|
|
||||||
if (!editor) return []
|
|
||||||
return items
|
|
||||||
.map(({ hunk, lineNumber }) => {
|
|
||||||
const pos = editor.getScrolledVisiblePosition({
|
|
||||||
lineNumber,
|
|
||||||
column: 1,
|
|
||||||
})
|
|
||||||
return pos ? { hunk, top: pos.top } : null
|
|
||||||
})
|
|
||||||
.filter((item): item is { hunk: MergeHunk; top: number } => item !== null)
|
|
||||||
// scrollTick is included to recompute on scroll
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [editor, items, scrollTick])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="merge-gutter-column">
|
|
||||||
{positioned.map(({ hunk, top }) => (
|
|
||||||
<button
|
|
||||||
key={hunk.id}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"merge-gutter-arrow-btn",
|
|
||||||
hunk.type === "conflict"
|
|
||||||
? "merge-gutter-arrow-conflict"
|
|
||||||
: "merge-gutter-arrow-accept"
|
|
||||||
)}
|
|
||||||
style={{ top: `${top}px` }}
|
|
||||||
onClick={() => onApply(hunk.id)}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{direction === "right" ? "\u00BB" : "\u00AB"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a DiffHunk to an editor range in the modified file,
|
|
||||||
* accounting for offset from previous hunks.
|
|
||||||
*/
|
|
||||||
function hunkToEditorRange(
|
|
||||||
hunk: DiffHunk,
|
|
||||||
allHunks: DiffHunk[],
|
|
||||||
totalLines: number
|
|
||||||
): IRange | null {
|
|
||||||
let offset = 0
|
|
||||||
for (const h of allHunks) {
|
|
||||||
if (h.baseStart >= hunk.baseStart) break
|
|
||||||
offset += h.newLines.length - h.baseCount
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hunk.newLines.length > 0) {
|
|
||||||
const start = hunk.baseStart + offset + 1
|
|
||||||
const end = start + hunk.newLines.length - 1
|
|
||||||
return {
|
|
||||||
startLineNumber: start,
|
|
||||||
startColumn: 1,
|
|
||||||
endLineNumber: Math.min(end, totalLines),
|
|
||||||
endColumn: 1,
|
|
||||||
}
|
|
||||||
} else if (hunk.baseCount > 0) {
|
|
||||||
const line = Math.min(hunk.baseStart + offset + 1, totalLines)
|
|
||||||
return {
|
|
||||||
startLineNumber: line,
|
|
||||||
startColumn: 1,
|
|
||||||
endLineNumber: line,
|
|
||||||
endColumn: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useCallback, useRef } from "react"
|
|
||||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
|
||||||
|
|
||||||
type EditorInstance = MonacoEditorNs.IStandaloneCodeEditor
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to synchronize scrolling between multiple Monaco editors.
|
|
||||||
* Uses a flag to prevent infinite scroll loops.
|
|
||||||
*/
|
|
||||||
export function useSyncScroll() {
|
|
||||||
const isSyncing = useRef(false)
|
|
||||||
const editorsRef = useRef<EditorInstance[]>([])
|
|
||||||
|
|
||||||
const registerEditor = useCallback(
|
|
||||||
(editor: EditorInstance, index: number) => {
|
|
||||||
editorsRef.current[index] = editor
|
|
||||||
|
|
||||||
editor.onDidScrollChange(() => {
|
|
||||||
if (isSyncing.current) return
|
|
||||||
isSyncing.current = true
|
|
||||||
|
|
||||||
const scrollTop = editor.getScrollTop()
|
|
||||||
const scrollLeft = editor.getScrollLeft()
|
|
||||||
|
|
||||||
for (let i = 0; i < editorsRef.current.length; i++) {
|
|
||||||
if (i !== index && editorsRef.current[i]) {
|
|
||||||
editorsRef.current[i].setScrollPosition({
|
|
||||||
scrollTop,
|
|
||||||
scrollLeft,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use rAF to release the sync flag after all scroll events settle
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
isSyncing.current = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return { registerEditor }
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
|
||||||
import { ShadcnLauncher } from "./shadcn/shadcn-launcher"
|
|
||||||
|
|
||||||
export function ProjectBootWorkspace() {
|
|
||||||
const t = useTranslations("ProjectBoot")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="shadcn" className="flex h-full flex-col gap-0">
|
|
||||||
<div className="shrink-0 border-b px-4 py-2">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="shadcn">{t("tabs.shadcn")}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="shadcn" className="min-h-0 flex-1">
|
|
||||||
<ShadcnLauncher />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
// ── Preset encoding/decoding (matches shadcn v2 format) ─────────────
|
|
||||||
|
|
||||||
const PRESET_STYLES = ["nova", "vega", "maia", "lyra", "mira"] as const
|
|
||||||
|
|
||||||
const PRESET_BASE_COLORS = [
|
|
||||||
"neutral",
|
|
||||||
"stone",
|
|
||||||
"zinc",
|
|
||||||
"gray",
|
|
||||||
"mauve",
|
|
||||||
"olive",
|
|
||||||
"mist",
|
|
||||||
"taupe",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PRESET_THEMES = [
|
|
||||||
"neutral",
|
|
||||||
"stone",
|
|
||||||
"zinc",
|
|
||||||
"gray",
|
|
||||||
"amber",
|
|
||||||
"blue",
|
|
||||||
"cyan",
|
|
||||||
"emerald",
|
|
||||||
"fuchsia",
|
|
||||||
"green",
|
|
||||||
"indigo",
|
|
||||||
"lime",
|
|
||||||
"orange",
|
|
||||||
"pink",
|
|
||||||
"purple",
|
|
||||||
"red",
|
|
||||||
"rose",
|
|
||||||
"sky",
|
|
||||||
"teal",
|
|
||||||
"violet",
|
|
||||||
"yellow",
|
|
||||||
"mauve",
|
|
||||||
"olive",
|
|
||||||
"mist",
|
|
||||||
"taupe",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PRESET_ICON_LIBRARIES = [
|
|
||||||
"lucide",
|
|
||||||
"hugeicons",
|
|
||||||
"tabler",
|
|
||||||
"phosphor",
|
|
||||||
"remixicon",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PRESET_FONTS = [
|
|
||||||
"inter",
|
|
||||||
"noto-sans",
|
|
||||||
"nunito-sans",
|
|
||||||
"figtree",
|
|
||||||
"roboto",
|
|
||||||
"raleway",
|
|
||||||
"dm-sans",
|
|
||||||
"public-sans",
|
|
||||||
"outfit",
|
|
||||||
"jetbrains-mono",
|
|
||||||
"geist",
|
|
||||||
"geist-mono",
|
|
||||||
"lora",
|
|
||||||
"merriweather",
|
|
||||||
"playfair-display",
|
|
||||||
"noto-serif",
|
|
||||||
"roboto-slab",
|
|
||||||
"oxanium",
|
|
||||||
"manrope",
|
|
||||||
"space-grotesk",
|
|
||||||
"montserrat",
|
|
||||||
"ibm-plex-sans",
|
|
||||||
"source-sans-3",
|
|
||||||
"instrument-sans",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PRESET_FONT_HEADINGS = ["inherit", ...PRESET_FONTS] as const
|
|
||||||
|
|
||||||
const PRESET_RADII = ["default", "none", "small", "medium", "large"] as const
|
|
||||||
|
|
||||||
const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
|
|
||||||
|
|
||||||
const PRESET_MENU_COLORS = [
|
|
||||||
"default",
|
|
||||||
"inverted",
|
|
||||||
"default-translucent",
|
|
||||||
"inverted-translucent",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** V2 field layout for bit-packing (order must match shadcn exactly) */
|
|
||||||
const PRESET_FIELDS_V2 = [
|
|
||||||
{ key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 },
|
|
||||||
{ key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 },
|
|
||||||
{ key: "radius", values: PRESET_RADII, bits: 4 },
|
|
||||||
{ key: "font", values: PRESET_FONTS, bits: 6 },
|
|
||||||
{ key: "iconLibrary", values: PRESET_ICON_LIBRARIES, bits: 6 },
|
|
||||||
{ key: "theme", values: PRESET_THEMES, bits: 6 },
|
|
||||||
{ key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 },
|
|
||||||
{ key: "style", values: PRESET_STYLES, bits: 6 },
|
|
||||||
{ key: "chartColor", values: PRESET_THEMES, bits: 6 },
|
|
||||||
{ key: "fontHeading", values: PRESET_FONT_HEADINGS, bits: 5 },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
|
|
||||||
function toBase62(num: number): string {
|
|
||||||
if (num === 0) return "0"
|
|
||||||
let result = ""
|
|
||||||
let n = num
|
|
||||||
while (n > 0) {
|
|
||||||
result = BASE62[n % 62] + result
|
|
||||||
n = Math.floor(n / 62)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encode a preset config into a compact base62 code (v2 format). */
|
|
||||||
export function encodePreset(config: PresetCodeConfig): string {
|
|
||||||
const defaults: Record<string, string> = {
|
|
||||||
menuColor: "default",
|
|
||||||
menuAccent: "subtle",
|
|
||||||
radius: "default",
|
|
||||||
font: "inter",
|
|
||||||
iconLibrary: "lucide",
|
|
||||||
theme: "neutral",
|
|
||||||
baseColor: "neutral",
|
|
||||||
style: "nova",
|
|
||||||
chartColor: config.theme ?? "neutral",
|
|
||||||
fontHeading: "inherit",
|
|
||||||
}
|
|
||||||
const merged: Record<string, string> = { ...defaults }
|
|
||||||
for (const [k, v] of Object.entries(config)) {
|
|
||||||
if (v) merged[k] = v
|
|
||||||
}
|
|
||||||
let bits = 0
|
|
||||||
let offset = 0
|
|
||||||
for (const field of PRESET_FIELDS_V2) {
|
|
||||||
const idx = (field.values as readonly string[]).indexOf(
|
|
||||||
merged[field.key] ?? ""
|
|
||||||
)
|
|
||||||
bits += (idx === -1 ? 0 : idx) * 2 ** offset
|
|
||||||
offset += field.bits
|
|
||||||
}
|
|
||||||
return "b" + toBase62(bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config types ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fields that are encoded into the preset code (sent to CLI & preview). */
|
|
||||||
export interface PresetCodeConfig {
|
|
||||||
style: string
|
|
||||||
baseColor: string
|
|
||||||
theme: string
|
|
||||||
chartColor: string
|
|
||||||
iconLibrary: string
|
|
||||||
font: string
|
|
||||||
fontHeading: string
|
|
||||||
radius: string
|
|
||||||
menuAccent: string
|
|
||||||
menuColor: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full UI config (preset fields + non-preset fields like base/template). */
|
|
||||||
export interface ShadcnPresetConfig extends PresetCodeConfig {
|
|
||||||
base: string
|
|
||||||
template: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_PRESET_CONFIG: ShadcnPresetConfig = {
|
|
||||||
base: "radix",
|
|
||||||
style: "nova",
|
|
||||||
baseColor: "neutral",
|
|
||||||
theme: "orange",
|
|
||||||
chartColor: "orange",
|
|
||||||
iconLibrary: "lucide",
|
|
||||||
font: "inter",
|
|
||||||
fontHeading: "inherit",
|
|
||||||
radius: "default",
|
|
||||||
menuAccent: "subtle",
|
|
||||||
menuColor: "default",
|
|
||||||
template: "start",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI option arrays ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const BASE_OPTIONS = [
|
|
||||||
{ value: "radix", label: "Radix" },
|
|
||||||
{ value: "base", label: "Base" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const STYLE_OPTIONS = PRESET_STYLES.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const BASE_COLOR_OPTIONS = PRESET_BASE_COLORS.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const THEME_OPTIONS = PRESET_THEMES.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const ICON_LIBRARY_OPTIONS = PRESET_ICON_LIBRARIES.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const FONT_OPTIONS = PRESET_FONTS.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v
|
|
||||||
.split("-")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" "),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const FONT_HEADING_OPTIONS = PRESET_FONT_HEADINGS.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label:
|
|
||||||
v === "inherit"
|
|
||||||
? "Inherit"
|
|
||||||
: v
|
|
||||||
.split("-")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" "),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const MENU_ACCENT_OPTIONS = PRESET_MENU_ACCENTS.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const MENU_COLOR_OPTIONS = PRESET_MENU_COLORS.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v
|
|
||||||
.split("-")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" "),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const RADIUS_OPTIONS = PRESET_RADII.map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const TEMPLATE_OPTIONS = [{ value: "start", label: "Start" }]
|
|
||||||
|
|
||||||
export const FRAMEWORK_OPTIONS = [
|
|
||||||
{ value: "next", label: "Next.js" },
|
|
||||||
{ value: "vite", label: "Vite" },
|
|
||||||
{ value: "start", label: "TanStack Start" },
|
|
||||||
{ value: "react-router", label: "React Router" },
|
|
||||||
{ value: "laravel", label: "Laravel" },
|
|
||||||
{ value: "astro", label: "Astro" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const PACKAGE_MANAGER_OPTIONS = [
|
|
||||||
{ value: "pnpm", label: "pnpm" },
|
|
||||||
{ value: "npm", label: "npm" },
|
|
||||||
{ value: "yarn", label: "yarn" },
|
|
||||||
{ value: "bun", label: "bun" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── URL builders ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build the preview iframe URL using a preset code. */
|
|
||||||
export function buildPreviewUrl(_base: string, presetCode: string): string {
|
|
||||||
return `https://ui.shadcn.com/preview/radix/preview-02?preset=${presetCode}`
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
FolderOpen,
|
|
||||||
ChevronsUpDown,
|
|
||||||
CircleCheck,
|
|
||||||
CircleX,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldLabel,
|
|
||||||
FieldTitle,
|
|
||||||
} from "@/components/ui/field"
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
|
||||||
import {
|
|
||||||
createShadcnProject,
|
|
||||||
openFolder,
|
|
||||||
detectPackageManager,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
|
|
||||||
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
|
||||||
import {
|
|
||||||
BASE_OPTIONS,
|
|
||||||
FRAMEWORK_OPTIONS,
|
|
||||||
PACKAGE_MANAGER_OPTIONS,
|
|
||||||
} from "./constants"
|
|
||||||
|
|
||||||
interface CreateProjectDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
presetCode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateProjectDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
presetCode,
|
|
||||||
}: CreateProjectDialogProps) {
|
|
||||||
const t = useTranslations("ProjectBoot")
|
|
||||||
const [projectName, setProjectName] = useState("my-app")
|
|
||||||
const [framework, setFramework] = useState("next")
|
|
||||||
const [packageManager, setPackageManager] = useState("pnpm")
|
|
||||||
const [saveDirectory, setSaveDirectory] = useState("")
|
|
||||||
const [base, setBase] = useState("radix")
|
|
||||||
const [rtl, setRtl] = useState(false)
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [browserOpen, setBrowserOpen] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [pmVersion, setPmVersion] = useState<string | null>(null)
|
|
||||||
const [pmInstalled, setPmInstalled] = useState<boolean | null>(null)
|
|
||||||
const [pmChecking, setPmChecking] = useState(false)
|
|
||||||
|
|
||||||
const checkPackageManager = useCallback(async (name: string) => {
|
|
||||||
setPmChecking(true)
|
|
||||||
setPmInstalled(null)
|
|
||||||
setPmVersion(null)
|
|
||||||
try {
|
|
||||||
const info = await detectPackageManager(name)
|
|
||||||
setPmInstalled(info.installed)
|
|
||||||
setPmVersion(info.version ?? null)
|
|
||||||
} catch {
|
|
||||||
setPmInstalled(false)
|
|
||||||
setPmVersion(null)
|
|
||||||
} finally {
|
|
||||||
setPmChecking(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
checkPackageManager(packageManager)
|
|
||||||
}
|
|
||||||
}, [open, packageManager, checkPackageManager])
|
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
|
||||||
if (isDesktop()) {
|
|
||||||
const result = await openFileDialog({ directory: true, multiple: false })
|
|
||||||
if (!result) return
|
|
||||||
const selected = Array.isArray(result) ? result[0] : result
|
|
||||||
setSaveDirectory(selected)
|
|
||||||
} else {
|
|
||||||
setBrowserOpen(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
setError(null)
|
|
||||||
setCreating(true)
|
|
||||||
try {
|
|
||||||
const projectPath = await createShadcnProject({
|
|
||||||
projectName,
|
|
||||||
template: framework,
|
|
||||||
presetCode,
|
|
||||||
packageManager,
|
|
||||||
targetDir: saveDirectory,
|
|
||||||
})
|
|
||||||
toast.success(t("toasts.createSuccess"))
|
|
||||||
onOpenChange(false)
|
|
||||||
resetForm()
|
|
||||||
await openFolder(projectPath)
|
|
||||||
} catch (err) {
|
|
||||||
const appErr = extractAppCommandError(err)
|
|
||||||
const message =
|
|
||||||
appErr?.code === "already_exists"
|
|
||||||
? t("errors.directoryExists")
|
|
||||||
: appErr?.code === "external_command_failed"
|
|
||||||
? t("errors.commandFailed")
|
|
||||||
: toErrorMessage(err)
|
|
||||||
setError(message)
|
|
||||||
toast.error(t("toasts.createFailed"), { description: message })
|
|
||||||
} finally {
|
|
||||||
setCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setProjectName("my-app")
|
|
||||||
setFramework("next")
|
|
||||||
setPackageManager("pnpm")
|
|
||||||
setSaveDirectory("")
|
|
||||||
setBase("radix")
|
|
||||||
setRtl(false)
|
|
||||||
setAdvancedOpen(false)
|
|
||||||
setError(null)
|
|
||||||
setPmVersion(null)
|
|
||||||
setPmInstalled(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCreate =
|
|
||||||
projectName.trim().length > 0 &&
|
|
||||||
saveDirectory.trim().length > 0 &&
|
|
||||||
pmInstalled === true
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
onOpenChange(v)
|
|
||||||
if (!v) resetForm()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.projectName")}</Label>
|
|
||||||
<Input
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
|
||||||
placeholder={t("createDialog.projectNamePlaceholder")}
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.saveDirectory")}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={saveDirectory}
|
|
||||||
onChange={(e) => setSaveDirectory(e.target.value)}
|
|
||||||
placeholder={t("createDialog.saveDirectoryPlaceholder")}
|
|
||||||
disabled={creating}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleBrowse}
|
|
||||||
disabled={creating}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{saveDirectory && projectName.trim() && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("createDialog.projectPath", {
|
|
||||||
path: `${saveDirectory}/${projectName.trim()}`,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.packageManager")}</Label>
|
|
||||||
<Tabs
|
|
||||||
value={packageManager}
|
|
||||||
onValueChange={setPackageManager}
|
|
||||||
className="gap-0"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full">
|
|
||||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={opt.value}
|
|
||||||
value={opt.value}
|
|
||||||
className="flex-1"
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
|
||||||
<TabsContent key={opt.value} value={opt.value}>
|
|
||||||
<div className="flex h-8 items-center gap-1.5 text-sm">
|
|
||||||
{pmChecking ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("createDialog.pmChecking")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : pmInstalled ? (
|
|
||||||
<>
|
|
||||||
<CircleCheck className="size-3.5 text-emerald-500" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{opt.label} v{pmVersion}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CircleX className="size-3.5 text-destructive" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("createDialog.pmNotInstalled")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="size-3.5" />
|
|
||||||
{t("createDialog.advancedOptions")}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.frameworkTemplate")}</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={framework}
|
|
||||||
onValueChange={setFramework}
|
|
||||||
disabled={creating}
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{FRAMEWORK_OPTIONS.map((opt) => (
|
|
||||||
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldContent>
|
|
||||||
<FieldTitle>{opt.label}</FieldTitle>
|
|
||||||
</FieldContent>
|
|
||||||
<RadioGroupItem
|
|
||||||
value={opt.value}
|
|
||||||
id={`fw-${opt.value}`}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.base")}</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={base}
|
|
||||||
onValueChange={setBase}
|
|
||||||
disabled={creating}
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{BASE_OPTIONS.map((opt) => (
|
|
||||||
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldContent>
|
|
||||||
<FieldTitle>{opt.label}</FieldTitle>
|
|
||||||
</FieldContent>
|
|
||||||
<RadioGroupItem
|
|
||||||
value={opt.value}
|
|
||||||
id={`base-${opt.value}`}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
|
|
||||||
<Switch
|
|
||||||
checked={rtl}
|
|
||||||
onCheckedChange={setRtl}
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t("createDialog.enableRtl")}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("createDialog.enableRtlDescription")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
{t("createDialog.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={!canCreate || creating}>
|
|
||||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{creating ? t("createDialog.creating") : t("createDialog.create")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<DirectoryBrowserDialog
|
|
||||||
open={browserOpen}
|
|
||||||
onOpenChange={setBrowserOpen}
|
|
||||||
onSelect={(path) => setSaveDirectory(path)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
|
||||||
STYLE_OPTIONS,
|
|
||||||
BASE_COLOR_OPTIONS,
|
|
||||||
THEME_OPTIONS,
|
|
||||||
ICON_LIBRARY_OPTIONS,
|
|
||||||
FONT_OPTIONS,
|
|
||||||
FONT_HEADING_OPTIONS,
|
|
||||||
MENU_ACCENT_OPTIONS,
|
|
||||||
MENU_COLOR_OPTIONS,
|
|
||||||
RADIUS_OPTIONS,
|
|
||||||
TEMPLATE_OPTIONS,
|
|
||||||
type ShadcnPresetConfig,
|
|
||||||
} from "./constants"
|
|
||||||
import { CreateProjectDialog } from "./create-project-dialog"
|
|
||||||
|
|
||||||
interface ShadcnConfigPanelProps {
|
|
||||||
config: ShadcnPresetConfig
|
|
||||||
onConfigChange: (key: keyof ShadcnPresetConfig, value: string) => void
|
|
||||||
presetCode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
onChange: (v: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
||||||
<Select value={value} onValueChange={onChange}>
|
|
||||||
<SelectTrigger className="h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<h4 className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
|
||||||
{children}
|
|
||||||
</h4>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShadcnConfigPanel({
|
|
||||||
config,
|
|
||||||
onConfigChange,
|
|
||||||
presetCode,
|
|
||||||
}: ShadcnConfigPanelProps) {
|
|
||||||
const t = useTranslations("ProjectBoot")
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
|
|
||||||
const field = (
|
|
||||||
key: keyof ShadcnPresetConfig,
|
|
||||||
i18nKey: string,
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
) => (
|
|
||||||
<ConfigField
|
|
||||||
label={t(i18nKey as Parameters<typeof t>[0])}
|
|
||||||
value={config[key]}
|
|
||||||
options={options}
|
|
||||||
onChange={(v) => onConfigChange(key, v)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<ScrollArea className="min-h-0 flex-1 px-4 py-3">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Style & Template */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SectionHeader>{t("config.sectionStyle")}</SectionHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
|
||||||
{field("style", "config.style", STYLE_OPTIONS)}
|
|
||||||
{field("template", "config.template", TEMPLATE_OPTIONS)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Colors */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SectionHeader>{t("config.sectionColors")}</SectionHeader>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{field("baseColor", "config.baseColor", BASE_COLOR_OPTIONS)}
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
|
||||||
{field("theme", "config.theme", THEME_OPTIONS)}
|
|
||||||
{field("chartColor", "config.chartColor", THEME_OPTIONS)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Typography */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SectionHeader>{t("config.sectionTypography")}</SectionHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
|
||||||
{field("font", "config.font", FONT_OPTIONS)}
|
|
||||||
{field("fontHeading", "config.fontHeading", FONT_HEADING_OPTIONS)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Interface */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SectionHeader>{t("config.sectionInterface")}</SectionHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
|
||||||
{field("iconLibrary", "config.iconLibrary", ICON_LIBRARY_OPTIONS)}
|
|
||||||
{field("radius", "config.radius", RADIUS_OPTIONS)}
|
|
||||||
{field("menuAccent", "config.menuAccent", MENU_ACCENT_OPTIONS)}
|
|
||||||
{field("menuColor", "config.menuColor", MENU_COLOR_OPTIONS)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t px-4 py-3">
|
|
||||||
<Button className="w-full" onClick={() => setCreateOpen(true)}>
|
|
||||||
{t("config.createProject")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateProjectDialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
presetCode={presetCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, useRef, useEffect } from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ShadcnConfigPanel } from "./shadcn-config-panel"
|
|
||||||
import { ShadcnPreview } from "./shadcn-preview"
|
|
||||||
import {
|
|
||||||
DEFAULT_PRESET_CONFIG,
|
|
||||||
encodePreset,
|
|
||||||
buildPreviewUrl,
|
|
||||||
type ShadcnPresetConfig,
|
|
||||||
} from "./constants"
|
|
||||||
|
|
||||||
const MIN_WIDTH = 260
|
|
||||||
const MAX_WIDTH = 420
|
|
||||||
const DEFAULT_WIDTH = 320
|
|
||||||
|
|
||||||
export function ShadcnLauncher() {
|
|
||||||
const [config, setConfig] = useState<ShadcnPresetConfig>(
|
|
||||||
DEFAULT_PRESET_CONFIG
|
|
||||||
)
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_WIDTH)
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
const startXRef = useRef(0)
|
|
||||||
const startWidthRef = useRef(0)
|
|
||||||
|
|
||||||
const presetCode = useMemo(() => encodePreset(config), [config])
|
|
||||||
const previewUrl = useMemo(
|
|
||||||
() => buildPreviewUrl(config.base, presetCode),
|
|
||||||
[config.base, presetCode]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateConfig = (key: keyof ShadcnPresetConfig, value: string) => {
|
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsDragging(true)
|
|
||||||
startXRef.current = e.clientX
|
|
||||||
startWidthRef.current = sidebarWidth
|
|
||||||
},
|
|
||||||
[sidebarWidth]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDragging) return
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const newWidth = Math.min(
|
|
||||||
MAX_WIDTH,
|
|
||||||
Math.max(
|
|
||||||
MIN_WIDTH,
|
|
||||||
startWidthRef.current + (e.clientX - startXRef.current)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setSidebarWidth(newWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove)
|
|
||||||
document.addEventListener("mouseup", handleMouseUp)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove)
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp)
|
|
||||||
}
|
|
||||||
}, [isDragging])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div style={{ width: sidebarWidth }} className="shrink-0">
|
|
||||||
<ShadcnConfigPanel
|
|
||||||
config={config}
|
|
||||||
onConfigChange={updateConfig}
|
|
||||||
presetCode={presetCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-20 flex w-px cursor-col-resize items-center justify-center",
|
|
||||||
"before:pointer-events-none before:absolute before:inset-y-0 before:left-1/2 before:h-full before:w-[var(--resize-handle-thickness)] before:-translate-x-1/2 before:bg-border before:transition-[width,background-color] before:duration-150 before:ease-out",
|
|
||||||
"after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2",
|
|
||||||
isDragging
|
|
||||||
? "[--resize-handle-thickness:5px] before:bg-foreground/60"
|
|
||||||
: "[--resize-handle-thickness:1px] hover:[--resize-handle-thickness:5px] hover:before:bg-foreground/40"
|
|
||||||
)}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn("min-w-0 flex-1", isDragging && "pointer-events-none")}
|
|
||||||
>
|
|
||||||
<ShadcnPreview previewUrl={previewUrl} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
|
|
||||||
interface ShadcnPreviewProps {
|
|
||||||
previewUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShadcnPreview({ previewUrl }: ShadcnPreviewProps) {
|
|
||||||
const t = useTranslations("ProjectBoot")
|
|
||||||
const [debouncedUrl, setDebouncedUrl] = useState(previewUrl)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
setDebouncedUrl(previewUrl)
|
|
||||||
setLoading(true)
|
|
||||||
}, 500)
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [previewUrl])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
|
||||||
{t("preview.loading")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<iframe
|
|
||||||
key={debouncedUrl}
|
|
||||||
src={debouncedUrl}
|
|
||||||
className="h-full w-full border-0"
|
|
||||||
onLoad={() => setLoading(false)}
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { createChatChannel, saveChatChannelToken } from "@/lib/api"
|
|
||||||
import type { ChannelType } from "@/lib/types"
|
|
||||||
|
|
||||||
interface AddChatChannelDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onChannelAdded: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddChatChannelDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onChannelAdded,
|
|
||||||
}: AddChatChannelDialogProps) {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [channelType, setChannelType] = useState<ChannelType>("telegram")
|
|
||||||
const [token, setToken] = useState("")
|
|
||||||
const [chatId, setChatId] = useState("")
|
|
||||||
const [appId, setAppId] = useState("")
|
|
||||||
const [baseUrl, setBaseUrl] = useState("https://ilinkai.weixin.qq.com")
|
|
||||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
|
|
||||||
const [dailyReportTime, setDailyReportTime] = useState("18:00")
|
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
|
||||||
setName("")
|
|
||||||
setChannelType("telegram")
|
|
||||||
setToken("")
|
|
||||||
setChatId("")
|
|
||||||
setAppId("")
|
|
||||||
setBaseUrl("https://ilinkai.weixin.qq.com")
|
|
||||||
setDailyReportEnabled(false)
|
|
||||||
setDailyReportTime("18:00")
|
|
||||||
setError(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
|
||||||
(nextOpen: boolean) => {
|
|
||||||
if (!nextOpen) resetForm()
|
|
||||||
onOpenChange(nextOpen)
|
|
||||||
},
|
|
||||||
[onOpenChange, resetForm]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setError(t("nameRequired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (channelType !== "weixin" && !token.trim()) {
|
|
||||||
setError(t("tokenRequired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (channelType !== "weixin" && !chatId.trim()) {
|
|
||||||
setError(t("chatIdRequired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const configJson =
|
|
||||||
channelType === "weixin"
|
|
||||||
? JSON.stringify({ base_url: baseUrl })
|
|
||||||
: channelType === "lark"
|
|
||||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
|
||||||
: JSON.stringify({ chat_id: chatId })
|
|
||||||
|
|
||||||
const channel = await createChatChannel({
|
|
||||||
name: name.trim(),
|
|
||||||
channelType,
|
|
||||||
configJson,
|
|
||||||
enabled: true,
|
|
||||||
dailyReportEnabled,
|
|
||||||
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (channelType !== "weixin" && token.trim()) {
|
|
||||||
await saveChatChannelToken(channel.id, token.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOpenChange(false)
|
|
||||||
onChannelAdded()
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
setError(msg)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
name,
|
|
||||||
token,
|
|
||||||
chatId,
|
|
||||||
channelType,
|
|
||||||
appId,
|
|
||||||
baseUrl,
|
|
||||||
dailyReportEnabled,
|
|
||||||
dailyReportTime,
|
|
||||||
handleOpenChange,
|
|
||||||
onChannelAdded,
|
|
||||||
t,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("addChannel")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">{t("channelName")}</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={t("channelNamePlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">{t("channelType")}</label>
|
|
||||||
<Select
|
|
||||||
value={channelType}
|
|
||||||
onValueChange={(v) => setChannelType(v as ChannelType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="telegram">Telegram</SelectItem>
|
|
||||||
<SelectItem value="lark">{t("lark")}</SelectItem>
|
|
||||||
<SelectItem value="weixin">{t("weixin")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channelType === "lark" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">App ID</label>
|
|
||||||
<Input
|
|
||||||
value={appId}
|
|
||||||
onChange={(e) => setAppId(e.target.value)}
|
|
||||||
placeholder="cli_xxxxx"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channelType !== "weixin" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
{channelType === "telegram" ? "Bot Token" : "App Secret"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setToken(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channelType !== "weixin" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">Chat ID</label>
|
|
||||||
<Input
|
|
||||||
value={chatId}
|
|
||||||
onChange={(e) => setChatId(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channelType === "weixin" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("weixinScanDescription")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
|
||||||
<Switch
|
|
||||||
checked={dailyReportEnabled}
|
|
||||||
onCheckedChange={setDailyReportEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dailyReportEnabled && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
{t("dailyReportTime")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={dailyReportTime}
|
|
||||||
onChange={(e) => setDailyReportTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleOpenChange(false)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={loading}>
|
|
||||||
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
|
|
||||||
{t("create")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Loader2, Save } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { getChatCommandPrefix, setChatCommandPrefix } from "@/lib/api"
|
|
||||||
|
|
||||||
const BUILT_IN_COMMANDS = [
|
|
||||||
{ name: "folder [n|path]", descKey: "folderDesc" },
|
|
||||||
{ name: "agent [n|name]", descKey: "agentDesc" },
|
|
||||||
{ name: "task <description>", descKey: "taskDesc" },
|
|
||||||
{ name: "sessions", descKey: "sessionsDesc" },
|
|
||||||
{ name: "resume [id]", descKey: "resumeDesc" },
|
|
||||||
{ name: "cancel", descKey: "cancelDesc" },
|
|
||||||
{ name: "approve [always]", descKey: "approveDesc" },
|
|
||||||
{ name: "deny", descKey: "denyDesc" },
|
|
||||||
{ name: "search <keyword>", descKey: "searchDesc" },
|
|
||||||
{ name: "today", descKey: "todayDesc" },
|
|
||||||
{ name: "status", descKey: "statusDesc" },
|
|
||||||
{ name: "help", descKey: "helpDesc" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function ChannelCommandsTab() {
|
|
||||||
const t = useTranslations("ChatChannelSettings.commands")
|
|
||||||
const [prefix, setPrefix] = useState("/")
|
|
||||||
const [inputPrefix, setInputPrefix] = useState("/")
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getChatCommandPrefix()
|
|
||||||
.then((p) => {
|
|
||||||
setPrefix(p)
|
|
||||||
setInputPrefix(p)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSavePrefix = useCallback(async () => {
|
|
||||||
const trimmed = inputPrefix.trim()
|
|
||||||
if (
|
|
||||||
trimmed.length === 0 ||
|
|
||||||
trimmed.length > 3 ||
|
|
||||||
/[a-zA-Z0-9]/.test(trimmed)
|
|
||||||
) {
|
|
||||||
toast.error(t("prefixInvalid"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await setChatCommandPrefix(trimmed)
|
|
||||||
setPrefix(trimmed)
|
|
||||||
toast.success(t("prefixSaved"))
|
|
||||||
} catch {
|
|
||||||
toast.error(t("prefixSaveFailed"))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [inputPrefix, t])
|
|
||||||
|
|
||||||
const dirty = inputPrefix !== prefix
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium">{t("prefixLabel")}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("prefixDescription")}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={inputPrefix}
|
|
||||||
onChange={(e) => setInputPrefix(e.target.value)}
|
|
||||||
className="w-20 text-center font-mono"
|
|
||||||
maxLength={3}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
onClick={handleSavePrefix}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-3.5 w-3.5 mr-1" />
|
|
||||||
)}
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium">{t("title")}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{BUILT_IN_COMMANDS.map((cmd) => (
|
|
||||||
<div
|
|
||||||
key={cmd.name}
|
|
||||||
className="flex items-center justify-between rounded-lg border bg-card px-4 py-3"
|
|
||||||
>
|
|
||||||
<code className="text-sm font-mono">
|
|
||||||
{prefix}
|
|
||||||
{cmd.name}
|
|
||||||
</code>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t(cmd.descKey)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { getChatEventFilter, setChatEventFilter } from "@/lib/api"
|
|
||||||
|
|
||||||
const ALL_EVENT_TYPES = [
|
|
||||||
{
|
|
||||||
id: "turn_complete",
|
|
||||||
labelKey: "turnComplete",
|
|
||||||
descKey: "turnCompleteDesc",
|
|
||||||
},
|
|
||||||
{ id: "error", labelKey: "error", descKey: "errorDesc" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ALL_IDS = ALL_EVENT_TYPES.map((e) => e.id)
|
|
||||||
|
|
||||||
function parseFilter(arr: string[] | null): Set<string> {
|
|
||||||
if (!arr) return new Set(ALL_IDS)
|
|
||||||
return new Set(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChannelEventsTab() {
|
|
||||||
const t = useTranslations("ChatChannelSettings.events")
|
|
||||||
const [enabledEvents, setEnabledEvents] = useState<Set<string>>(
|
|
||||||
new Set(ALL_IDS)
|
|
||||||
)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getChatEventFilter()
|
|
||||||
.then((arr) => setEnabledEvents(parseFilter(arr)))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
async (eventId: string, checked: boolean) => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const next = new Set(enabledEvents)
|
|
||||||
if (checked) {
|
|
||||||
next.add(eventId)
|
|
||||||
} else {
|
|
||||||
next.delete(eventId)
|
|
||||||
}
|
|
||||||
const isAll = next.size === ALL_EVENT_TYPES.length
|
|
||||||
await setChatEventFilter(isAll ? null : [...next])
|
|
||||||
setEnabledEvents(next)
|
|
||||||
toast.success(t("saved"))
|
|
||||||
} catch {
|
|
||||||
toast.error(t("saveFailed"))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enabledEvents, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
|
||||||
|
|
||||||
<section className="space-y-1">
|
|
||||||
{ALL_EVENT_TYPES.map((evt) => (
|
|
||||||
<div
|
|
||||||
key={evt.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border bg-card px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium">{t(evt.labelKey)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t(evt.descKey)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={enabledEvents.has(evt.id)}
|
|
||||||
disabled={saving}
|
|
||||||
onCheckedChange={(checked) => handleToggle(evt.id, checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
MessageCircle,
|
|
||||||
Pencil,
|
|
||||||
Play,
|
|
||||||
Plus,
|
|
||||||
Square,
|
|
||||||
Trash2,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import {
|
|
||||||
listChatChannels,
|
|
||||||
deleteChatChannel,
|
|
||||||
connectChatChannel,
|
|
||||||
disconnectChatChannel,
|
|
||||||
testChatChannel,
|
|
||||||
updateChatChannel,
|
|
||||||
getChatChannelStatus,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import { subscribe } from "@/lib/platform"
|
|
||||||
import type {
|
|
||||||
ChatChannelInfo,
|
|
||||||
ChannelStatusInfo,
|
|
||||||
ChannelType,
|
|
||||||
} from "@/lib/types"
|
|
||||||
import { AddChatChannelDialog } from "./add-chat-channel-dialog"
|
|
||||||
import { EditChatChannelDialog } from "./edit-chat-channel-dialog"
|
|
||||||
import { WeixinQrcodeDialog } from "./weixin-qrcode-dialog"
|
|
||||||
|
|
||||||
export function ChannelListTab() {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
const [channels, setChannels] = useState<ChatChannelInfo[]>([])
|
|
||||||
const [statuses, setStatuses] = useState<ChannelStatusInfo[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
|
||||||
const [editTarget, setEditTarget] = useState<ChatChannelInfo | null>(null)
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
|
|
||||||
const [actionLoading, setActionLoading] = useState<number | null>(null)
|
|
||||||
const [qrcodeChannelId, setQrcodeChannelId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const loadChannels = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [chs, sts] = await Promise.all([
|
|
||||||
listChatChannels(),
|
|
||||||
getChatChannelStatus().catch(() => []),
|
|
||||||
])
|
|
||||||
setChannels(chs)
|
|
||||||
setStatuses(sts)
|
|
||||||
} catch {
|
|
||||||
toast.error(t("loadFailed"))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChannels().catch(console.error)
|
|
||||||
}, [loadChannels])
|
|
||||||
|
|
||||||
// Subscribe to real-time status change events from backend
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
let unsub: (() => void) | undefined
|
|
||||||
subscribe<{
|
|
||||||
channel_id: number
|
|
||||||
status: ChannelStatusInfo["status"]
|
|
||||||
}>("chat-channel://status", (payload) => {
|
|
||||||
setStatuses((prev) => {
|
|
||||||
const idx = prev.findIndex((s) => s.channel_id === payload.channel_id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const updated = [...prev]
|
|
||||||
updated[idx] = { ...updated[idx], status: payload.status }
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}).then((fn) => {
|
|
||||||
if (cancelled) fn()
|
|
||||||
else unsub = fn
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
unsub?.()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleToggleEnabled = useCallback(
|
|
||||||
async (ch: ChatChannelInfo, connected: boolean) => {
|
|
||||||
try {
|
|
||||||
const disabling = ch.enabled
|
|
||||||
if (disabling && connected) {
|
|
||||||
await disconnectChatChannel(ch.id)
|
|
||||||
}
|
|
||||||
await updateChatChannel({ id: ch.id, enabled: !ch.enabled })
|
|
||||||
await loadChannels()
|
|
||||||
} catch {
|
|
||||||
toast.error(t("saveFailed"))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadChannels, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleConnect = useCallback(
|
|
||||||
async (id: number, channelType?: ChannelType) => {
|
|
||||||
setActionLoading(id)
|
|
||||||
try {
|
|
||||||
await connectChatChannel(id)
|
|
||||||
toast.success(t("connectSuccess"))
|
|
||||||
await loadChannels()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (channelType === "weixin") {
|
|
||||||
// No token or token expired — show QR code dialog
|
|
||||||
setQrcodeChannelId(id)
|
|
||||||
} else {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
toast.error(t("connectFailed") + ": " + msg)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadChannels, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleWeixinAuthSuccess = useCallback(
|
|
||||||
async (channelId: number) => {
|
|
||||||
setQrcodeChannelId(null)
|
|
||||||
setActionLoading(channelId)
|
|
||||||
try {
|
|
||||||
await connectChatChannel(channelId)
|
|
||||||
toast.success(t("connectSuccess"))
|
|
||||||
await loadChannels()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
toast.error(t("connectFailed") + ": " + msg)
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadChannels, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDisconnect = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
setActionLoading(id)
|
|
||||||
try {
|
|
||||||
await disconnectChatChannel(id)
|
|
||||||
toast.success(t("disconnectSuccess"))
|
|
||||||
await loadChannels()
|
|
||||||
} catch {
|
|
||||||
toast.error(t("disconnectFailed"))
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadChannels, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleTest = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
setActionLoading(id)
|
|
||||||
try {
|
|
||||||
await testChatChannel(id)
|
|
||||||
toast.success(t("testSuccess"))
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
toast.error(t("testFailed") + ": " + msg)
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
try {
|
|
||||||
await deleteChatChannel(deleteTarget.id)
|
|
||||||
toast.success(t("deleteSuccess"))
|
|
||||||
setDeleteTarget(null)
|
|
||||||
await loadChannels()
|
|
||||||
} catch {
|
|
||||||
toast.error(t("deleteFailed"))
|
|
||||||
}
|
|
||||||
}, [deleteTarget, loadChannels, t])
|
|
||||||
|
|
||||||
const getChannelStatus = (id: number) =>
|
|
||||||
statuses.find((s) => s.channel_id === id)?.status ?? "disconnected"
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
{t("loading")}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">{t("channelListTitle")}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("channelListDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
||||||
{t("addChannel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channels.length === 0 ? (
|
|
||||||
<section className="rounded-xl border bg-card p-8 text-center">
|
|
||||||
<MessageCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">{t("noChannels")}</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="space-y-2">
|
|
||||||
{channels.map((ch) => {
|
|
||||||
const status = getChannelStatus(ch.id)
|
|
||||||
const isConnected = status === "connected"
|
|
||||||
const isLoading = actionLoading === ch.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={ch.id}
|
|
||||||
className="rounded-xl border bg-card p-4 flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{ch.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{ch.channel_type}
|
|
||||||
{ch.channel_type === "weixin" && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="inline-flex cursor-help rounded-sm text-yellow-600 outline-none focus-visible:ring-1 focus-visible:ring-ring dark:text-yellow-500"
|
|
||||||
aria-label={t("weixinReconnectNotice")}
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{t("weixinReconnectNotice")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-2 w-2 rounded-full ${
|
|
||||||
isConnected
|
|
||||||
? "bg-green-500"
|
|
||||||
: status === "connecting"
|
|
||||||
? "bg-yellow-500 animate-pulse"
|
|
||||||
: status === "error"
|
|
||||||
? "bg-red-500"
|
|
||||||
: "bg-gray-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
|
||||||
{ch.daily_report_enabled && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("dailyReport")}: {ch.daily_report_time || "18:00"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={ch.enabled}
|
|
||||||
onCheckedChange={() => handleToggleEnabled(ch, isConnected)}
|
|
||||||
/>
|
|
||||||
{isConnected ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
title={t("disconnect")}
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => handleDisconnect(ch.id)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Square className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title={t("connect")}
|
|
||||||
disabled={isLoading || !ch.enabled}
|
|
||||||
onClick={() => handleConnect(ch.id, ch.channel_type)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title={t("test")}
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => handleTest(ch.id)}
|
|
||||||
>
|
|
||||||
<Zap className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title={t("editChannel")}
|
|
||||||
disabled={isConnected || isLoading}
|
|
||||||
onClick={() => setEditTarget(ch)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title={t("delete")}
|
|
||||||
onClick={() => setDeleteTarget(ch)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AddChatChannelDialog
|
|
||||||
open={addDialogOpen}
|
|
||||||
onOpenChange={setAddDialogOpen}
|
|
||||||
onChannelAdded={loadChannels}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{editTarget && (
|
|
||||||
<EditChatChannelDialog
|
|
||||||
open={!!editTarget}
|
|
||||||
channel={editTarget}
|
|
||||||
onOpenChange={(open) => !open && setEditTarget(null)}
|
|
||||||
onChannelUpdated={loadChannels}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{qrcodeChannelId !== null && (
|
|
||||||
<WeixinQrcodeDialog
|
|
||||||
open
|
|
||||||
channelId={qrcodeChannelId}
|
|
||||||
onOpenChange={(open) => !open && setQrcodeChannelId(null)}
|
|
||||||
onAuthSuccess={handleWeixinAuthSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("deleteConfirmMessage")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete}>
|
|
||||||
{t("delete")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { getChatMessageLanguage, setChatMessageLanguage } from "@/lib/api"
|
|
||||||
|
|
||||||
const SUPPORTED_LANGUAGES = [
|
|
||||||
"en",
|
|
||||||
"zh-cn",
|
|
||||||
"zh-tw",
|
|
||||||
"ja",
|
|
||||||
"ko",
|
|
||||||
"es",
|
|
||||||
"de",
|
|
||||||
"fr",
|
|
||||||
"pt",
|
|
||||||
"ar",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function ChannelOtherTab() {
|
|
||||||
const t = useTranslations("ChatChannelSettings.language")
|
|
||||||
const [language, setLanguage] = useState("en")
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getChatMessageLanguage()
|
|
||||||
.then((lang) => setLanguage(lang))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLanguageChange = useCallback(
|
|
||||||
async (value: string) => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await setChatMessageLanguage(value)
|
|
||||||
setLanguage(value)
|
|
||||||
toast.success(t("saved"))
|
|
||||||
} catch {
|
|
||||||
toast.error(t("saveFailed"))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium">{t("title")}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
|
||||||
<Select
|
|
||||||
value={language}
|
|
||||||
onValueChange={handleLanguageChange}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-56">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
|
||||||
<SelectItem key={lang} value={lang}>
|
|
||||||
{t(lang)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { ChannelListTab } from "./channel-list-tab"
|
|
||||||
import { ChannelCommandsTab } from "./channel-commands-tab"
|
|
||||||
import { ChannelEventsTab } from "./channel-events-tab"
|
|
||||||
import { ChannelOtherTab } from "./channel-other-tab"
|
|
||||||
|
|
||||||
export function ChatChannelSettings() {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<Tabs defaultValue="channels" className="w-full space-y-4 p-3 md:p-4">
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("sectionDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="channels">{t("tabs.channels")}</TabsTrigger>
|
|
||||||
<TabsTrigger value="commands">{t("tabs.commands")}</TabsTrigger>
|
|
||||||
<TabsTrigger value="events">{t("tabs.events")}</TabsTrigger>
|
|
||||||
<TabsTrigger value="other">{t("tabs.other")}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<TabsContent value="channels" className="mt-0">
|
|
||||||
<ChannelListTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="commands" className="mt-0">
|
|
||||||
<ChannelCommandsTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="events" className="mt-0">
|
|
||||||
<ChannelEventsTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="other" className="mt-0">
|
|
||||||
<ChannelOtherTab />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</ScrollArea>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import {
|
|
||||||
updateChatChannel,
|
|
||||||
saveChatChannelToken,
|
|
||||||
getChatChannelHasToken,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import type { ChatChannelInfo } from "@/lib/types"
|
|
||||||
|
|
||||||
interface EditChatChannelDialogProps {
|
|
||||||
open: boolean
|
|
||||||
channel: ChatChannelInfo
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onChannelUpdated: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditChatChannelDialog({
|
|
||||||
open,
|
|
||||||
channel,
|
|
||||||
onOpenChange,
|
|
||||||
onChannelUpdated,
|
|
||||||
}: EditChatChannelDialogProps) {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const config = JSON.parse(channel.config_json || "{}")
|
|
||||||
const [name, setName] = useState(channel.name)
|
|
||||||
const [token, setToken] = useState("")
|
|
||||||
const [chatId, setChatId] = useState(config.chat_id ?? "")
|
|
||||||
const [appId, setAppId] = useState(config.app_id ?? "")
|
|
||||||
const [baseUrl] = useState(config.base_url ?? "")
|
|
||||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(
|
|
||||||
channel.daily_report_enabled
|
|
||||||
)
|
|
||||||
const [dailyReportTime, setDailyReportTime] = useState(
|
|
||||||
channel.daily_report_time || "18:00"
|
|
||||||
)
|
|
||||||
const [hasToken, setHasToken] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
getChatChannelHasToken(channel.id)
|
|
||||||
.then(setHasToken)
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
}, [open, channel.id])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setError(t("nameRequired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (channel.channel_type !== "weixin" && !chatId.trim()) {
|
|
||||||
setError(t("chatIdRequired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const configJson =
|
|
||||||
channel.channel_type === "weixin"
|
|
||||||
? JSON.stringify({ base_url: baseUrl })
|
|
||||||
: channel.channel_type === "lark"
|
|
||||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
|
||||||
: JSON.stringify({ chat_id: chatId })
|
|
||||||
|
|
||||||
await updateChatChannel({
|
|
||||||
id: channel.id,
|
|
||||||
name: name.trim(),
|
|
||||||
configJson,
|
|
||||||
dailyReportEnabled,
|
|
||||||
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (token.trim()) {
|
|
||||||
await saveChatChannelToken(channel.id, token.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(false)
|
|
||||||
onChannelUpdated()
|
|
||||||
toast.success(t("editSuccess"))
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
setError(msg)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
name,
|
|
||||||
token,
|
|
||||||
chatId,
|
|
||||||
channel,
|
|
||||||
appId,
|
|
||||||
baseUrl,
|
|
||||||
dailyReportEnabled,
|
|
||||||
dailyReportTime,
|
|
||||||
onOpenChange,
|
|
||||||
onChannelUpdated,
|
|
||||||
t,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("editChannel")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">{t("channelName")}</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={t("channelNamePlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel.channel_type === "lark" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">App ID</label>
|
|
||||||
<Input
|
|
||||||
value={appId}
|
|
||||||
onChange={(e) => setAppId(e.target.value)}
|
|
||||||
placeholder="cli_xxxxx"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channel.channel_type !== "weixin" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
{channel.channel_type === "telegram"
|
|
||||||
? "Bot Token"
|
|
||||||
: "App Secret"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setToken(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channel.channel_type !== "weixin" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">Chat ID</label>
|
|
||||||
<Input
|
|
||||||
value={chatId}
|
|
||||||
onChange={(e) => setChatId(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
channel.channel_type === "telegram"
|
|
||||||
? "-100123456789"
|
|
||||||
: "oc_xxxxx"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channel.channel_type === "weixin" && baseUrl && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">Base URL</label>
|
|
||||||
<Input value={baseUrl} disabled />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
|
||||||
<Switch
|
|
||||||
checked={dailyReportEnabled}
|
|
||||||
onCheckedChange={setDailyReportEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dailyReportEnabled && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
{t("dailyReportTime")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={dailyReportTime}
|
|
||||||
onChange={(e) => setDailyReportTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={loading}>
|
|
||||||
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,9 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
SendHorizontal,
|
|
||||||
Palette,
|
Palette,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
Server,
|
Server,
|
||||||
@@ -28,7 +26,6 @@ import { useRouter } from "next/navigation"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AppToaster } from "@/components/ui/app-toaster"
|
import { AppToaster } from "@/components/ui/app-toaster"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { detectEnvironment } from "@/lib/transport/detect"
|
|
||||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
@@ -45,9 +42,7 @@ interface SettingsNavItem {
|
|||||||
| "quick_messages"
|
| "quick_messages"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "version_control"
|
| "version_control"
|
||||||
| "chat_channels"
|
|
||||||
| "system"
|
| "system"
|
||||||
| "web_service"
|
|
||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,16 +92,6 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
|||||||
labelKey: "version_control",
|
labelKey: "version_control",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/settings/chat-channels",
|
|
||||||
labelKey: "chat_channels",
|
|
||||||
icon: SendHorizontal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/settings/web-service",
|
|
||||||
labelKey: "web_service",
|
|
||||||
icon: Globe,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/settings/system",
|
href: "/settings/system",
|
||||||
labelKey: "system",
|
labelKey: "system",
|
||||||
@@ -165,18 +150,13 @@ export function SettingsShell({ children }: SettingsShellProps) {
|
|||||||
[router, setNavOpen]
|
[router, setNavOpen]
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredNavItems = SETTINGS_NAV_ITEMS.filter(
|
|
||||||
(item) =>
|
|
||||||
!(item.labelKey === "web_service" && detectEnvironment() === "web")
|
|
||||||
)
|
|
||||||
|
|
||||||
const navContent = (
|
const navContent = (
|
||||||
<>
|
<>
|
||||||
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
||||||
{t("preferences")}
|
{t("preferences")}
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{filteredNavItems.map((item) => {
|
{SETTINGS_NAV_ITEMS.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const translationKey = `nav.${item.labelKey}` as const
|
const translationKey = `nav.${item.labelKey}` as const
|
||||||
const active =
|
const active =
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
|
||||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api"
|
|
||||||
|
|
||||||
/** Client-side QR code expiry (5 minutes). */
|
|
||||||
const QR_EXPIRY_MS = 5 * 60 * 1000
|
|
||||||
/** Show a warning after this many consecutive polling failures. */
|
|
||||||
const POLL_ERROR_WARN_THRESHOLD = 3
|
|
||||||
|
|
||||||
interface WeixinQrcodeDialogProps {
|
|
||||||
open: boolean
|
|
||||||
channelId: number
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onAuthSuccess: (channelId: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeixinQrcodeContent({
|
|
||||||
channelId,
|
|
||||||
onAuthSuccess,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
channelId: number
|
|
||||||
onAuthSuccess: (channelId: number) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
const [qrcodeImg, setQrcodeImg] = useState<string | null>(null)
|
|
||||||
const [qrcodeId, setQrcodeId] = useState<string | null>(null)
|
|
||||||
const [status, setStatus] = useState<"loading" | "waiting" | "expired">(
|
|
||||||
"loading"
|
|
||||||
)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [pollErrors, setPollErrors] = useState(0)
|
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
// Stabilise callbacks via ref so the polling effect doesn't re-trigger
|
|
||||||
const onAuthSuccessRef = useRef(onAuthSuccess)
|
|
||||||
const onCloseRef = useRef(onClose)
|
|
||||||
useEffect(() => {
|
|
||||||
onAuthSuccessRef.current = onAuthSuccess
|
|
||||||
onCloseRef.current = onClose
|
|
||||||
})
|
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
|
||||||
if (pollingRef.current) {
|
|
||||||
clearInterval(pollingRef.current)
|
|
||||||
pollingRef.current = null
|
|
||||||
}
|
|
||||||
if (expiryRef.current) {
|
|
||||||
clearTimeout(expiryRef.current)
|
|
||||||
expiryRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchQrcode = useCallback(async () => {
|
|
||||||
setStatus("loading")
|
|
||||||
setError(null)
|
|
||||||
setQrcodeImg(null)
|
|
||||||
setQrcodeId(null)
|
|
||||||
setPollErrors(0)
|
|
||||||
stopPolling()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await weixinGetQrcode()
|
|
||||||
setQrcodeId(result.qrcode_id)
|
|
||||||
|
|
||||||
if (result.qrcode_img_content) {
|
|
||||||
const raw = result.qrcode_img_content
|
|
||||||
const imgSrc = raw.startsWith("data:")
|
|
||||||
? raw
|
|
||||||
: `data:image/png;base64,${raw}`
|
|
||||||
setQrcodeImg(imgSrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("waiting")
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
setError(msg)
|
|
||||||
setStatus("expired")
|
|
||||||
}
|
|
||||||
}, [stopPolling])
|
|
||||||
|
|
||||||
// Fetch QR code on mount + cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
|
|
||||||
fetchQrcode()
|
|
||||||
return () => stopPolling()
|
|
||||||
}, [fetchQrcode, stopPolling])
|
|
||||||
|
|
||||||
// Start polling + expiry timer when we have a qrcodeId
|
|
||||||
useEffect(() => {
|
|
||||||
if (!qrcodeId || status !== "waiting") return
|
|
||||||
|
|
||||||
// Client-side expiry guard
|
|
||||||
expiryRef.current = setTimeout(() => {
|
|
||||||
stopPolling()
|
|
||||||
setStatus("expired")
|
|
||||||
}, QR_EXPIRY_MS)
|
|
||||||
|
|
||||||
pollingRef.current = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const result = await weixinCheckQrcode(channelId, qrcodeId)
|
|
||||||
setPollErrors(0)
|
|
||||||
if (result.status === "confirmed") {
|
|
||||||
stopPolling()
|
|
||||||
onAuthSuccessRef.current(channelId)
|
|
||||||
onCloseRef.current()
|
|
||||||
} else if (result.status === "expired") {
|
|
||||||
stopPolling()
|
|
||||||
setStatus("expired")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPollErrors((n) => n + 1)
|
|
||||||
}
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
return () => stopPolling()
|
|
||||||
}, [qrcodeId, status, channelId, stopPolling])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-4 py-4">
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{t("weixinScanDescription")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{status === "loading" && (
|
|
||||||
<div className="flex h-48 w-48 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "waiting" && qrcodeImg && (
|
|
||||||
<>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={qrcodeImg}
|
|
||||||
alt="WeChat QR Code"
|
|
||||||
className="h-48 w-48 rounded-md"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
{t("weixinWaitingScan")}
|
|
||||||
</p>
|
|
||||||
{pollErrors >= POLL_ERROR_WARN_THRESHOLD && (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/5 px-3 py-1.5 text-xs text-yellow-500">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{t("weixinPollError")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "expired" && (
|
|
||||||
<>
|
|
||||||
<div className="flex h-48 w-48 items-center justify-center rounded-md bg-muted">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("weixinQrcodeExpired")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchQrcode}>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
|
||||||
{t("weixinRefreshQrcode")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeixinQrcodeDialog({
|
|
||||||
open,
|
|
||||||
channelId,
|
|
||||||
onOpenChange,
|
|
||||||
onAuthSuccess,
|
|
||||||
}: WeixinQrcodeDialogProps) {
|
|
||||||
const t = useTranslations("ChatChannelSettings")
|
|
||||||
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("weixinScanTitle")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{open && (
|
|
||||||
<WeixinQrcodeContent
|
|
||||||
channelId={channelId}
|
|
||||||
onAuthSuccess={onAuthSuccess}
|
|
||||||
onClose={handleClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
export type TransportEnvironment = "tauri" | "web"
|
export type TransportEnvironment = "tauri"
|
||||||
|
|
||||||
export function detectEnvironment(): TransportEnvironment {
|
export function detectEnvironment(): TransportEnvironment {
|
||||||
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
|
|
||||||
return "tauri"
|
return "tauri"
|
||||||
}
|
|
||||||
return "web"
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { detectEnvironment } from "./detect"
|
|
||||||
import type { Transport } from "./types"
|
import type { Transport } from "./types"
|
||||||
|
|
||||||
export type { Transport, UnsubscribeFn } from "./types"
|
export type { Transport, UnsubscribeFn } from "./types"
|
||||||
@@ -7,26 +6,15 @@ let _transport: Transport | null = null
|
|||||||
|
|
||||||
export function getTransport(): Transport {
|
export function getTransport(): Transport {
|
||||||
if (!_transport) {
|
if (!_transport) {
|
||||||
const env = detectEnvironment()
|
|
||||||
if (env === "tauri") {
|
|
||||||
// Use dynamic require to avoid bundling tauri deps in web mode.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { TauriTransport } = require("./tauri-transport") as {
|
const { TauriTransport } = require("./tauri-transport") as {
|
||||||
TauriTransport: new () => Transport
|
TauriTransport: new () => Transport
|
||||||
}
|
}
|
||||||
_transport = new TauriTransport()
|
_transport = new TauriTransport()
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { WebTransport } = require("./web-transport") as {
|
|
||||||
WebTransport: new (baseUrl: string) => Transport
|
|
||||||
}
|
|
||||||
const baseUrl = window.location.origin
|
|
||||||
_transport = new WebTransport(baseUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return _transport
|
return _transport
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDesktop(): boolean {
|
export function isDesktop(): boolean {
|
||||||
return getTransport().isDesktop()
|
return true
|
||||||
}
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,90 @@
|
|||||||
import { computeLineDiff, type DiffHunk } from "@/components/merge/merge-diff"
|
export interface DiffHunk {
|
||||||
|
baseStart: number
|
||||||
|
baseCount: number
|
||||||
|
newLines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function lcsTable(a: string[], b: string[]): number[][] {
|
||||||
|
const m = a.length
|
||||||
|
const n = b.length
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array<number>(n + 1).fill(0)
|
||||||
|
)
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditOp {
|
||||||
|
type: "equal" | "delete" | "insert"
|
||||||
|
aIdx: number
|
||||||
|
bIdx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
|
||||||
|
const ops: EditOp[] = []
|
||||||
|
let i = a.length
|
||||||
|
let j = b.length
|
||||||
|
while (i > 0 || j > 0) {
|
||||||
|
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
||||||
|
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||||
|
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
|
||||||
|
j--
|
||||||
|
} else {
|
||||||
|
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
|
||||||
|
const dp = lcsTable(a, b)
|
||||||
|
const ops = backtrackLCS(a, b, dp)
|
||||||
|
const hunks: DiffHunk[] = []
|
||||||
|
let idx = 0
|
||||||
|
while (idx < ops.length) {
|
||||||
|
const op = ops[idx]
|
||||||
|
if (op.type === "equal") {
|
||||||
|
idx++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let baseStart = op.type === "delete" ? op.aIdx : -1
|
||||||
|
let baseCount = 0
|
||||||
|
const newLines: string[] = []
|
||||||
|
while (idx < ops.length && ops[idx].type !== "equal") {
|
||||||
|
const cur = ops[idx]
|
||||||
|
if (cur.type === "delete") {
|
||||||
|
if (baseStart === -1) baseStart = cur.aIdx
|
||||||
|
baseCount++
|
||||||
|
} else {
|
||||||
|
if (baseStart === -1) {
|
||||||
|
baseStart = 0
|
||||||
|
for (let k = idx - 1; k >= 0; k--) {
|
||||||
|
if (ops[k].type === "equal" || ops[k].type === "delete") {
|
||||||
|
baseStart = ops[k].aIdx + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newLines.push(b[cur.bIdx])
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
hunks.push({ baseStart, baseCount, newLines })
|
||||||
|
}
|
||||||
|
return hunks
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum product of line counts before falling back to naive diff.
|
* Maximum product of line counts before falling back to naive diff.
|
||||||
|
|||||||
Reference in New Issue
Block a user