diff --git a/.cursorrules b/.cursorrules index 7edf7fb8..d5af3e40 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,5 +1,5 @@ # Cursor AI Rules for Cheolsu Proxy Project -당신은 TypeScript, React19, Shadcn UI, Radix UI, Tailwind, Rust, Tauri, MITM(Man In the Middle attack) Proxy, FSD(Feature-Sliced Design), zustand 전문가입니다. +당신은 TypeScript, React19, Shadcn UI, Radix UI, Tailwind, Rust, Tauri, MITM(Man In The Middle attack) Proxy, FSD(Feature-Sliced Design), zustand 전문가입니다. ## 프로젝트 개요 - Cheolsu Proxy Rust 기반의 프록시 도구로, Tauri를 사용한 데스크톱 애플리케이션입니다. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c41d5fe1..01923777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,7 +119,7 @@ importers: specifier: ^1.3.8 version: 1.4.0 zod: - specifier: ^4.1.8 + specifier: ^4.1.12 version: 4.1.12 zustand: specifier: ^5.0.8 diff --git a/tauri-ui/package.json b/tauri-ui/package.json index 11bff741..fea9fce4 100644 --- a/tauri-ui/package.json +++ b/tauri-ui/package.json @@ -45,7 +45,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.12", "tw-animate-css": "^1.3.8", - "zod": "^4.1.8", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/tauri-ui/pnpm-lock.yaml b/tauri-ui/pnpm-lock.yaml index 01409bdd..378ff745 100644 --- a/tauri-ui/pnpm-lock.yaml +++ b/tauri-ui/pnpm-lock.yaml @@ -111,8 +111,8 @@ importers: specifier: ^1.3.8 version: 1.3.8 zod: - specifier: ^4.1.8 - version: 4.1.9 + specifier: ^4.1.12 + version: 4.1.12 zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@19.1.12)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)) @@ -2384,8 +2384,8 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} - zod@4.1.9: - resolution: {integrity: sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} zustand@5.0.8: resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} @@ -4559,7 +4559,7 @@ snapshots: yn@3.1.1: {} - zod@4.1.9: {} + zod@4.1.12: {} zustand@5.0.8(@types/react@19.1.12)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)): optionalDependencies: diff --git a/tauri-ui/src-tauri/Cargo.toml b/tauri-ui/src-tauri/Cargo.toml index 9e369e98..d1e2739f 100644 --- a/tauri-ui/src-tauri/Cargo.toml +++ b/tauri-ui/src-tauri/Cargo.toml @@ -16,9 +16,6 @@ edition = "2021" name = "cheolsu_proxy" crate-type = ["staticlib", "cdylib", "rlib"] -# 빌드 스크립트 추가 -build = "build.rs" - [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/tauri-ui/src/app/App.tsx b/tauri-ui/src/app/App.tsx index 4a2e6758..3500fe90 100644 --- a/tauri-ui/src/app/App.tsx +++ b/tauri-ui/src/app/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useThemeProvider, RouterProvider } from './providers'; +import { useThemeProvider, RouterProvider, ProxyEventProvider } from './providers'; import { Toaster } from '@/shared/ui'; import { useProxyStore } from '@/shared/stores'; @@ -14,8 +14,10 @@ const App: React.FC = () => { return (
- - + + + +
); }; diff --git a/tauri-ui/src/app/providers/index.ts b/tauri-ui/src/app/providers/index.ts index f1e94c9e..67270dbc 100644 --- a/tauri-ui/src/app/providers/index.ts +++ b/tauri-ui/src/app/providers/index.ts @@ -1,2 +1,3 @@ export * from './use-theme-provider'; export * from './router-provider'; +export * from './proxy-event-provider'; diff --git a/tauri-ui/src/app/providers/proxy-event-provider.tsx b/tauri-ui/src/app/providers/proxy-event-provider.tsx new file mode 100644 index 00000000..832bd2b2 --- /dev/null +++ b/tauri-ui/src/app/providers/proxy-event-provider.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; + +import { listen } from '@tauri-apps/api/event'; + +import type { ProxyEventTuple, HttpTransaction } from '@/entities/proxy'; +import { useTransactionStore } from '@/shared/stores'; + +/** + * 전역 Proxy 이벤트 리스너 Provider + * 앱 전체에서 proxy_event를 수신하여 transaction store에 저장합니다. + * 탭 이동과 무관하게 이벤트를 계속 수신할 수 있습니다. + */ +export const ProxyEventProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { addTransaction, isPaused } = useTransactionStore(); + + useEffect(() => { + const unlisten = listen('proxy_event', (event) => { + // isPaused 상태와 관계없이 이벤트는 수신하되, store에 추가할지만 결정 + if (isPaused) return; + + const [request, response] = event.payload; + const transaction: HttpTransaction = { request, response }; + + // zustand store에 transaction 추가 + addTransaction(transaction); + }); + + return () => { + unlisten.then((f) => f()); + }; + }, [addTransaction, isPaused]); + + return <>{children}; +}; diff --git a/tauri-ui/src/pages/network-dashboard/hooks/index.ts b/tauri-ui/src/pages/network-dashboard/hooks/index.ts index 98a7a200..d8ac55e2 100644 --- a/tauri-ui/src/pages/network-dashboard/hooks/index.ts +++ b/tauri-ui/src/pages/network-dashboard/hooks/index.ts @@ -1,3 +1,2 @@ export * from './use-transaction-filters'; -export * from './use-proxy-event-control'; export * from './use-transactions'; diff --git a/tauri-ui/src/pages/network-dashboard/hooks/use-proxy-event-control.ts b/tauri-ui/src/pages/network-dashboard/hooks/use-proxy-event-control.ts deleted file mode 100644 index 12ed0b8e..00000000 --- a/tauri-ui/src/pages/network-dashboard/hooks/use-proxy-event-control.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; - -import { listen } from '@tauri-apps/api/event'; - -import type { ProxyEventTuple, HttpTransaction } from '@/entities/proxy'; - -interface UseProxyEventControlProps { - onTransactionReceived: (transaction: HttpTransaction) => void; - initialPaused?: boolean; -} - -export const useProxyEventControl = ({ onTransactionReceived, initialPaused = false }: UseProxyEventControlProps) => { - const [paused, setPaused] = useState(initialPaused); - - const togglePause = useCallback(() => setPaused((prev) => !prev), []); - const pause = useCallback(() => setPaused(true), []); - const resume = useCallback(() => setPaused(false), []); - - useEffect(() => { - if (paused) return; - - const unlisten = listen('proxy_event', (event) => { - const [request, response] = event.payload; - onTransactionReceived({ request, response }); - }); - - return () => { - unlisten.then((f) => f()); - }; - }, [paused]); - - return { paused, togglePause, pause, resume }; -}; diff --git a/tauri-ui/src/pages/network-dashboard/hooks/use-transactions.ts b/tauri-ui/src/pages/network-dashboard/hooks/use-transactions.ts index 97c2c5f1..17acc2b1 100644 --- a/tauri-ui/src/pages/network-dashboard/hooks/use-transactions.ts +++ b/tauri-ui/src/pages/network-dashboard/hooks/use-transactions.ts @@ -1,48 +1,70 @@ -import { useState, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import type { HttpTransaction } from '@/entities/proxy'; +import { useTransactionStore } from '@/shared/stores'; -export const useTransactions = () => { - const [transactions, setTransactions] = useState([]); - const [selectedTransaction, setSelectedTransaction] = useState(null); +interface UseTransactionsProps { + initialPaused?: boolean; +} - const addTransaction = useCallback((transaction: HttpTransaction) => { - setTransactions(prev => { - const existingTransaction = prev.find(t => t.request?.time === transaction.request?.time); - - if (existingTransaction) { - return prev; - } - - return [...prev, transaction]; - }); - }, []); - - const clearTransactions = useCallback(() => { - setTransactions([]); - setSelectedTransaction(null) - }, []); - - const deleteTransaction = useCallback((id: number) => { - setTransactions(prev => prev.filter((transaction) => transaction?.request?.time !== id)); - }, []); +/** + * Transaction 관련 모든 기능을 관리하는 통합 Hook + * - Transaction 데이터 관리 + * - Transaction 선택 관리 + * - Proxy 이벤트 제어 (pause/resume) + */ +export const useTransactions = ({ initialPaused = false }: UseTransactionsProps = {}) => { + const { + transactions, + selectedTransaction, + isPaused, + addTransaction, + clearTransactions, + deleteTransaction, + setSelectedTransaction, + setPaused, + togglePause, + } = useTransactionStore(); + // 초기 paused 상태 설정 + useEffect(() => { + if (initialPaused !== undefined) { + setPaused(initialPaused); + } + }, [initialPaused, setPaused]); - const createTransactionSelectHandler = useCallback((transaction: HttpTransaction) => () => { - setSelectedTransaction(transaction); - }, []); + const createTransactionSelectHandler = useCallback( + (transaction: HttpTransaction) => () => { + setSelectedTransaction(transaction); + }, + [setSelectedTransaction], + ); const clearSelectedTransaction = useCallback(() => { - setSelectedTransaction(null) - }, []) + setSelectedTransaction(null); + }, [setSelectedTransaction]); + + const pause = useCallback(() => setPaused(true), [setPaused]); + const resume = useCallback(() => setPaused(false), [setPaused]); return { + // Transaction 데이터 transactions, + selectedTransaction, addTransaction, clearTransactions, deleteTransaction, - selectedTransaction, + + // Transaction 선택 관리 createTransactionSelectHandler, - clearSelectedTransaction + clearSelectedTransaction, + + // Proxy 이벤트 제어 + isPaused, + paused: isPaused, // 하위 호환성을 위해 별칭 제공 + setPaused, + togglePause, + pause, + resume, }; }; diff --git a/tauri-ui/src/pages/network-dashboard/ui/network-dashboard.tsx b/tauri-ui/src/pages/network-dashboard/ui/network-dashboard.tsx index b8898f10..564992cf 100644 --- a/tauri-ui/src/pages/network-dashboard/ui/network-dashboard.tsx +++ b/tauri-ui/src/pages/network-dashboard/ui/network-dashboard.tsx @@ -8,7 +8,7 @@ import { NetworkTable } from '@/widgets/network-table'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/shared/ui'; -import { useProxyEventControl, useTransactionFilters, useTransactions } from '../hooks'; +import { useTransactionFilters, useTransactions } from '../hooks'; import { useProxyStore } from '@/shared/stores'; import { HostPathTree } from '@/widgets/host-path-tree/ui/host-path-tree'; @@ -17,16 +17,15 @@ export const NetworkDashboard = () => { const { transactions, - addTransaction, clearTransactions, deleteTransaction, selectedTransaction, createTransactionSelectHandler, clearSelectedTransaction, + paused, + togglePause, } = useTransactions(); - const { paused, togglePause } = useProxyEventControl({ onTransactionReceived: addTransaction }); - const { searchQuery, setMethodFilter, @@ -38,14 +37,14 @@ export const NetworkDashboard = () => { } = useTransactionFilters({ transactions }); const createTransactionDeleteHandler = useCallback( - (id: number) => () => { + (id: string) => () => { deleteTransaction(id); - if (selectedTransaction?.request?.time === id) { + if (selectedTransaction?.request?.id === id) { clearSelectedTransaction(); } }, - [], + [deleteTransaction, selectedTransaction, clearSelectedTransaction], ); return ( diff --git a/tauri-ui/src/pages/sessions/context/session-form-context.tsx b/tauri-ui/src/pages/sessions/context/session-form-context.tsx new file mode 100644 index 00000000..6617ed55 --- /dev/null +++ b/tauri-ui/src/pages/sessions/context/session-form-context.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; +import { useForm } from '@tanstack/react-form'; +import { zodValidator } from '@tanstack/zod-form-adapter'; + +import type { SessionEditFormData } from '../lib/session-edit-schema'; + +export interface SessionFormInstance { + getFieldValue: (name: string) => any; + setFieldValue: (name: string, value: any) => void; + validate: () => Promise; + reset: () => void; + getValues: () => SessionEditFormData; +} + +const SessionFormContext = createContext(null); + +export const useSessionForm = () => { + const context = useContext(SessionFormContext); + if (!context) { + throw new Error('useSessionForm must be used within a SessionFormProvider'); + } + return context; +}; + +export { SessionFormContext }; diff --git a/tauri-ui/src/pages/sessions/hooks/index.ts b/tauri-ui/src/pages/sessions/hooks/index.ts new file mode 100644 index 00000000..12e6f5d3 --- /dev/null +++ b/tauri-ui/src/pages/sessions/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-session-edit'; diff --git a/tauri-ui/src/pages/sessions/hooks/use-session-edit.ts b/tauri-ui/src/pages/sessions/hooks/use-session-edit.ts new file mode 100644 index 00000000..0f79fef1 --- /dev/null +++ b/tauri-ui/src/pages/sessions/hooks/use-session-edit.ts @@ -0,0 +1,122 @@ +import { useState, useCallback, useRef } from 'react'; +import { useForm } from '@tanstack/react-form'; + +import type { SessionStore } from '@/entities/session'; +import { useSessionStore } from '@/shared/stores'; +import type { SessionEditFormData } from '../lib/session-edit-schema'; + +interface UseSessionEditProps { + session: SessionStore; +} + +// 객체 비교를 위한 헬퍼 함수 +const isEqual = (a: any, b: any): boolean => { + // undefined와 null을 같은 것으로 처리 + if (a === undefined || a === null) a = undefined; + if (b === undefined || b === null) b = undefined; + + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + + if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + return JSON.stringify(a) === JSON.stringify(b); + } + return a === b; +}; + +/** + * 세션 편집을 위한 Hook + * 폼 기반으로 세션 데이터를 편집하고 저장할 수 있습니다. + */ +export const useSessionEdit = ({ session }: UseSessionEditProps) => { + const { updateSession } = useSessionStore(); + const [isEditing, setIsEditing] = useState(false); + const originalDataRef = useRef(null); + + // 폼 초기값 설정 + const getInitialValues = (): SessionEditFormData => { + return { + id: session.id, + url: session.url, + method: session.method, + isActive: session.isActive, + request: session.request, + response: session.response, + }; + }; + + const form = useForm({ + defaultValues: getInitialValues(), + onSubmit: async ({ value }) => { + console.log('Form submitted with value:', value); + // 현재 폼 데이터와 원본 데이터를 비교해서 변경된 필드만 추출 + const originalData = originalDataRef.current; + console.log('Original data:', originalData); + if (!originalData) return; + + // 변경된 필드만 추출 + const changedFields: Partial = {}; + + if (!isEqual(value.request, originalData.request)) { + changedFields.request = value.request; + } + + if (!isEqual(value.response, originalData.response)) { + changedFields.response = value.response; + } + + if (!isEqual(value.url, originalData.url)) { + changedFields.url = value.url; + } + + if (!isEqual(value.method, originalData.method)) { + changedFields.method = value.method; + } + + if (!isEqual(value.isActive, originalData.isActive)) { + changedFields.isActive = value.isActive; + } + + // 변경된 필드가 있는 경우에만 저장 + if (Object.keys(changedFields).length > 0) { + const updatedSession = { ...session, ...changedFields }; + console.log('Updating session:', updatedSession); + console.log('Changed fields:', changedFields); + updateSession(updatedSession as any); + setIsEditing(false); + } else { + console.log('No changes detected'); + setIsEditing(false); + } + }, + }); + + const startEditing = useCallback(() => { + const initialValues = getInitialValues(); + originalDataRef.current = initialValues; + form.setFieldValue('id', initialValues.id); + form.setFieldValue('url', initialValues.url); + form.setFieldValue('method', initialValues.method); + form.setFieldValue('isActive', initialValues.isActive); + form.setFieldValue('request', initialValues.request); + form.setFieldValue('response', initialValues.response); + setIsEditing(true); + }, [form]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + originalDataRef.current = null; + }, []); + + const saveChanges = useCallback(() => { + form.handleSubmit(); + }, [form]); + + return { + isEditing, + form, + startEditing, + cancelEditing, + saveChanges, + }; +}; diff --git a/tauri-ui/src/pages/sessions/lib/session-edit-schema.ts b/tauri-ui/src/pages/sessions/lib/session-edit-schema.ts new file mode 100644 index 00000000..45f8f46d --- /dev/null +++ b/tauri-ui/src/pages/sessions/lib/session-edit-schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +// 세션 편집을 위한 스키마 +export const sessionEditSchema = z.object({ + id: z.string().min(1, 'ID is required'), + url: z.string().url('Valid URL is required'), + method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE', 'OTHERS']), + isActive: z.boolean(), + request: z + .object({ + headers: z.record(z.string(), z.string()).optional(), + data: z.union([z.record(z.string(), z.any()), z.string()]).optional(), + params: z.union([z.record(z.string(), z.any()), z.string()]).optional(), + }) + .optional(), + response: z + .object({ + status: z.number().min(100).max(599), + headers: z.record(z.string(), z.string()).optional(), + data: z.union([z.record(z.string(), z.any()), z.string()]).optional(), + }) + .optional(), +}); + +export type SessionEditFormData = z.infer; diff --git a/tauri-ui/src/pages/sessions/ui/session-editor.tsx b/tauri-ui/src/pages/sessions/ui/session-editor.tsx new file mode 100644 index 00000000..7dd9adee --- /dev/null +++ b/tauri-ui/src/pages/sessions/ui/session-editor.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect } from 'react'; +import Editor from '@monaco-editor/react'; + +import type { SessionStore } from '@/entities/session'; +import { Button, Input, Badge, Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui'; +import { useSessionEdit } from '../hooks/use-session-edit'; +import { formatValueToJsonString, handleEditorChange } from '../utils'; +import { toast } from 'sonner'; +import { Save, X } from 'lucide-react'; + +interface SessionEditorProps { + session: SessionStore; + isEditing: boolean; + onSave: () => void; + onCancel: () => void; +} + +/** + * 세션 편집을 위한 폼 기반 편집 컴포넌트 + */ +export const SessionEditor = ({ session, isEditing, onSave, onCancel }: SessionEditorProps) => { + const { form, saveChanges, startEditing } = useSessionEdit({ session }); + const [isSaving, setIsSaving] = useState(false); + + // 편집 모드가 활성화될 때 startEditing 호출 + useEffect(() => { + if (isEditing) { + startEditing(); + } + }, [isEditing, startEditing]); + + const handleSave = async () => { + try { + setIsSaving(true); + await saveChanges(); + toast.success('Session updated successfully'); + onSave(); + } catch (error) { + toast.error('Failed to save session'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+

{isEditing ? 'Edit Session' : 'Session Details'}

+
+ {isEditing && ( +
+ + +
+ )} +
+ +
+
+ {/* Basic Info */} +
+
+ + + {(field) => ( +
+ {field.state.value} +
+ )} +
+
+ +
+ + + {(field) => ( +
+ + {field.state.value} + +
+ )} +
+
+ +
+ + + {(field) => ( +
+ isEditing && field.handleChange(!field.state.value)} + > + {field.state.value ? 'Active' : 'Inactive'} + +
+ )} +
+
+
+ + {/* Response Status */} +
+
+ + + {(field) => ( + field.handleChange(Number.parseInt(e.target.value) || 200)} + placeholder="200" + disabled={!isEditing} + className={`mt-1 ${isEditing ? '' : 'bg-muted/50 cursor-not-allowed'}`} + /> + )} + +
+
+
+ + {/* Request/Response Tabs */} + + + Request Headers + Request Data + Response Headers + Response Data + + + +
+ + + {(field) => ( +
+ handleEditorChange(value, isEditing, field.handleChange)} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + lineNumbers: 'on', + roundedSelection: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + }, + automaticLayout: true, + formatOnPaste: isEditing, + formatOnType: isEditing, + readOnly: !isEditing, + }} + theme="vs" + /> +
+ )} +
+
+
+ + +
+ + + {(field) => ( +
+ handleEditorChange(value, isEditing, field.handleChange)} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + lineNumbers: 'on', + roundedSelection: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + }, + automaticLayout: true, + formatOnPaste: isEditing, + formatOnType: isEditing, + readOnly: !isEditing, + }} + theme="vs" + /> +
+ )} +
+
+
+ + +
+ + + {(field) => ( +
+ handleEditorChange(value, isEditing, field.handleChange)} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + lineNumbers: 'on', + roundedSelection: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + }, + automaticLayout: true, + formatOnPaste: isEditing, + formatOnType: isEditing, + readOnly: !isEditing, + }} + theme="vs" + /> +
+ )} +
+
+
+ + +
+ + + {(field) => ( +
+ handleEditorChange(value, isEditing, field.handleChange)} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + lineNumbers: 'on', + roundedSelection: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + }, + automaticLayout: true, + formatOnPaste: isEditing, + formatOnType: isEditing, + readOnly: !isEditing, + }} + theme="vs" + /> +
+ )} +
+
+
+
+
+
+ ); +}; diff --git a/tauri-ui/src/pages/sessions/ui/sessions-page.tsx b/tauri-ui/src/pages/sessions/ui/sessions-page.tsx index b6f44efb..7b79a84a 100644 --- a/tauri-ui/src/pages/sessions/ui/sessions-page.tsx +++ b/tauri-ui/src/pages/sessions/ui/sessions-page.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react'; + import { useSessionStore, useProxyStore } from '@/shared/stores'; -import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui'; +import { Card, CardContent, CardHeader } from '@/shared/ui'; import { Badge } from '@/shared/ui'; -import { Trash2, Copy, ExternalLink } from 'lucide-react'; +import { Trash2, Edit } from 'lucide-react'; import { Button } from '@/shared/ui'; import { toast } from 'sonner'; import { AppSidebar } from '@/shared/app-sidebar'; +import { SessionEditor } from './session-editor'; /** * 세션 데이터를 표시하는 페이지 @@ -14,20 +17,23 @@ import { AppSidebar } from '@/shared/app-sidebar'; export const SessionsPage = () => { const { isConnected } = useProxyStore(); const { sessions, deleteSession } = useSessionStore(); + const [editingSessionId, setEditingSessionId] = useState(null); const handleDeleteSession = (id: string) => { deleteSession(id); toast.success('Session deleted successfully'); }; - const handleCopySession = (session: any) => { - const sessionText = JSON.stringify(session, null, 2); - navigator.clipboard.writeText(sessionText); - toast.success('Session data copied to clipboard'); + const handleEditSession = (sessionId: string) => { + setEditingSessionId(sessionId); + }; + + const handleSaveSession = () => { + setEditingSessionId(null); }; - const handleOpenUrl = (url: string) => { - window.open(url, '_blank'); + const handleCancelEdit = () => { + setEditingSessionId(null); }; return ( @@ -35,17 +41,17 @@ export const SessionsPage = () => {
-
-
-
-

Saved Sessions

-

Manage and view your saved HTTP sessions

-
- + {/* Header similar to NetworkHeader */} +
+
+

Saved Sessions

+ {sessions.length} sessions
+
+
{sessions.length === 0 ? ( @@ -62,30 +68,27 @@ export const SessionsPage = () => {
- {session.url} +
+ + {session.method} + + + {session.url} + +
{session.isActive ? 'Active' : 'Inactive'} - - {session.method} -
-
-
- {/* Request Section */} -
-

Request

-
- {session.request?.headers && ( -
- Headers: -
-                                {JSON.stringify(session.request.headers, null, 2)}
-                              
-
- )} - {session.request?.data && ( -
- Data: -
-                                {JSON.stringify(session.request.data, null, 2)}
-                              
-
- )} - {session.request?.params && ( -
- Params: -
-                                {JSON.stringify(session.request.params, null, 2)}
-                              
-
- )} -
-
- - {/* Response Section */} -
-

Response

-
- {session.response?.status && ( -
- Status: - - {session.response.status} - -
- )} - {session.response?.headers && ( -
- Headers: -
-                                {JSON.stringify(session.response.headers, null, 2)}
-                              
-
- )} - {session.response?.data && ( -
- Data: -
-                                {JSON.stringify(session.response.data, null, 2)}
-                              
-
- )} -
-
-
+
))} diff --git a/tauri-ui/src/pages/sessions/utils/index.ts b/tauri-ui/src/pages/sessions/utils/index.ts new file mode 100644 index 00000000..ddb6a86b --- /dev/null +++ b/tauri-ui/src/pages/sessions/utils/index.ts @@ -0,0 +1 @@ +export { formatValueToJsonString, handleEditorChange } from './json-editor-utils'; diff --git a/tauri-ui/src/pages/sessions/utils/json-editor-utils.ts b/tauri-ui/src/pages/sessions/utils/json-editor-utils.ts new file mode 100644 index 00000000..7a637060 --- /dev/null +++ b/tauri-ui/src/pages/sessions/utils/json-editor-utils.ts @@ -0,0 +1,58 @@ +/** + * JSON 에디터를 위한 유틸리티 함수들 + */ + +/** + * 값을 JSON 문자열로 포맷팅합니다 + * @param value - 포맷팅할 값 + * @returns 포맷팅된 JSON 문자열 + */ +export const formatValueToJsonString = (value: unknown): string => { + if (!value) return ''; + + // 이미 문자열인 경우 파싱 시도 + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return JSON.stringify(parsed, null, 2); + } catch { + return value; + } + } + + // 객체인 경우 그대로 문자열화 + return JSON.stringify(value, null, 2); +}; + +/** + * 에디터 값 변경을 처리합니다 + * @param value - 에디터에서 입력된 값 + * @param isEditing - 편집 모드 여부 + * @param handleChange - 값 변경 핸들러 + */ +export const handleEditorChange = ( + value: string | undefined, + isEditing: boolean, + handleChange: (value: T) => void, +): void => { + if (!isEditing || value === undefined) return; + + // 빈 텍스트인 경우 undefined로 저장 + if (value.trim() === '') { + handleChange(undefined as T); + return; + } + + try { + const parsed = JSON.parse(value); + // 빈 객체인 경우 undefined로 저장 + if (parsed && typeof parsed === 'object' && Object.keys(parsed).length === 0) { + handleChange(undefined as T); + } else { + handleChange(parsed as T); + } + } catch { + // Invalid JSON, save as string + handleChange(value as T); + } +}; diff --git a/tauri-ui/src/shared/stores/index.ts b/tauri-ui/src/shared/stores/index.ts index 42c965b9..33b41e2d 100644 --- a/tauri-ui/src/shared/stores/index.ts +++ b/tauri-ui/src/shared/stores/index.ts @@ -1,2 +1,3 @@ export * from './session-store'; export * from './proxy-store'; +export * from './transaction-store'; diff --git a/tauri-ui/src/shared/stores/transaction-store.ts b/tauri-ui/src/shared/stores/transaction-store.ts new file mode 100644 index 00000000..83367f87 --- /dev/null +++ b/tauri-ui/src/shared/stores/transaction-store.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import type { HttpTransaction } from '@/entities/proxy'; + +interface TransactionState { + transactions: HttpTransaction[]; + selectedTransaction: HttpTransaction | null; + isPaused: boolean; + addTransaction: (transaction: HttpTransaction) => void; + setSelectedTransaction: (transaction: HttpTransaction | null) => void; + clearTransactions: () => void; + deleteTransaction: (id: string) => void; + setPaused: (paused: boolean) => void; + togglePause: () => void; +} + +export const useTransactionStore = create()( + persist( + (set, get) => ({ + transactions: [], + selectedTransaction: null, + isPaused: false, + + addTransaction: (transaction: HttpTransaction) => { + const { isPaused, transactions } = get(); + if (isPaused) return; + + // 중복 transaction 체크 (id 기준) + const existingTransaction = transactions.find((t) => t.request?.id === transaction.request?.id); + if (existingTransaction) return; + + set((state) => ({ + transactions: [transaction, ...state.transactions], + })); + }, + + setSelectedTransaction: (transaction: HttpTransaction | null) => { + set({ selectedTransaction: transaction }); + }, + + clearTransactions: () => { + set({ transactions: [], selectedTransaction: null }); + }, + + deleteTransaction: (id: string) => { + set((state) => { + const filteredTransactions = state.transactions.filter((transaction) => transaction?.request?.id !== id); + + // 삭제된 transaction이 현재 선택된 transaction이면 선택 해제 + const newSelectedTransaction = + state.selectedTransaction?.request?.id === id ? null : state.selectedTransaction; + + return { + transactions: filteredTransactions, + selectedTransaction: newSelectedTransaction, + }; + }); + }, + + setPaused: (paused: boolean) => { + set({ isPaused: paused }); + }, + + togglePause: () => { + set((state) => ({ isPaused: !state.isPaused })); + }, + }), + { + name: 'cheolsu-transaction-store', + // transactions는 persist하지 않음 (앱 재시작 시 초기화되어야 함) + // currentTransaction만 persist + partialize: (state) => ({ + isPaused: state.isPaused, + }), + }, + ), +); diff --git a/tauri-ui/src/shared/ui/index.ts b/tauri-ui/src/shared/ui/index.ts index 2aba708b..78c29240 100644 --- a/tauri-ui/src/shared/ui/index.ts +++ b/tauri-ui/src/shared/ui/index.ts @@ -13,3 +13,4 @@ export * from './resizable'; export * from './scroll-area'; export * from './sonner'; export * from './virtualized-scroll-area'; +export * from './dialog'; diff --git a/tauri-ui/src/widgets/network-table/ui/network-table.tsx b/tauri-ui/src/widgets/network-table/ui/network-table.tsx index f3dceffe..51768674 100644 --- a/tauri-ui/src/widgets/network-table/ui/network-table.tsx +++ b/tauri-ui/src/widgets/network-table/ui/network-table.tsx @@ -8,7 +8,7 @@ interface NetworkTableProps { transactions: HttpTransaction[]; selectedTransaction: HttpTransaction | null; createTransactionSelectHandler: (transaction: HttpTransaction) => () => void; - createTransactionDeleteHandler: (id: number) => () => void; + createTransactionDeleteHandler: (id: string) => () => void; } export const NetworkTable = ({ diff --git a/tauri-ui/src/widgets/network-table/ui/table-body.tsx b/tauri-ui/src/widgets/network-table/ui/table-body.tsx index f5e49666..9f1d1c84 100644 --- a/tauri-ui/src/widgets/network-table/ui/table-body.tsx +++ b/tauri-ui/src/widgets/network-table/ui/table-body.tsx @@ -10,13 +10,13 @@ import { TableRow } from './table-row'; interface TableBodyProps { data: TableRowData[]; createTransactionSelectHandler: (request: HttpTransaction) => () => void; - createTransactionDeleteHandler: (id: number) => () => void; + createTransactionDeleteHandler: (id: string) => () => void; } export const TableBody = ({ data, createTransactionSelectHandler, createTransactionDeleteHandler }: TableBodyProps) => { const rowHandlers = useMemo(() => { return data.map((rowData, index) => { - const id = rowData.transaction.request?.time ?? index; + const id = rowData.transaction.request?.id ?? index.toString(); return { onSelect: createTransactionSelectHandler(rowData.transaction), onDelete: createTransactionDeleteHandler(id),