11import { memo , useCallback , useMemo , useState } from "react" ;
22import { invoke } from "@tauri-apps/api/core" ;
3+ import { save } from "@tauri-apps/plugin-dialog" ;
34import ReactMarkdown from "react-markdown" ;
45import remarkGfm from "remark-gfm" ;
56import remarkMath from "remark-math" ;
@@ -8,6 +9,8 @@ import "katex/dist/katex.min.css";
89import type { AiAnalysis , SourceRef } from "../../types/search" ;
910import { FileIcon } from "../ui/FileIcon" ;
1011import { ResultContextMenu , useContextMenu } from "./ResultContextMenu" ;
12+ import { useUIContext } from "../../contexts/UIContext" ;
13+ import { buildAiAnswerMarkdown , toDefaultSavePath , toSafeFileStem } from "../../utils/aiAnswerMarkdown" ;
1114
1215interface Props {
1316 answer : string ;
@@ -16,6 +19,8 @@ interface Props {
1619 error : string | null ;
1720 onReset : ( ) => void ;
1821 currentQuestion ?: string ;
22+ /** 질의에 사용된 검색 범위 폴더 — Markdown 저장 시 기본 폴더로 쓴다 */
23+ searchScope ?: string | null ;
1924 onExampleClick ?: ( text : string ) => void ;
2025 /** AI 답변 [출처N] 또는 참조 문서 클릭 → 미리보기 인용 점프 */
2126 onCite ?: ( source : SourceRef ) => void ;
@@ -65,7 +70,8 @@ function linkifyCitations(text: string): string {
6570 return text . replace ( / \[ 출 처 \s * : ? \s * ( \d + ) \s * \] / g, ( _m , n ) => `[출처${ n } ](${ CITE_SCHEME } ${ n } )` ) ;
6671}
6772
68- function AiAnswerPanel ( { answer, isStreaming, analysis, error, onReset, currentQuestion, onCite } : Props ) {
73+
74+ function AiAnswerPanel ( { answer, isStreaming, analysis, error, onReset, currentQuestion, searchScope, onCite } : Props ) {
6975 const handleOpenFile = useCallback ( ( path : string ) => {
7076 invoke ( "open_file" , { path } ) . catch ( ( ) => { } ) ;
7177 } , [ ] ) ;
@@ -305,8 +311,13 @@ function AiAnswerPanel({ answer, isStreaming, analysis, error, onReset, currentQ
305311
306312 { /* 하단 액션 바 */ }
307313 { ( analysis || error ) && (
308- < CopyableActionBar answer = { answer } analysis = { analysis } onReset = { onReset } />
309-
314+ < CopyableActionBar
315+ answer = { answer }
316+ analysis = { analysis }
317+ onReset = { onReset }
318+ currentQuestion = { currentQuestion }
319+ searchScope = { searchScope }
320+ />
310321 ) }
311322 </ div >
312323 ) ;
@@ -393,9 +404,55 @@ function SourceFileItem({
393404 ) ;
394405}
395406
396- /** 하단 액션 바 — 새 질문 + 복사 버튼 */
397- function CopyableActionBar ( { answer, analysis, onReset } : { answer : string ; analysis : AiAnalysis | null ; onReset : ( ) => void } ) {
407+ /** 하단 액션 바 — 새 질문 + 복사 + Markdown 저장 버튼 */
408+ function CopyableActionBar ( {
409+ answer,
410+ analysis,
411+ onReset,
412+ currentQuestion,
413+ searchScope,
414+ } : {
415+ answer : string ;
416+ analysis : AiAnalysis | null ;
417+ onReset : ( ) => void ;
418+ currentQuestion ?: string ;
419+ searchScope ?: string | null ;
420+ } ) {
398421 const [ copied , setCopied ] = useState ( false ) ;
422+ const [ isSaving , setIsSaving ] = useState ( false ) ;
423+ const { showToast, updateToast } = useUIContext ( ) ;
424+
425+ // 답변 + 참조 문서를 .md 파일로 저장 (미리보기 패널과 같은 export_markdown 커맨드 사용)
426+ const handleSaveMarkdown = useCallback ( async ( ) => {
427+ const timestamp = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
428+ let outputPath : string | null = null ;
429+ try {
430+ outputPath = await save ( {
431+ defaultPath : toDefaultSavePath (
432+ searchScope ,
433+ `${ toSafeFileStem ( currentQuestion ) } _${ timestamp } .md` ,
434+ ) ,
435+ filters : [ { name : "Markdown" , extensions : [ "md" ] } ] ,
436+ } ) ;
437+ } catch {
438+ showToast ( "파일 저장 창 열기 실패" , "error" ) ;
439+ return ;
440+ }
441+ if ( ! outputPath ) return ; // 사용자 취소
442+
443+ setIsSaving ( true ) ;
444+ const toastId = showToast ( "Markdown 저장 중..." , "loading" ) ;
445+ try {
446+ const content = buildAiAnswerMarkdown ( currentQuestion , answer , analysis ) ;
447+ await invoke ( "export_markdown" , { content, outputPath } ) ;
448+ updateToast ( toastId , { message : "Markdown 파일로 저장했습니다" , type : "success" } ) ;
449+ } catch ( e ) {
450+ const msg = typeof e === "string" ? e : ( ( e as { message ?: string } ) ?. message ?? "저장 실패" ) ;
451+ updateToast ( toastId , { message : `저장 실패: ${ msg } ` , type : "error" } ) ;
452+ } finally {
453+ setIsSaving ( false ) ;
454+ }
455+ } , [ answer , analysis , currentQuestion , searchScope , showToast , updateToast ] ) ;
399456
400457 const handleCopy = useCallback ( async ( ) => {
401458 try {
@@ -451,6 +508,23 @@ function CopyableActionBar({ answer, analysis, onReset }: { answer: string; anal
451508 { copied ? "복사됨" : "복사" }
452509 </ button >
453510 ) }
511+ { answer && (
512+ < button
513+ onClick = { handleSaveMarkdown }
514+ disabled = { isSaving }
515+ className = "flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium rounded-md transition-colors hover:bg-[var(--color-bg-tertiary)] disabled:opacity-50"
516+ style = { { color : "var(--color-text-muted)" } }
517+ aria-label = "AI 답변을 Markdown 파일로 저장"
518+ title = "질문·답변·참조 문서를 .md 파일로 저장"
519+ >
520+ < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
521+ < path d = "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
522+ < polyline points = "17 21 17 13 7 13 7 21" />
523+ < polyline points = "7 3 7 8 15 8" />
524+ </ svg >
525+ { isSaving ? "저장 중..." : "MD 저장" }
526+ </ button >
527+ ) }
454528 </ div >
455529 { analysis && (
456530 < span className = "text-[10px] text-[var(--color-text-tertiary)] tabular-nums" >
0 commit comments