完善folder页面的web接口实现
This commit is contained in:
@@ -82,7 +82,7 @@ fn package_name_from_spec(package: &str) -> String {
|
|||||||
/// Check whether a command is available on the system PATH.
|
/// Check whether a command is available on the system PATH.
|
||||||
/// Uses `which` on unix and `where` on windows — lightweight and does not
|
/// Uses `which` on unix and `where` on windows — lightweight and does not
|
||||||
/// invoke the target binary itself, avoiding side-effects or slow startups.
|
/// invoke the target binary itself, avoiding side-effects or slow startups.
|
||||||
fn is_cmd_available(cmd: &str) -> bool {
|
pub(crate) fn is_cmd_available(cmd: &str) -> bool {
|
||||||
which::which(cmd).is_ok()
|
which::which(cmd).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_agent_local_config_json(agent_type: AgentType) -> Option<String> {
|
pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option<String> {
|
||||||
if agent_type == AgentType::Codex {
|
if agent_type == AgentType::Codex {
|
||||||
return load_codex_local_config_json();
|
return load_codex_local_config_json();
|
||||||
}
|
}
|
||||||
@@ -979,7 +979,7 @@ fn important_env_targets(agent_type: AgentType) -> (&'static str, &'static str,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime_env_from_setting(
|
pub(crate) fn build_runtime_env_from_setting(
|
||||||
agent_type: AgentType,
|
agent_type: AgentType,
|
||||||
setting: Option<&crate::db::entities::agent_setting::Model>,
|
setting: Option<&crate::db::entities::agent_setting::Model>,
|
||||||
local_config_json: Option<&str>,
|
local_config_json: Option<&str>,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::terminal::types::TerminalInfo;
|
|||||||
/// Uses `credential.helper` with a script that calls the app binary with
|
/// Uses `credential.helper` with a script that calls the app binary with
|
||||||
/// `--credential-helper`. The binary opens the DB, looks up the matching
|
/// `--credential-helper`. The binary opens the DB, looks up the matching
|
||||||
/// account, and outputs credentials. No credentials are written to disk.
|
/// account, and outputs credentials. No credentials are written to disk.
|
||||||
fn prepare_credential_env(
|
pub(crate) fn prepare_credential_env(
|
||||||
app_data_dir: &std::path::Path,
|
app_data_dir: &std::path::Path,
|
||||||
) -> Option<HashMap<String, String>> {
|
) -> Option<HashMap<String, String>> {
|
||||||
// Get the path to the current running binary
|
// Get the path to the current running binary
|
||||||
|
|||||||
@@ -1,3 +1,141 @@
|
|||||||
// ACP (Agent Communication Protocol) web handlers.
|
use axum::{extract::Extension, Json};
|
||||||
// TODO: Implement ACP handlers for web mode.
|
use serde::Deserialize;
|
||||||
// These require special handling for connection lifecycle and streaming events.
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::acp::manager::ConnectionManager;
|
||||||
|
use crate::acp::registry;
|
||||||
|
use crate::acp::types::{AcpAgentInfo, AcpAgentStatus};
|
||||||
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::commands::acp as acp_commands;
|
||||||
|
use crate::db::service::agent_setting_service;
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentTypeParams {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acp_get_agent_status(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<AgentTypeParams>,
|
||||||
|
) -> Result<Json<AcpAgentStatus>, AppCommandError> {
|
||||||
|
let db = app.state::<crate::db::AppDatabase>();
|
||||||
|
let result = acp_commands::acp_get_agent_status(params.agent_type, db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acp_list_agents(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
) -> Result<Json<Vec<AcpAgentInfo>>, AppCommandError> {
|
||||||
|
let db = app.state::<crate::db::AppDatabase>();
|
||||||
|
let result = acp_commands::acp_list_agents(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AcpConnectParams {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub working_dir: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acp_connect(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<AcpConnectParams>,
|
||||||
|
) -> Result<Json<String>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
let manager = app.state::<ConnectionManager>();
|
||||||
|
let meta = registry::get_agent_meta(params.agent_type);
|
||||||
|
|
||||||
|
let setting = agent_setting_service::get_by_agent_type(&db.conn, params.agent_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
let disabled = setting
|
||||||
|
.as_ref()
|
||||||
|
.map(|model| !model.enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if disabled {
|
||||||
|
return Err(AppCommandError::task_execution_failed(format!(
|
||||||
|
"{} is disabled in settings",
|
||||||
|
params.agent_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_config_json = acp_commands::load_agent_local_config_json(params.agent_type);
|
||||||
|
let mut runtime_env = acp_commands::build_runtime_env_from_setting(
|
||||||
|
params.agent_type,
|
||||||
|
setting.as_ref(),
|
||||||
|
local_config_json.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if params.agent_type == AgentType::OpenClaw && params.session_id.is_none() {
|
||||||
|
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution {
|
||||||
|
if !acp_commands::is_cmd_available(cmd) {
|
||||||
|
return Err(AppCommandError::task_execution_failed(format!(
|
||||||
|
"{} SDK is not installed. Please install it in Agent Settings.",
|
||||||
|
meta.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection_id = manager
|
||||||
|
.spawn_agent(
|
||||||
|
params.agent_type,
|
||||||
|
params.working_dir,
|
||||||
|
params.session_id,
|
||||||
|
runtime_env,
|
||||||
|
"web".to_string(),
|
||||||
|
app.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(connection_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AcpDisconnectParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acp_disconnect(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<AcpDisconnectParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let manager = app.state::<ConnectionManager>();
|
||||||
|
manager
|
||||||
|
.disconnect(¶ms.connection_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AcpPromptParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub blocks: Vec<crate::acp::types::PromptInputBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acp_prompt(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<AcpPromptParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let manager = app.state::<ConnectionManager>();
|
||||||
|
manager
|
||||||
|
.send_prompt(¶ms.connection_id, params.blocks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,3 +262,21 @@ pub async fn delete_conversation(
|
|||||||
.map_err(AppCommandError::from)?;
|
.map_err(AppCommandError::from)?;
|
||||||
Ok(Json(()))
|
Ok(Json(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateConversationExternalIdParams {
|
||||||
|
pub conversation_id: i32,
|
||||||
|
pub external_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_conversation_external_id(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<UpdateConversationExternalIdParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
conversation_service::update_external_id(&db.conn, params.conversation_id, params.external_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,25 @@
|
|||||||
// Folder commands web handlers.
|
use axum::{extract::Extension, Json};
|
||||||
// TODO: Implement folder command CRUD handlers for web mode.
|
use serde::Deserialize;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::db::service::folder_command_service;
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FolderIdParams {
|
||||||
|
pub folder_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_folder_commands(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<FolderIdParams>,
|
||||||
|
) -> Result<Json<Vec<FolderCommandInfo>>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
let result = folder_command_service::list_by_folder(&db.conn, params.folder_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use axum::{extract::Extension, Json};
|
use axum::{extract::Extension, Json};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::app_error::AppCommandError;
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::commands::folders as folder_commands;
|
||||||
use crate::db::service::folder_service;
|
use crate::db::service::folder_service;
|
||||||
use crate::db::AppDatabase;
|
use crate::db::AppDatabase;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
@@ -54,5 +55,224 @@ pub async fn open_folder_window(
|
|||||||
Ok(Json(entry))
|
Ok(Json(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add remaining folder handlers (git operations, file operations, etc.)
|
// --- New handlers below ---
|
||||||
// These will be added incrementally as needed.
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveFolderOpenedConversationsParams {
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub items: Vec<OpenedConversation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_folder_opened_conversations(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<SaveFolderOpenedConversationsParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
let db = app.state::<AppDatabase>();
|
||||||
|
folder_service::save_opened_conversations(&db.conn, params.folder_id, params.items)
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::from)?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PathParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_git_branch(
|
||||||
|
Json(params): Json<PathParams>,
|
||||||
|
) -> Result<Json<Option<String>>, AppCommandError> {
|
||||||
|
let result = folder_commands::get_git_branch(params.path).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetFileTreeParams {
|
||||||
|
pub path: String,
|
||||||
|
pub max_depth: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_file_tree(
|
||||||
|
Json(params): Json<GetFileTreeParams>,
|
||||||
|
) -> Result<Json<Vec<folder_commands::FileTreeNode>>, AppCommandError> {
|
||||||
|
let result = folder_commands::get_file_tree(params.path, params.max_depth).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RootPathParams {
|
||||||
|
pub root_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_file_tree_watch(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<RootPathParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
folder_commands::start_file_tree_watch(app, params.root_path).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_file_tree_watch(
|
||||||
|
Json(params): Json<RootPathParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
folder_commands::stop_file_tree_watch(params.root_path).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OpenSettingsWindowParams {
|
||||||
|
pub section: Option<String>,
|
||||||
|
pub agent_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SettingsNavigationResult {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web equivalent of `open_settings_window`: returns the target navigation path.
|
||||||
|
/// The web client handles the actual navigation.
|
||||||
|
pub async fn open_settings_window(
|
||||||
|
Json(params): Json<OpenSettingsWindowParams>,
|
||||||
|
) -> Result<Json<SettingsNavigationResult>, AppCommandError> {
|
||||||
|
let route = match params.section.as_deref() {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = if route == "settings/agents" {
|
||||||
|
if let Some(ref agent) = params.agent_type {
|
||||||
|
let trimmed = agent.trim();
|
||||||
|
if !trimmed.is_empty()
|
||||||
|
&& trimmed
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||||
|
{
|
||||||
|
format!("/{route}?agent={trimmed}")
|
||||||
|
} else {
|
||||||
|
format!("/{route}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("/{route}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("/{route}")
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(SettingsNavigationResult { path }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitStatusParams {
|
||||||
|
pub path: String,
|
||||||
|
pub show_all_untracked: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_status(
|
||||||
|
Json(params): Json<GitStatusParams>,
|
||||||
|
) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
folder_commands::git_status(params.path, params.show_all_untracked).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReadFilePreviewParams {
|
||||||
|
pub root_path: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_file_preview(
|
||||||
|
Json(params): Json<ReadFilePreviewParams>,
|
||||||
|
) -> Result<Json<folder_commands::FilePreviewContent>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
folder_commands::read_file_preview(params.root_path, params.path).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_list_all_branches(
|
||||||
|
Json(params): Json<PathParams>,
|
||||||
|
) -> Result<Json<folder_commands::GitBranchList>, AppCommandError> {
|
||||||
|
let result = folder_commands::git_list_all_branches(params.path).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitCommitBranchesParams {
|
||||||
|
pub path: String,
|
||||||
|
pub commit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_commit_branches(
|
||||||
|
Json(params): Json<GitCommitBranchesParams>,
|
||||||
|
) -> Result<Json<Vec<String>>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
folder_commands::git_commit_branches(params.path, params.commit).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitShowFileParams {
|
||||||
|
pub path: String,
|
||||||
|
pub file: String,
|
||||||
|
pub ref_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_show_file(
|
||||||
|
Json(params): Json<GitShowFileParams>,
|
||||||
|
) -> Result<Json<String>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
folder_commands::git_show_file(params.path, params.file, params.ref_name).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitDiffParams {
|
||||||
|
pub path: String,
|
||||||
|
pub file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_diff(
|
||||||
|
Json(params): Json<GitDiffParams>,
|
||||||
|
) -> Result<Json<String>, AppCommandError> {
|
||||||
|
let result = folder_commands::git_diff(params.path, params.file).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_list_remotes(
|
||||||
|
Json(params): Json<PathParams>,
|
||||||
|
) -> Result<Json<Vec<folder_commands::GitRemote>>, AppCommandError> {
|
||||||
|
let result = folder_commands::git_list_remotes(params.path).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OpenCommitWindowParams {
|
||||||
|
pub folder_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web equivalent of `open_commit_window`: returns the navigation path.
|
||||||
|
pub async fn open_commit_window(
|
||||||
|
Json(params): Json<OpenCommitWindowParams>,
|
||||||
|
) -> Result<Json<SettingsNavigationResult>, AppCommandError> {
|
||||||
|
Ok(Json(SettingsNavigationResult {
|
||||||
|
path: format!("/commit?folderId={}", params.folder_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,45 @@
|
|||||||
// Terminal web handlers.
|
use axum::{extract::Extension, Json};
|
||||||
// TODO: Implement terminal handlers for web mode.
|
use serde::Deserialize;
|
||||||
// Terminal I/O streams over WebSocket instead of Tauri events.
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::commands::terminal::prepare_credential_env;
|
||||||
|
use crate::terminal::manager::{SpawnOptions, TerminalManager};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TerminalSpawnParams {
|
||||||
|
pub working_dir: String,
|
||||||
|
pub initial_command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn terminal_spawn(
|
||||||
|
Extension(app): Extension<tauri::AppHandle>,
|
||||||
|
Json(params): Json<TerminalSpawnParams>,
|
||||||
|
) -> Result<Json<String>, AppCommandError> {
|
||||||
|
let manager = app.state::<TerminalManager>();
|
||||||
|
let terminal_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
|
||||||
|
let extra_env = prepare_credential_env(&app_data_dir);
|
||||||
|
|
||||||
|
let id = manager
|
||||||
|
.spawn_with_id(
|
||||||
|
SpawnOptions {
|
||||||
|
terminal_id,
|
||||||
|
working_dir: params.working_dir,
|
||||||
|
owner_window_label: "web".to_string(),
|
||||||
|
initial_command: params.initial_command,
|
||||||
|
extra_env,
|
||||||
|
temp_files: vec![],
|
||||||
|
},
|
||||||
|
app.clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(id))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,22 @@
|
|||||||
// Version control web handlers.
|
use axum::Json;
|
||||||
// TODO: Implement git settings and GitHub account handlers for web mode.
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app_error::AppCommandError;
|
||||||
|
use crate::commands::folders as folder_commands;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitLogParams {
|
||||||
|
pub path: String,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub remote: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_log(
|
||||||
|
Json(params): Json<GitLogParams>,
|
||||||
|
) -> Result<Json<folder_commands::GitLogResult>, AppCommandError> {
|
||||||
|
let result =
|
||||||
|
folder_commands::git_log(params.path, params.limit, params.branch, params.remote).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path:
|
|||||||
.route("/update_conversation_status", post(handlers::conversations::update_conversation_status))
|
.route("/update_conversation_status", post(handlers::conversations::update_conversation_status))
|
||||||
.route("/update_conversation_title", post(handlers::conversations::update_conversation_title))
|
.route("/update_conversation_title", post(handlers::conversations::update_conversation_title))
|
||||||
.route("/delete_conversation", post(handlers::conversations::delete_conversation))
|
.route("/delete_conversation", post(handlers::conversations::delete_conversation))
|
||||||
|
.route("/update_conversation_external_id", post(handlers::conversations::update_conversation_external_id))
|
||||||
// Folders
|
// Folders
|
||||||
.route("/load_folder_history", post(handlers::folders::load_folder_history))
|
.route("/load_folder_history", post(handlers::folders::load_folder_history))
|
||||||
.route("/get_folder", post(handlers::folders::get_folder))
|
.route("/get_folder", post(handlers::folders::get_folder))
|
||||||
@@ -42,6 +43,35 @@ pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path:
|
|||||||
// System settings
|
// System settings
|
||||||
.route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings))
|
.route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings))
|
||||||
.route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings))
|
.route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings))
|
||||||
|
// Folders (extended)
|
||||||
|
.route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations))
|
||||||
|
.route("/get_git_branch", post(handlers::folders::get_git_branch))
|
||||||
|
.route("/get_file_tree", post(handlers::folders::get_file_tree))
|
||||||
|
.route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch))
|
||||||
|
.route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch))
|
||||||
|
.route("/open_settings_window", post(handlers::folders::open_settings_window))
|
||||||
|
// Version control
|
||||||
|
.route("/git_log", post(handlers::version_control::git_log))
|
||||||
|
// Folder commands
|
||||||
|
.route("/list_folder_commands", post(handlers::folder_commands::list_folder_commands))
|
||||||
|
// Git operations
|
||||||
|
.route("/git_status", post(handlers::folders::git_status))
|
||||||
|
.route("/git_list_all_branches", post(handlers::folders::git_list_all_branches))
|
||||||
|
.route("/git_commit_branches", post(handlers::folders::git_commit_branches))
|
||||||
|
.route("/git_show_file", post(handlers::folders::git_show_file))
|
||||||
|
.route("/git_diff", post(handlers::folders::git_diff))
|
||||||
|
.route("/git_list_remotes", post(handlers::folders::git_list_remotes))
|
||||||
|
.route("/open_commit_window", post(handlers::folders::open_commit_window))
|
||||||
|
// File operations
|
||||||
|
.route("/read_file_preview", post(handlers::folders::read_file_preview))
|
||||||
|
// ACP
|
||||||
|
.route("/acp_get_agent_status", post(handlers::acp::acp_get_agent_status))
|
||||||
|
.route("/acp_list_agents", post(handlers::acp::acp_list_agents))
|
||||||
|
.route("/acp_connect", post(handlers::acp::acp_connect))
|
||||||
|
.route("/acp_disconnect", post(handlers::acp::acp_disconnect))
|
||||||
|
.route("/acp_prompt", post(handlers::acp::acp_prompt))
|
||||||
|
// Terminal
|
||||||
|
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
||||||
// Catch-all: return proper JSON 404 for unimplemented API endpoints
|
// Catch-all: return proper JSON 404 for unimplemented API endpoints
|
||||||
.fallback(api_not_found)
|
.fallback(api_not_found)
|
||||||
// Auth middleware for API routes
|
// Auth middleware for API routes
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn, randomUUID } from "@/lib/utils"
|
||||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||||
import { readFileBase64 } from "@/lib/api"
|
import { readFileBase64 } from "@/lib/api"
|
||||||
@@ -245,7 +245,7 @@ function isTextLikeFile(file: File): boolean {
|
|||||||
|
|
||||||
function buildClipboardResourceUri(name: string): string {
|
function buildClipboardResourceUri(name: string): string {
|
||||||
const normalizedName = name.trim() || "clipboard-resource"
|
const normalizedName = name.trim() || "clipboard-resource"
|
||||||
return `clipboard://${encodeURIComponent(normalizedName)}-${crypto.randomUUID()}`
|
return `clipboard://${encodeURIComponent(normalizedName)}-${randomUUID()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDataUri(base64Data: string, mimeType: string | null): string {
|
function buildDataUri(base64Data: string, mimeType: string | null): string {
|
||||||
@@ -491,7 +491,7 @@ export function MessageInput({
|
|||||||
setAttachments((prev) => [
|
setAttachments((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
...resources.map((resource) => ({
|
...resources.map((resource) => ({
|
||||||
id: `resource-embedded:${crypto.randomUUID()}`,
|
id: `resource-embedded:${randomUUID()}`,
|
||||||
type: "resource" as const,
|
type: "resource" as const,
|
||||||
kind: "embedded" as const,
|
kind: "embedded" as const,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
@@ -530,7 +530,7 @@ export function MessageInput({
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const path = getFilePath(file)
|
const path = getFilePath(file)
|
||||||
const name = file.name || `resource-${crypto.randomUUID()}`
|
const name = file.name || `resource-${randomUUID()}`
|
||||||
const mimeType = file.type || mimeTypeFromPath(name)
|
const mimeType = file.type || mimeTypeFromPath(name)
|
||||||
if (path) {
|
if (path) {
|
||||||
const uri = toFileUri(path)
|
const uri = toFileUri(path)
|
||||||
@@ -596,7 +596,7 @@ export function MessageInput({
|
|||||||
: (mimeTypeFromPath(file.name) ?? "image/png")
|
: (mimeTypeFromPath(file.name) ?? "image/png")
|
||||||
const base64Data = await blobToBase64(file)
|
const base64Data = await blobToBase64(file)
|
||||||
return {
|
return {
|
||||||
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
|
id: `image:${Date.now()}:${index}:${randomUUID()}`,
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
data: base64Data,
|
data: base64Data,
|
||||||
uri: null,
|
uri: null,
|
||||||
@@ -615,7 +615,7 @@ export function MessageInput({
|
|||||||
paths.map(async (path, index) => {
|
paths.map(async (path, index) => {
|
||||||
const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES)
|
const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES)
|
||||||
return {
|
return {
|
||||||
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
|
id: `image:${Date.now()}:${index}:${randomUUID()}`,
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
data,
|
data,
|
||||||
uri: toFileUri(path),
|
uri: toFileUri(path),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useAcpActions } from "@/contexts/acp-connections-context"
|
|||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn, randomUUID } from "@/lib/utils"
|
||||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||||
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
|
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
|
||||||
import { MessageListView } from "@/components/message/message-list-view"
|
import { MessageListView } from "@/components/message/message-list-view"
|
||||||
@@ -103,7 +103,7 @@ function buildOptimisticUserTurnFromDraft(
|
|||||||
blocks.push({ type: "text", text })
|
blocks.push({ type: "text", text })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `optimistic-${crypto.randomUUID()}`,
|
id: `optimistic-${randomUUID()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
blocks,
|
blocks,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -762,7 +762,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
(answer: string) => {
|
(answer: string) => {
|
||||||
if (connStatus !== "connected") return
|
if (connStatus !== "connected") return
|
||||||
const optimisticTurn: MessageTurn = {
|
const optimisticTurn: MessageTurn = {
|
||||||
id: `optimistic-${crypto.randomUUID()}`,
|
id: `optimistic-${randomUUID()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
blocks: [{ type: "text", text: answer }],
|
blocks: [{ type: "text", text: answer }],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { Eye, EyeOff } from "lucide-react"
|
import { Eye, EyeOff } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { randomUUID } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +73,7 @@ export function AddGitAccountDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const account: GitHubAccount = {
|
const account: GitHubAccount = {
|
||||||
id: crypto.randomUUID(),
|
id: randomUUID(),
|
||||||
server_url: trimmedUrl,
|
server_url: trimmedUrl,
|
||||||
username: trimmedUser,
|
username: trimmedUser,
|
||||||
scopes: [],
|
scopes: [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react"
|
|||||||
import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-react"
|
import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
import { openUrl } from "@/lib/platform"
|
import { openUrl } from "@/lib/platform"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { randomUUID } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -91,7 +92,7 @@ export function AddGitHubAccountDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const account: GitHubAccount = {
|
const account: GitHubAccount = {
|
||||||
id: crypto.randomUUID(),
|
id: randomUUID(),
|
||||||
server_url: serverUrl.trim() || "https://github.com",
|
server_url: serverUrl.trim() || "https://github.com",
|
||||||
username: result.username ?? "unknown",
|
username: result.username ?? "unknown",
|
||||||
scopes: result.scopes,
|
scopes: result.scopes,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { subscribe } from "@/lib/platform"
|
import { subscribe } from "@/lib/platform"
|
||||||
|
import { randomUUID } from "@/lib/utils"
|
||||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||||
import {
|
import {
|
||||||
acpConnect,
|
acpConnect,
|
||||||
@@ -536,7 +537,7 @@ function connectionsReducer(
|
|||||||
const updated = { ...conn, status: action.status }
|
const updated = { ...conn, status: action.status }
|
||||||
if (action.status === "prompting") {
|
if (action.status === "prompting") {
|
||||||
updated.liveMessage = {
|
updated.liveMessage = {
|
||||||
id: crypto.randomUUID(),
|
id: randomUUID(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [],
|
content: [],
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import { randomUUID } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -147,7 +148,7 @@ async function saveGenericAccount(
|
|||||||
(a) => a.username === creds.username && extractHost(a.server_url) === host
|
(a) => a.username === creds.username && extractHost(a.server_url) === host
|
||||||
)
|
)
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
const newId = crypto.randomUUID()
|
const newId = randomUUID()
|
||||||
await saveAccountToken(newId, creds.password)
|
await saveAccountToken(newId, creds.password)
|
||||||
await updateGitHubAccounts({
|
await updateGitHubAccounts({
|
||||||
accounts: [
|
accounts: [
|
||||||
@@ -283,7 +284,7 @@ export function GitCredentialProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
const newAccount = {
|
const newAccount = {
|
||||||
id: crypto.randomUUID(),
|
id: randomUUID(),
|
||||||
server_url: serverUrl,
|
server_url: serverUrl,
|
||||||
username: result.username ?? "unknown",
|
username: result.username ?? "unknown",
|
||||||
scopes: result.scopes,
|
scopes: result.scopes,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import type { PromptDraft } from "@/lib/types"
|
import type { PromptDraft } from "@/lib/types"
|
||||||
|
import { randomUUID } from "@/lib/utils"
|
||||||
|
|
||||||
export interface QueuedMessage {
|
export interface QueuedMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -31,7 +32,7 @@ export function useMessageQueue(): UseMessageQueueReturn {
|
|||||||
|
|
||||||
const enqueue = useCallback((draft: PromptDraft, modeId: string | null) => {
|
const enqueue = useCallback((draft: PromptDraft, modeId: string | null) => {
|
||||||
const item: QueuedMessage = {
|
const item: QueuedMessage = {
|
||||||
id: crypto.randomUUID(),
|
id: randomUUID(),
|
||||||
draft,
|
draft,
|
||||||
modeId,
|
modeId,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -852,7 +852,14 @@ export async function openFolderWindow(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openCommitWindow(folderId: number): Promise<void> {
|
export async function openCommitWindow(folderId: number): Promise<void> {
|
||||||
return getTransport().call("open_commit_window", { folderId })
|
if (getTransport().isDesktop()) {
|
||||||
|
return getTransport().call("open_commit_window", { folderId })
|
||||||
|
}
|
||||||
|
const result = await getTransport().call<{ path: string }>(
|
||||||
|
"open_commit_window",
|
||||||
|
{ folderId },
|
||||||
|
)
|
||||||
|
window.location.href = result.path
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsSection =
|
export type SettingsSection =
|
||||||
@@ -871,10 +878,21 @@ export async function openSettingsWindow(
|
|||||||
section?: SettingsSection,
|
section?: SettingsSection,
|
||||||
options?: OpenSettingsWindowOptions
|
options?: OpenSettingsWindowOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return getTransport().call("open_settings_window", {
|
if (getTransport().isDesktop()) {
|
||||||
section: section ?? null,
|
return getTransport().call("open_settings_window", {
|
||||||
agentType: options?.agentType ?? null,
|
section: section ?? null,
|
||||||
})
|
agentType: options?.agentType ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Web mode: get navigation path from backend and navigate
|
||||||
|
const result = await getTransport().call<{ path: string }>(
|
||||||
|
"open_settings_window",
|
||||||
|
{
|
||||||
|
section: section ?? null,
|
||||||
|
agentType: options?.agentType ?? null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
window.location.href = result.path
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
|
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
|
||||||
|
|||||||
@@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a UUID v4. Uses `crypto.randomUUID()` when available (secure
|
||||||
|
* contexts), otherwise falls back to `crypto.getRandomValues()`.
|
||||||
|
*/
|
||||||
|
export function randomUUID(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
// Fallback for non-secure contexts (HTTP over LAN)
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
// Set version 4 and variant bits
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user