Initial commit
This commit is contained in:
1868
src-tauri/src/commands/acp.rs
Normal file
1868
src-tauri/src/commands/acp.rs
Normal file
File diff suppressed because it is too large
Load Diff
384
src-tauri/src/commands/conversations.rs
Normal file
384
src-tauri/src/commands/conversations.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::db::entities::conversation;
|
||||
use crate::db::service::{conversation_service, folder_service, import_service};
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::opencode::OpenCodeParser;
|
||||
use crate::parsers::{path_eq_for_matching, AgentParser};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folder_conversations(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
status: Option<String>,
|
||||
) -> Result<Vec<DbConversationSummary>, String> {
|
||||
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
|
||||
fn list_conversations_sync(
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
folder_path: Option<String>,
|
||||
) -> Result<Vec<ConversationSummary>, String> {
|
||||
let mut all_conversations = Vec::new();
|
||||
let mut seen_keys = HashSet::new();
|
||||
|
||||
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
|
||||
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
|
||||
(AgentType::Codex, Box::new(CodexParser::new())),
|
||||
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||
];
|
||||
|
||||
for (at, parser) in &parsers {
|
||||
if let Some(ref filter) = agent_type {
|
||||
if filter != at {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match parser.list_conversations() {
|
||||
Ok(conversations) => {
|
||||
// Deduplicate conversations based on (agent_type, id) combination
|
||||
for conversation in conversations {
|
||||
let key = format!("{:?}-{}", conversation.agent_type, conversation.id);
|
||||
if seen_keys.insert(key) {
|
||||
all_conversations.push(conversation);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error listing {} conversations: {}", at, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if let Some(ref query) = search {
|
||||
let query_lower = query.to_lowercase();
|
||||
all_conversations.retain(|s| {
|
||||
s.title
|
||||
.as_ref()
|
||||
.map(|t| t.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.folder_name
|
||||
.as_ref()
|
||||
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.folder_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.git_branch
|
||||
.as_ref()
|
||||
.map(|b| b.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Apply folder path filter
|
||||
if let Some(ref fp) = folder_path {
|
||||
all_conversations.retain(|s| {
|
||||
s.folder_path
|
||||
.as_deref()
|
||||
.map(|p| path_eq_for_matching(p, fp.as_str()))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
match sort_by.as_deref() {
|
||||
Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)),
|
||||
Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
|
||||
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
|
||||
}
|
||||
|
||||
Ok(all_conversations)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_conversations(
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
folder_path: Option<String>,
|
||||
) -> Result<Vec<ConversationSummary>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
list_conversations_sync(agent_type, search, sort_by, folder_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_conversation(
|
||||
agent_type: AgentType,
|
||||
conversation_id: String,
|
||||
) -> Result<ConversationDetail, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parser: Box<dyn AgentParser> = match agent_type {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"conversation parsing not supported for {agent_type}"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
parser
|
||||
.get_conversation(&conversation_id)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folders() -> Result<Vec<FolderInfo>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
Ok(compute_folders(&all_conversations))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stats() -> Result<AgentStats, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
compute_stats(&all_conversations)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sidebar_data() -> Result<SidebarData, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
let folders = compute_folders(&all_conversations);
|
||||
let stats = compute_stats(&all_conversations)?;
|
||||
Ok(SidebarData { folders, stats })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
|
||||
let mut folder_map: HashMap<String, FolderInfo> = HashMap::new();
|
||||
|
||||
for conversation in all_conversations {
|
||||
let path = conversation
|
||||
.folder_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = conversation
|
||||
.folder_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let entry = folder_map
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| FolderInfo {
|
||||
path: path.clone(),
|
||||
name,
|
||||
agent_types: Vec::new(),
|
||||
conversation_count: 0,
|
||||
});
|
||||
|
||||
entry.conversation_count += 1;
|
||||
if !entry.agent_types.contains(&conversation.agent_type) {
|
||||
entry.agent_types.push(conversation.agent_type);
|
||||
}
|
||||
}
|
||||
|
||||
let mut folders: Vec<FolderInfo> = folder_map.into_values().collect();
|
||||
folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||
folders
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_local_conversations(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
) -> Result<ImportResult, String> {
|
||||
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Folder not found: {folder_id}"))?;
|
||||
|
||||
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_folder_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
) -> Result<DbConversationDetail, String> {
|
||||
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
|
||||
let at = summary.agent_type;
|
||||
let eid = ext_id.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let parser: Box<dyn AgentParser> = match at {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
_ => return Ok((vec![], None)),
|
||||
};
|
||||
// If the external session file doesn't exist yet (e.g., new ACP session
|
||||
// not yet synced to disk), return empty turns instead of failing.
|
||||
match parser.get_conversation(&eid) {
|
||||
Ok(d) => Ok((d.turns, d.session_stats)),
|
||||
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e: String| e)?
|
||||
} else {
|
||||
(vec![], None)
|
||||
};
|
||||
|
||||
let mut summary = summary;
|
||||
summary.message_count = turns.len() as u32;
|
||||
|
||||
Ok(DbConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
agent_type: AgentType,
|
||||
title: Option<String>,
|
||||
) -> Result<i32, String> {
|
||||
// Detect current git branch from the folder path
|
||||
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
detect_git_branch(&folder.path).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(model.id)
|
||||
}
|
||||
|
||||
async fn detect_git_branch(path: &str) -> Option<String> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if branch.is_empty() || branch == "HEAD" {
|
||||
return None;
|
||||
}
|
||||
Some(branch)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_status(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
status: String,
|
||||
) -> Result<(), String> {
|
||||
let status_enum: conversation::ConversationStatus =
|
||||
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?;
|
||||
conversation_service::update_status(&db.conn, conversation_id, status_enum)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_title(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
title: String,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::update_title(&db.conn, conversation_id, title)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_external_id(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
external_id: String,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::soft_delete(&db.conn, conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats, String> {
|
||||
let mut total_messages: u32 = 0;
|
||||
let mut counts: HashMap<AgentType, u32> = HashMap::new();
|
||||
|
||||
for conversation in all_conversations {
|
||||
total_messages += conversation.message_count;
|
||||
*counts.entry(conversation.agent_type).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut by_agent: Vec<AgentConversationCount> = counts
|
||||
.into_iter()
|
||||
.map(|(agent_type, conversation_count)| AgentConversationCount {
|
||||
agent_type,
|
||||
conversation_count,
|
||||
})
|
||||
.collect();
|
||||
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||
|
||||
Ok(AgentStats {
|
||||
total_conversations: all_conversations.len() as u32,
|
||||
total_messages,
|
||||
by_agent,
|
||||
})
|
||||
}
|
||||
153
src-tauri/src/commands/folder_commands.rs
Normal file
153
src-tauri/src/commands/folder_commands.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use crate::db::error::DbError;
|
||||
use crate::db::service::folder_command_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::FolderCommandInfo;
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
static BOOTSTRAP_FOLDER_COMMANDS_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> {
|
||||
let mut has_package_json = false;
|
||||
let mut has_pnpm_lock = false;
|
||||
let mut has_yarn_lock = false;
|
||||
let mut has_bun_lock = false;
|
||||
|
||||
let entries = match std::fs::read_dir(folder_path) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Some(file_name) = entry.file_name().to_str().map(|s| s.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
match file_name.as_str() {
|
||||
"package.json" => has_package_json = true,
|
||||
"pnpm-lock.yaml" => has_pnpm_lock = true,
|
||||
"yarn.lock" => has_yarn_lock = true,
|
||||
"bun.lockb" | "bun.lock" => has_bun_lock = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_package_json {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let package_json_path = Path::new(folder_path).join("package.json");
|
||||
let package_json_content = match std::fs::read_to_string(package_json_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let package_manager = if has_pnpm_lock {
|
||||
"pnpm"
|
||||
} else if has_yarn_lock {
|
||||
"yarn"
|
||||
} else if has_bun_lock {
|
||||
"bun"
|
||||
} else {
|
||||
"npm"
|
||||
};
|
||||
|
||||
let mut commands = Vec::new();
|
||||
if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) {
|
||||
for (script_name, script_value) in scripts {
|
||||
if script_name.trim().is_empty() || script_value.as_str().is_none() {
|
||||
continue;
|
||||
}
|
||||
commands.push((
|
||||
script_name.to_string(),
|
||||
format!("{package_manager} run {script_name}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folder_commands(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
name: String,
|
||||
command: String,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
folder_command_service::create(&db.conn, folder_id, &name, &command).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
name: Option<String>,
|
||||
command: Option<String>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
folder_command_service::update(&db.conn, id, name, command, sort_order).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
folder_command_service::delete(&db.conn, id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_folder_commands(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
ids: Vec<i32>,
|
||||
) -> Result<(), DbError> {
|
||||
folder_command_service::reorder(&db.conn, folder_id, ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bootstrap_folder_commands_from_package_json(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
folder_path: String,
|
||||
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||
let existing = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||
if !existing.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let path_for_task = folder_path;
|
||||
let commands_to_create =
|
||||
tokio::task::spawn_blocking(move || load_package_scripts_as_commands(&path_for_task))
|
||||
.await
|
||||
.map_err(|e| DbError::Migration(format!("bootstrap task failed: {e}")))?;
|
||||
|
||||
if commands_to_create.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
// Serialize bootstrap so concurrent calls do not create duplicate commands.
|
||||
let _bootstrap_guard = BOOTSTRAP_FOLDER_COMMANDS_LOCK.lock().await;
|
||||
|
||||
let latest = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||
if !latest.is_empty() {
|
||||
return Ok(latest);
|
||||
}
|
||||
|
||||
folder_command_service::create_many(&db.conn, folder_id, &commands_to_create).await?;
|
||||
|
||||
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||
}
|
||||
2543
src-tauri/src/commands/folders.rs
Normal file
2543
src-tauri/src/commands/folders.rs
Normal file
File diff suppressed because it is too large
Load Diff
3283
src-tauri/src/commands/mcp.rs
Normal file
3283
src-tauri/src/commands/mcp.rs
Normal file
File diff suppressed because it is too large
Load Diff
8
src-tauri/src/commands/mod.rs
Normal file
8
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod acp;
|
||||
pub mod conversations;
|
||||
pub mod folder_commands;
|
||||
pub mod folders;
|
||||
pub mod mcp;
|
||||
pub mod system_settings;
|
||||
pub mod terminal;
|
||||
pub mod windows;
|
||||
78
src-tauri/src/commands/system_settings.rs
Normal file
78
src-tauri/src/commands/system_settings.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::service::app_metadata_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::SystemProxySettings;
|
||||
use crate::network::proxy;
|
||||
|
||||
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
|
||||
|
||||
fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxySettings, String> {
|
||||
if !settings.enabled {
|
||||
let proxy_url = settings
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
return Ok(SystemProxySettings {
|
||||
enabled: false,
|
||||
proxy_url,
|
||||
});
|
||||
}
|
||||
|
||||
let proxy_url = settings
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
|
||||
|
||||
reqwest::Proxy::all(proxy_url).map_err(|e| format!("invalid proxy url: {e}"))?;
|
||||
|
||||
Ok(SystemProxySettings {
|
||||
enabled: true,
|
||||
proxy_url: Some(proxy_url.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn load_system_proxy_settings(
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
let raw = app_metadata_service::get_value(conn, SYSTEM_PROXY_SETTINGS_KEY)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(raw) = raw else {
|
||||
return Ok(SystemProxySettings::default());
|
||||
};
|
||||
|
||||
let parsed = serde_json::from_str::<SystemProxySettings>(&raw)
|
||||
.map_err(|e| format!("failed to parse stored proxy settings: {e}"))?;
|
||||
normalize_proxy_settings(parsed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy_settings(
|
||||
db: State<'_, AppDatabase>,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
load_system_proxy_settings(&db.conn).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_system_proxy_settings(
|
||||
settings: SystemProxySettings,
|
||||
db: State<'_, AppDatabase>,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
let normalized = normalize_proxy_settings(settings)?;
|
||||
let serialized = serde_json::to_string(&normalized).map_err(|e| e.to_string())?;
|
||||
|
||||
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
proxy::apply_system_proxy_settings(&normalized)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
56
src-tauri/src/commands/terminal.rs
Normal file
56
src-tauri/src/commands/terminal.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::manager::TerminalManager;
|
||||
use crate::terminal::types::TerminalInfo;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_spawn(
|
||||
working_dir: String,
|
||||
initial_command: Option<String>,
|
||||
manager: State<'_, TerminalManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
) -> Result<String, TerminalError> {
|
||||
manager.spawn(
|
||||
working_dir,
|
||||
window.label().to_string(),
|
||||
app_handle,
|
||||
initial_command,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_write(
|
||||
terminal_id: String,
|
||||
data: String,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.write(&terminal_id, data.as_bytes())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_resize(
|
||||
terminal_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.resize(&terminal_id, cols, rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_kill(
|
||||
terminal_id: String,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.kill(&terminal_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_list(
|
||||
manager: State<'_, TerminalManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<TerminalInfo>, TerminalError> {
|
||||
Ok(manager.list_with_exit_check(Some(&app_handle)))
|
||||
}
|
||||
365
src-tauri/src/commands/windows.rs
Normal file
365
src-tauri/src/commands/windows.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::FolderHistoryEntry;
|
||||
|
||||
pub struct SettingsWindowState {
|
||||
owner_window_label: Mutex<Option<String>>,
|
||||
disabled_windows: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
pub struct CommitWindowState {
|
||||
owner_by_commit_label: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_platform_window_style<'a, R, M>(
|
||||
builder: WebviewWindowBuilder<'a, R, M>,
|
||||
) -> WebviewWindowBuilder<'a, R, M>
|
||||
where
|
||||
R: tauri::Runtime,
|
||||
M: tauri::Manager<R>,
|
||||
{
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return builder
|
||||
.hidden_title(true)
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return builder.decorations(false);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_windows_undecorated(window: &tauri::WebviewWindow) {
|
||||
let _ = window.set_decorations(false);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {}
|
||||
|
||||
impl SettingsWindowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
owner_window_label: Mutex::new(None),
|
||||
disabled_windows: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_owner(&self, label: String) {
|
||||
if let Ok(mut owner) = self.owner_window_label.lock() {
|
||||
*owner = Some(label);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_owner(&self) -> Option<String> {
|
||||
self.owner_window_label
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut owner| owner.take())
|
||||
}
|
||||
|
||||
fn set_disabled_windows(&self, labels: HashSet<String>) {
|
||||
if let Ok(mut disabled) = self.disabled_windows.lock() {
|
||||
*disabled = labels;
|
||||
}
|
||||
}
|
||||
|
||||
fn take_disabled_windows(&self) -> HashSet<String> {
|
||||
self.disabled_windows
|
||||
.lock()
|
||||
.map(|mut disabled| std::mem::take(&mut *disabled))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitWindowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
owner_by_commit_label: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_owner(&self, commit_label: String, owner_label: String) {
|
||||
if let Ok(mut owners) = self.owner_by_commit_label.lock() {
|
||||
owners.insert(commit_label, owner_label);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_owner(&self, commit_label: &str) -> Option<String> {
|
||||
self.owner_by_commit_label
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut owners| owners.remove(commit_label))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<i32> {
|
||||
let url = window.url().ok()?;
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "id")
|
||||
.and_then(|(_, value)| value.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn resolve_settings_route(section: Option<&str>) -> &'static str {
|
||||
match section {
|
||||
Some("appearance") => "settings/appearance",
|
||||
Some("agents") => "settings/agents",
|
||||
Some("mcp") => "settings/mcp",
|
||||
Some("skills") => "settings/skills",
|
||||
Some("shortcuts") => "settings/shortcuts",
|
||||
Some("system") => "settings/system",
|
||||
_ => "settings/system",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent_query(agent_type: Option<&str>) -> Option<String> {
|
||||
let raw = agent_type?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if raw
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> String {
|
||||
let route = resolve_settings_route(section);
|
||||
if route == "settings/agents" {
|
||||
if let Some(agent) = normalize_agent_query(agent_type) {
|
||||
return format!("{route}?agent={agent}");
|
||||
}
|
||||
}
|
||||
route.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_open_folders(
|
||||
app: AppHandle,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
) -> Result<Vec<FolderHistoryEntry>, String> {
|
||||
let windows = app.webview_windows();
|
||||
let mut folder_ids: Vec<i32> = Vec::new();
|
||||
|
||||
for (label, window) in &windows {
|
||||
if label.starts_with("folder-") {
|
||||
if let Some(id) = get_folder_id_from_window(window) {
|
||||
folder_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_folders = crate::db::service::folder_service::list_folders(&db.conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let open_folders: Vec<FolderHistoryEntry> = all_folders
|
||||
.into_iter()
|
||||
.filter(|f| folder_ids.contains(&f.id))
|
||||
.collect();
|
||||
|
||||
Ok(open_folders)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), String> {
|
||||
let windows = app.webview_windows();
|
||||
for (label, window) in &windows {
|
||||
if label.starts_with("folder-") {
|
||||
if let Some(id) = get_folder_id_from_window(window) {
|
||||
if id == folder_id {
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("No open window for folder {}", folder_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_folder_window(
|
||||
app: AppHandle,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
// Add to history via DB
|
||||
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Create folder window with unique label
|
||||
let label = format!("folder-{}", uuid::Uuid::new_v4());
|
||||
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into());
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(&entry.name)
|
||||
.inner_size(1260.0, 860.0)
|
||||
.min_inner_size(900.0, 600.0);
|
||||
let folder_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&folder_window);
|
||||
|
||||
// Close welcome window
|
||||
if let Some(w) = app.get_webview_window("welcome") {
|
||||
w.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_commit_window(
|
||||
app: AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
state: tauri::State<'_, CommitWindowState>,
|
||||
folder_id: i32,
|
||||
) -> Result<(), String> {
|
||||
let owner_label = window.label().to_string();
|
||||
let label = format!("commit-{folder_id}");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
owner_window.set_enabled(false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
state.set_owner(label.clone(), owner_label);
|
||||
let _ = existing.unminimize();
|
||||
existing.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Folder {} not found", folder_id))?;
|
||||
|
||||
let url = WebviewUrl::App(format!("commit?folderId={folder_id}").into());
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(&format!("提交代码 - {}", folder.name))
|
||||
.inner_size(1220.0, 820.0)
|
||||
.min_inner_size(980.0, 620.0)
|
||||
.always_on_top(true)
|
||||
.center();
|
||||
let commit_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&commit_window);
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
if let Err(err) = owner_window.set_enabled(false) {
|
||||
let _ = commit_window.close();
|
||||
return Err(err.to_string());
|
||||
}
|
||||
}
|
||||
state.set_owner(label, owner_label);
|
||||
commit_window.set_focus().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_settings_window(
|
||||
app: AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
section: Option<String>,
|
||||
agent_type: Option<String>,
|
||||
state: tauri::State<'_, SettingsWindowState>,
|
||||
) -> Result<(), String> {
|
||||
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
|
||||
if let Some(existing) = app.get_webview_window("settings") {
|
||||
ensure_windows_undecorated(&existing);
|
||||
if section.is_some() || agent_type.is_some() {
|
||||
let target_path = format!("/{target_route}");
|
||||
let target_json = serde_json::to_string(&target_path).map_err(|e| e.to_string())?;
|
||||
let nav_script = format!("window.location.replace({target_json});");
|
||||
existing.eval(&nav_script).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let _ = existing.unminimize();
|
||||
existing.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let owner_label = window.label().to_string();
|
||||
let url = WebviewUrl::App(target_route.into());
|
||||
let builder = WebviewWindowBuilder::new(&app, "settings", url)
|
||||
.title("Settings")
|
||||
.inner_size(1080.0, 700.0)
|
||||
.min_inner_size(1080.0, 600.0)
|
||||
.always_on_top(true)
|
||||
.center();
|
||||
let settings_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&settings_window);
|
||||
|
||||
let mut disabled = HashSet::new();
|
||||
for (label, webview) in app.webview_windows() {
|
||||
if label != "settings" {
|
||||
webview.set_enabled(false).map_err(|e| e.to_string())?;
|
||||
disabled.insert(label);
|
||||
}
|
||||
}
|
||||
|
||||
state.set_owner(owner_label);
|
||||
state.set_disabled_windows(disabled);
|
||||
settings_window.set_focus().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_windows_after_settings(app: &AppHandle, state: &SettingsWindowState) {
|
||||
for label in state.take_disabled_windows() {
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.set_enabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(owner_label) = state.take_owner() {
|
||||
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_window_after_commit(
|
||||
app: &AppHandle,
|
||||
state: &CommitWindowState,
|
||||
commit_window_label: &str,
|
||||
) {
|
||||
if let Some(owner_label) = state.take_owner(commit_window_label) {
|
||||
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||
let _ = window.set_enabled(true);
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window("welcome") {
|
||||
ensure_windows_undecorated(&existing);
|
||||
return Ok(());
|
||||
}
|
||||
let url = WebviewUrl::App("welcome".into());
|
||||
let builder = WebviewWindowBuilder::new(app, "welcome", url)
|
||||
.title("Codeg")
|
||||
.inner_size(800.0, 520.0)
|
||||
.min_inner_size(600.0, 400.0)
|
||||
.center();
|
||||
let welcome_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&welcome_window);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user