diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d60e6bd..d3cd999 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod model_provider; #[cfg(feature = "tauri-runtime")] pub mod notification; pub mod project_boot; +pub mod quick_messages; pub mod system_settings; pub mod terminal; pub mod version_control; diff --git a/src-tauri/src/commands/quick_messages.rs b/src-tauri/src/commands/quick_messages.rs new file mode 100644 index 0000000..758b741 --- /dev/null +++ b/src-tauri/src/commands/quick_messages.rs @@ -0,0 +1,55 @@ +#[cfg(feature = "tauri-runtime")] +use crate::db::error::DbError; +#[cfg(feature = "tauri-runtime")] +use crate::db::service::quick_message_service; +#[cfg(feature = "tauri-runtime")] +use crate::db::AppDatabase; +#[cfg(feature = "tauri-runtime")] +use crate::models::QuickMessageInfo; + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn quick_messages_list( + db: tauri::State<'_, AppDatabase>, +) -> Result, DbError> { + quick_message_service::list(&db.conn).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn quick_messages_create( + db: tauri::State<'_, AppDatabase>, + title: String, + content: String, +) -> Result { + quick_message_service::create(&db.conn, &title, &content).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn quick_messages_update( + db: tauri::State<'_, AppDatabase>, + id: i32, + title: Option, + content: Option, +) -> Result { + quick_message_service::update(&db.conn, id, title, content).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn quick_messages_delete( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), DbError> { + quick_message_service::delete(&db.conn, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn quick_messages_reorder( + db: tauri::State<'_, AppDatabase>, + ids: Vec, +) -> Result<(), DbError> { + quick_message_service::reorder(&db.conn, ids).await +} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index a19ec29..89d5b6b 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -9,3 +9,4 @@ pub mod folder_command; pub mod model_provider; pub mod opened_tab; pub mod prelude; +pub mod quick_message; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 320b617..59ca8cc 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -10,3 +10,4 @@ pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; pub use super::model_provider::Entity as ModelProvider; pub use super::opened_tab::Entity as OpenedTab; +pub use super::quick_message::Entity as QuickMessage; diff --git a/src-tauri/src/db/entities/quick_message.rs b/src-tauri/src/db/entities/quick_message.rs new file mode 100644 index 0000000..e3d361e --- /dev/null +++ b/src-tauri/src/db/entities/quick_message.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "quick_message")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + #[sea_orm(column_type = "Text")] + pub content: String, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/migration/m20260424_000002_quick_message.rs b/src-tauri/src/db/migration/m20260424_000002_quick_message.rs new file mode 100644 index 0000000..a503f09 --- /dev/null +++ b/src-tauri/src/db/migration/m20260424_000002_quick_message.rs @@ -0,0 +1,70 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(QuickMessage::Table) + .if_not_exists() + .col( + ColumnDef::new(QuickMessage::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(QuickMessage::Title).string().not_null()) + .col(ColumnDef::new(QuickMessage::Content).text().not_null()) + .col( + ColumnDef::new(QuickMessage::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(QuickMessage::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(QuickMessage::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_quick_message_sort_order") + .table(QuickMessage::Table) + .col(QuickMessage::SortOrder) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(QuickMessage::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum QuickMessage { + Table, + Id, + Title, + Content, + SortOrder, + CreatedAt, + UpdatedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 0007e5e..a440f04 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -13,6 +13,7 @@ mod m20260420_000001_opened_tabs; mod m20260422_000001_folder_sort_order; mod m20260423_000001_drop_folder_parent_branch; mod m20260424_000001_folder_color; +mod m20260424_000002_quick_message; pub struct Migrator; #[async_trait::async_trait] @@ -32,6 +33,7 @@ impl MigratorTrait for Migrator { Box::new(m20260422_000001_folder_sort_order::Migration), Box::new(m20260423_000001_drop_folder_parent_branch::Migration), Box::new(m20260424_000001_folder_color::Migration), + Box::new(m20260424_000002_quick_message::Migration), ] } } diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index 49f7c08..be73479 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -7,5 +7,6 @@ pub mod folder_command_service; pub mod folder_service; pub mod import_service; pub mod model_provider_service; +pub mod quick_message_service; pub mod sender_context_service; pub mod tab_service; diff --git a/src-tauri/src/db/service/quick_message_service.rs b/src-tauri/src/db/service/quick_message_service.rs new file mode 100644 index 0000000..8e3f0a0 --- /dev/null +++ b/src-tauri/src/db/service/quick_message_service.rs @@ -0,0 +1,114 @@ +use chrono::Utc; +use sea_orm::DatabaseConnection; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ConnectionTrait, DbBackend, EntityTrait, + IntoActiveModel, QueryOrder, Set, Statement, +}; + +use crate::db::entities::quick_message; +use crate::db::error::DbError; +use crate::models::QuickMessageInfo; + +fn to_info(m: quick_message::Model) -> QuickMessageInfo { + QuickMessageInfo { + id: m.id, + title: m.title, + content: m.content, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +pub async fn list(conn: &DatabaseConnection) -> Result, DbError> { + let rows = quick_message::Entity::find() + .order_by_asc(quick_message::Column::SortOrder) + .all(conn) + .await?; + + Ok(rows.into_iter().map(to_info).collect()) +} + +pub async fn create( + conn: &DatabaseConnection, + title: &str, + content: &str, +) -> Result { + let now = Utc::now(); + + let max_order = quick_message::Entity::find() + .order_by_desc(quick_message::Column::SortOrder) + .one(conn) + .await? + .map(|m| m.sort_order) + .unwrap_or(-1); + + let active = quick_message::ActiveModel { + id: NotSet, + title: Set(title.to_string()), + content: Set(content.to_string()), + sort_order: Set(max_order + 1), + created_at: Set(now), + updated_at: Set(now), + }; + + let model = active.insert(conn).await?; + Ok(to_info(model)) +} + +pub async fn update( + conn: &DatabaseConnection, + id: i32, + title: Option, + content: Option, +) -> Result { + let row = quick_message::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("QuickMessage {} not found", id)))?; + + let mut active = row.into_active_model(); + if let Some(t) = title { + active.title = Set(t); + } + if let Some(c) = content { + active.content = Set(c); + } + active.updated_at = Set(Utc::now()); + + let model = active.update(conn).await?; + Ok(to_info(model)) +} + +pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + quick_message::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn reorder(conn: &DatabaseConnection, ids: Vec) -> Result<(), DbError> { + if ids.is_empty() { + return Ok(()); + } + + let now = Utc::now(); + let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string(); + let case_expr = ids + .iter() + .enumerate() + .map(|(idx, id)| format!("WHEN {} THEN {}", id, idx)) + .collect::>() + .join(" "); + let id_list = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + + let sql = format!( + "UPDATE quick_message SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE id IN ({id_list})" + ); + conn.execute(Statement::from_string(DbBackend::Sqlite, sql)) + .await?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f4b4c6..85ac98f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,7 +24,8 @@ mod tauri_app { use crate::commands::{ acp as acp_commands, chat_channel as chat_channel_commands, conversations, experts as experts_commands, folder_commands, folders, mcp as mcp_commands, - model_provider as model_provider_commands, notification, project_boot, system_settings, + model_provider as model_provider_commands, notification, project_boot, + quick_messages as quick_messages_commands, system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; @@ -354,6 +355,11 @@ mod tauri_app { folder_commands::delete_folder_command, folder_commands::reorder_folder_commands, folder_commands::bootstrap_folder_commands_from_package_json, + quick_messages_commands::quick_messages_list, + quick_messages_commands::quick_messages_create, + quick_messages_commands::quick_messages_update, + quick_messages_commands::quick_messages_delete, + quick_messages_commands::quick_messages_reorder, terminal_commands::terminal_spawn, terminal_commands::terminal_write, terminal_commands::terminal_resize, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 05ab57f..8c56b3d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -4,6 +4,7 @@ pub mod conversation; pub mod folder; pub mod message; pub mod model_provider; +pub mod quick_message; pub mod system; pub use agent::AgentType; @@ -15,6 +16,7 @@ pub use conversation::{ SidebarData, }; pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedTab}; +pub use quick_message::QuickMessageInfo; pub use message::{ AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage, diff --git a/src-tauri/src/models/quick_message.rs b/src-tauri/src/models/quick_message.rs new file mode 100644 index 0000000..c1b3ed5 --- /dev/null +++ b/src-tauri/src/models/quick_message.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct QuickMessageInfo { + pub id: i32, + pub title: String, + pub content: String, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index cc7d7de..172cb0a 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -10,6 +10,7 @@ pub mod git; pub mod mcp; pub mod model_provider; pub mod project_boot; +pub mod quick_messages; pub mod system_settings; pub mod terminal; pub mod version_control; diff --git a/src-tauri/src/web/handlers/quick_messages.rs b/src-tauri/src/web/handlers/quick_messages.rs new file mode 100644 index 0000000..32c9cef --- /dev/null +++ b/src-tauri/src/web/handlers/quick_messages.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::db::service::quick_message_service; +use crate::models::QuickMessageInfo; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateQuickMessageParams { + pub title: String, + pub content: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateQuickMessageParams { + pub id: i32, + pub title: Option, + pub content: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteQuickMessageParams { + pub id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReorderQuickMessagesParams { + pub ids: Vec, +} + +pub async fn quick_messages_list( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let result = quick_message_service::list(&state.db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn quick_messages_create( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = quick_message_service::create(&state.db.conn, ¶ms.title, ¶ms.content) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn quick_messages_update( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = + quick_message_service::update(&state.db.conn, params.id, params.title, params.content) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn quick_messages_delete( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + quick_message_service::delete(&state.db.conn, params.id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +pub async fn quick_messages_reorder( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + quick_message_service::reorder(&state.db.conn, params.ids) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index f7717c6..804c8ba 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -631,6 +631,27 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: "/delete_model_provider", post(handlers::model_provider::delete_model_provider), ) + // ─── Quick Messages ─── + .route( + "/quick_messages_list", + post(handlers::quick_messages::quick_messages_list), + ) + .route( + "/quick_messages_create", + post(handlers::quick_messages::quick_messages_create), + ) + .route( + "/quick_messages_update", + post(handlers::quick_messages::quick_messages_update), + ) + .route( + "/quick_messages_delete", + post(handlers::quick_messages::quick_messages_delete), + ) + .route( + "/quick_messages_reorder", + post(handlers::quick_messages::quick_messages_reorder), + ) // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) diff --git a/src/app/settings/quick-messages/page.tsx b/src/app/settings/quick-messages/page.tsx new file mode 100644 index 0000000..0f00fa5 --- /dev/null +++ b/src/app/settings/quick-messages/page.tsx @@ -0,0 +1,5 @@ +import { QuickMessagesSettings } from "@/components/settings/quick-messages-settings" + +export default function SettingsQuickMessagesPage() { + return +} diff --git a/src/components/settings/quick-messages-settings.tsx b/src/components/settings/quick-messages-settings.tsx new file mode 100644 index 0000000..285f9a7 --- /dev/null +++ b/src/components/settings/quick-messages-settings.tsx @@ -0,0 +1,583 @@ +"use client" + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent, + type ReactNode, +} from "react" +import { GripVertical, Loader2, Plus, Save, Trash2 } from "lucide-react" +import { Reorder, useDragControls } from "motion/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 { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { + quickMessagesCreate, + quickMessagesDelete, + quickMessagesList, + quickMessagesReorder, + quickMessagesUpdate, +} from "@/lib/api" +import type { QuickMessage } from "@/lib/types" + +const LEFT_MIN_WIDTH = 280 +const RIGHT_MIN_WIDTH = 420 + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +function toPercent(pixels: number, totalPixels: number): number { + if (totalPixels <= 0) return 0 + return (pixels / totalPixels) * 100 +} + +interface QuickMessageReorderItemProps { + message: QuickMessage + selected: boolean + reordering: boolean + onSelect: (id: number) => void + onDragStart: () => void + onDragEnd: () => void + children: ( + startDrag: (event: PointerEvent) => void + ) => ReactNode +} + +function QuickMessageReorderItem({ + message, + selected, + reordering, + onSelect, + onDragStart, + onDragEnd, + children, +}: QuickMessageReorderItemProps) { + const dragControls = useDragControls() + + const startDrag = useCallback( + (event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + dragControls.start(event) + }, + [dragControls] + ) + + return ( + onSelect(message.id)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + onSelect(message.id) + }} + > + {children(startDrag)} + + ) +} + +export function QuickMessagesSettings() { + const t = useTranslations("QuickMessagesSettings") + + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [searchQuery, setSearchQuery] = useState("") + + const [draftTitle, setDraftTitle] = useState("") + const [draftContent, setDraftContent] = useState("") + const [saving, setSaving] = useState(false) + const [creating, setCreating] = useState(false) + const [deleteTargetId, setDeleteTargetId] = useState(null) + const [deleting, setDeleting] = useState(false) + + const [reordering, setReordering] = useState(false) + const pendingOrderRef = useRef(null) + + const panelContainerRef = useRef(null) + const [panelContainerWidth, setPanelContainerWidth] = useState(0) + const titleInputRef = useRef(null) + + const refresh = useCallback(async () => { + setLoading(true) + setLoadError(null) + try { + const list = await quickMessagesList() + setMessages(list) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setLoadError(message) + setMessages([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + refresh().catch((err) => { + console.error("[QuickMessagesSettings] initial refresh failed:", err) + }) + }, [refresh]) + + useEffect(() => { + const container = panelContainerRef.current + if (!container) return + const updateWidth = (next: number) => { + setPanelContainerWidth((prev) => + Math.abs(prev - next) < 1 ? prev : next + ) + } + updateWidth(container.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + updateWidth( + entries[0]?.contentRect.width ?? container.getBoundingClientRect().width + ) + }) + observer.observe(container) + return () => { + observer.disconnect() + } + }, []) + + const selectedMessage = useMemo( + () => messages.find((m) => m.id === selectedId) ?? null, + [messages, selectedId] + ) + + useEffect(() => { + if (selectedMessage) { + setDraftTitle(selectedMessage.title) + setDraftContent(selectedMessage.content) + } else { + setDraftTitle("") + setDraftContent("") + } + }, [selectedMessage]) + + useEffect(() => { + if (selectedId === null && messages.length > 0) { + setSelectedId(messages[0].id) + } + }, [selectedId, messages]) + + const filteredMessages = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return messages + return messages.filter( + (m) => + m.title.toLowerCase().includes(q) || m.content.toLowerCase().includes(q) + ) + }, [messages, searchQuery]) + + const isDirty = useMemo(() => { + if (!selectedMessage) return false + return ( + draftTitle !== selectedMessage.title || + draftContent !== selectedMessage.content + ) + }, [selectedMessage, draftTitle, draftContent]) + + const persistReorder = useCallback( + async (ids: number[]) => { + if (ids.length === 0) return + setReordering(true) + try { + await quickMessagesReorder(ids) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(t("toasts.saveOrderFailed"), { description: message }) + await refresh() + } finally { + setReordering(false) + } + }, + [refresh, t] + ) + + const handleReorder = useCallback((next: QuickMessage[]) => { + const reordered = next.map((m, index) => ({ ...m, sort_order: index })) + setMessages(reordered) + pendingOrderRef.current = reordered.map((m) => m.id) + }, []) + + const handleCreate = useCallback(async () => { + setCreating(true) + try { + const created = await quickMessagesCreate({ title: "", content: "" }) + setMessages((prev) => [...prev, created]) + setSelectedId(created.id) + toast.success(t("toasts.created")) + requestAnimationFrame(() => { + titleInputRef.current?.focus() + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(t("toasts.createFailed"), { description: message }) + } finally { + setCreating(false) + } + }, [t]) + + const handleSave = useCallback(async () => { + if (!selectedMessage) return + setSaving(true) + try { + const updated = await quickMessagesUpdate({ + id: selectedMessage.id, + title: draftTitle, + content: draftContent, + }) + setMessages((prev) => + prev.map((m) => (m.id === updated.id ? updated : m)) + ) + toast.success(t("toasts.saved")) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(t("toasts.saveFailed"), { description: message }) + } finally { + setSaving(false) + } + }, [selectedMessage, draftTitle, draftContent, t]) + + const handleDelete = useCallback(async () => { + if (deleteTargetId === null) return + const target = deleteTargetId + setDeleting(true) + try { + await quickMessagesDelete(target) + setMessages((prev) => { + const next = prev.filter((m) => m.id !== target) + if (selectedId === target) { + setSelectedId(next[0]?.id ?? null) + } + return next + }) + toast.success(t("toasts.deleted")) + setDeleteTargetId(null) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(t("toasts.deleteFailed"), { description: message }) + } finally { + setDeleting(false) + } + }, [deleteTargetId, selectedId, t]) + + const safeContainerWidth = + panelContainerWidth > 0 ? panelContainerWidth : 1200 + const leftMinSize = clamp( + toPercent(LEFT_MIN_WIDTH, safeContainerWidth), + 5, + 95 + ) + const rightMinSize = clamp( + toPercent(RIGHT_MIN_WIDTH, safeContainerWidth), + 5, + 95 + ) + const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize) + + if (loading) { + return ( +
+ + {t("loading")} +
+ ) + } + + const deleteTargetMessage = + deleteTargetId !== null + ? (messages.find((m) => m.id === deleteTargetId) ?? null) + : null + + return ( +
+
+
+

{t("title")}

+

+ {t("description")} +

+
+
+ + {loadError && ( +
+ {loadError} +
+ )} + +
+ + +
+
+
+ setSearchQuery(event.target.value)} + placeholder={t("searchPlaceholder")} + /> + +
+
+ + {filteredMessages.length === 0 ? ( +
+ {messages.length === 0 + ? t("emptyList") + : t("searchPlaceholder")} +
+ ) : ( + + {filteredMessages.map((m) => ( + setSelectedId(id)} + onDragStart={() => { + /* no-op: list re-render handles dragging state */ + }} + onDragEnd={() => { + const order = pendingOrderRef.current + pendingOrderRef.current = null + if (order && !reordering) { + persistReorder(order).catch((err) => { + console.error( + "[QuickMessagesSettings] reorder failed:", + err + ) + }) + } + }} + > + {(startDrag) => ( +
+ +
+
+ {m.title || ( + + {t("untitled")} + + )} +
+ {m.content && ( +
+ {m.content} +
+ )} +
+
+ )} +
+ ))} +
+ )} +
+
+ + + + +
+ {selectedMessage ? ( +
+
+
+ + setDraftTitle(event.target.value)} + placeholder={t("fields.titlePlaceholder")} + /> +
+
+ +