diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 750edde..e4ccf12 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4675,5 +4675,22 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~43 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 11차) +## Phase 44 — LauncherViewModel·SettingsWindow.Tools·MarkdownRenderer 파셜 분할 (v2.3) ✅ 완료 + +> **목표**: LauncherViewModel(805줄)·SettingsWindow.Tools(875줄)·MarkdownRenderer(825줄) 분할. + +| 원본 파일 | 원본 | 메인 | 신규 파일 | 신규 줄 수 | +|----------|------|------|----------|----------| +| LauncherViewModel.cs | 805 | 402 | FileAction.cs | 154 | +| LauncherViewModel.cs | — | — | Commands.cs | 273 | +| SettingsWindow.Tools.cs | 875 | 605 | ToolCards.cs | 283 | +| MarkdownRenderer.cs | 825 | 621 | Highlighting.cs | 215 | + +- **LauncherViewModel 메인**: 805줄 → 402줄 (50.1% 감소) +- **총 신규 파일**: 4개 +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~44 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 12차) diff --git a/src/AxCopilot/Services/MarkdownRenderer.Highlighting.cs b/src/AxCopilot/Services/MarkdownRenderer.Highlighting.cs new file mode 100644 index 0000000..14c0c08 --- /dev/null +++ b/src/AxCopilot/Services/MarkdownRenderer.Highlighting.cs @@ -0,0 +1,215 @@ +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; + +namespace AxCopilot.Services; + +public static partial class MarkdownRenderer +{ + // ─── 구문 하이라이팅 ────────────────────────────────────────────────── + + private static readonly Brush KeywordBrush = new SolidColorBrush(Color.FromRgb(0xC5, 0x86, 0xC0)); // purple + private static readonly Brush TypeBrush = new SolidColorBrush(Color.FromRgb(0x4E, 0xC9, 0xB0)); // teal + private static readonly Brush StringBrush = new SolidColorBrush(Color.FromRgb(0xCE, 0x91, 0x78)); // orange + private static readonly Brush CommentBrush = new SolidColorBrush(Color.FromRgb(0x6A, 0x99, 0x55)); // green + private static readonly Brush NumberBrush = new SolidColorBrush(Color.FromRgb(0xB5, 0xCE, 0xA8)); // light green + private static readonly Brush MethodBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0xDC, 0xAA)); // yellow + private static readonly Brush DirectiveBrush = new SolidColorBrush(Color.FromRgb(0x9C, 0xDC, 0xFE)); // light blue + + private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) + { + "abstract","as","async","await","base","bool","break","byte","case","catch","char","checked", + "class","const","continue","decimal","default","delegate","do","double","else","enum","event", + "explicit","extern","false","finally","fixed","float","for","foreach","goto","if","implicit", + "in","int","interface","internal","is","lock","long","namespace","new","null","object","operator", + "out","override","params","private","protected","public","readonly","ref","return","sbyte","sealed", + "short","sizeof","stackalloc","static","string","struct","switch","this","throw","true","try", + "typeof","uint","ulong","unchecked","unsafe","ushort","using","var","virtual","void","volatile", + "while","yield","record","init","required","get","set","value","where","when","and","or","not", + }; + + private static readonly HashSet PythonKeywords = new(StringComparer.Ordinal) + { + "False","None","True","and","as","assert","async","await","break","class","continue","def", + "del","elif","else","except","finally","for","from","global","if","import","in","is","lambda", + "nonlocal","not","or","pass","raise","return","try","while","with","yield", + }; + + private static readonly HashSet JsKeywords = new(StringComparer.Ordinal) + { + "abstract","arguments","async","await","boolean","break","byte","case","catch","char","class", + "const","continue","debugger","default","delete","do","double","else","enum","eval","export", + "extends","false","final","finally","float","for","from","function","goto","if","implements", + "import","in","instanceof","int","interface","let","long","native","new","null","of","package", + "private","protected","public","return","short","static","super","switch","synchronized","this", + "throw","throws","transient","true","try","typeof","undefined","var","void","volatile","while", + "with","yield", + }; + + private static readonly HashSet JavaKeywords = new(StringComparer.Ordinal) + { + "abstract","assert","boolean","break","byte","case","catch","char","class","const","continue", + "default","do","double","else","enum","extends","false","final","finally","float","for","goto", + "if","implements","import","instanceof","int","interface","long","native","new","null","package", + "private","protected","public","return","short","static","strictfp","super","switch","synchronized", + "this","throw","throws","transient","true","try","void","volatile","while","var","record","sealed", + "permits","yield", + }; + + private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "SELECT","FROM","WHERE","INSERT","INTO","UPDATE","SET","DELETE","CREATE","DROP","ALTER","TABLE", + "INDEX","VIEW","JOIN","INNER","LEFT","RIGHT","OUTER","FULL","CROSS","ON","AND","OR","NOT","IN", + "IS","NULL","LIKE","BETWEEN","EXISTS","HAVING","GROUP","BY","ORDER","ASC","DESC","LIMIT","OFFSET", + "UNION","ALL","DISTINCT","AS","CASE","WHEN","THEN","ELSE","END","VALUES","PRIMARY","KEY","FOREIGN", + "REFERENCES","CONSTRAINT","UNIQUE","CHECK","DEFAULT","COUNT","SUM","AVG","MIN","MAX","CAST", + "COALESCE","IF","BEGIN","COMMIT","ROLLBACK","GRANT","REVOKE","TRUNCATE","WITH","RECURSIVE", + }; + + private static HashSet GetKeywordsForLang(string lang) => lang.ToLowerInvariant() switch + { + "csharp" or "cs" or "c#" => CSharpKeywords, + "python" or "py" => PythonKeywords, + "javascript" or "js" or "jsx" => JsKeywords, + "typescript" or "ts" or "tsx" => JsKeywords, + "java" or "kotlin" or "kt" => JavaKeywords, + "sql" or "mysql" or "postgresql" or "sqlite" => SqlKeywords, + "go" or "golang" => new(StringComparer.Ordinal) + { + "break","case","chan","const","continue","default","defer","else","fallthrough","for","func", + "go","goto","if","import","interface","map","package","range","return","select","struct", + "switch","type","var","nil","true","false","append","len","cap","make","new","panic","recover", + }, + "rust" or "rs" => new(StringComparer.Ordinal) + { + "as","async","await","break","const","continue","crate","dyn","else","enum","extern","false", + "fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return", + "self","Self","static","struct","super","trait","true","type","unsafe","use","where","while", + "yield","Box","Vec","String","Option","Result","Some","None","Ok","Err","println","macro_rules", + }, + "cpp" or "c++" or "c" or "h" or "hpp" => new(StringComparer.Ordinal) + { + "auto","break","case","char","const","continue","default","do","double","else","enum","extern", + "float","for","goto","if","inline","int","long","register","return","short","signed","sizeof", + "static","struct","switch","typedef","union","unsigned","void","volatile","while","class", + "namespace","using","public","private","protected","virtual","override","template","typename", + "nullptr","true","false","new","delete","throw","try","catch","const_cast","dynamic_cast", + "static_cast","reinterpret_cast","bool","string","include","define","ifdef","ifndef","endif", + }, + _ => CSharpKeywords, // default + }; + + private static readonly Regex SyntaxPattern = new( + @"(//[^\n]*|#[^\n]*)" + // single-line comment (// or #) + @"|(""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*')" + // strings + @"|(\b\d+\.?\d*[fFdDmMlL]?\b)" + // numbers + @"|(\b[A-Z]\w*(?=\s*[\.<\(]))" + // type-like (PascalCase before . < () + @"|(\b\w+(?=\s*\())" + // method call + @"|(\b\w+\b)", // identifier / keyword + RegexOptions.Compiled); + + private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor) + { + if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code)) + { + tb.Text = code; + return; + } + + var keywords = GetKeywordsForLang(lang); + var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell" + or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1"; + + foreach (Match m in SyntaxPattern.Matches(code)) + { + if (m.Groups[1].Success) // comment + { + var commentText = m.Groups[1].Value; + // # 주석은 해당 언어에서만 적용 + if (commentText.StartsWith('#') && !isCommentHash) + { + // # 이 주석이 아닌 언어: 일반 텍스트로 처리 + tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); + } + else + { + tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); + } + } + else if (m.Groups[2].Success) // string + { + tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); + } + else if (m.Groups[3].Success) // number + { + tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); + } + else if (m.Groups[4].Success) // type + { + var word = m.Groups[4].Value; + tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); + } + else if (m.Groups[5].Success) // method + { + var word = m.Groups[5].Value; + if (keywords.Contains(word)) + tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); + else + tb.Inlines.Add(new Run(word) { Foreground = MethodBrush }); + } + else if (m.Groups[6].Success) // identifier + { + var word = m.Groups[6].Value; + if (keywords.Contains(word)) + tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); + else + tb.Inlines.Add(new Run(word) { Foreground = defaultColor }); + } + } + + // Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움 + // Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완 + // 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근 + tb.Inlines.Clear(); + int lastEnd = 0; + foreach (Match m in SyntaxPattern.Matches(code)) + { + if (m.Index > lastEnd) + tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor }); + + if (m.Groups[1].Success) + { + var commentText = m.Groups[1].Value; + if (commentText.StartsWith('#') && !isCommentHash) + tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); + else + tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); + } + else if (m.Groups[2].Success) + tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); + else if (m.Groups[3].Success) + tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); + else if (m.Groups[4].Success) + { + var word = m.Groups[4].Value; + tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); + } + else if (m.Groups[5].Success) + { + var word = m.Groups[5].Value; + tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush }); + } + else if (m.Groups[6].Success) + { + var word = m.Groups[6].Value; + tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor }); + } + + lastEnd = m.Index + m.Length; + } + if (lastEnd < code.Length) + tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor }); + } +} diff --git a/src/AxCopilot/Services/MarkdownRenderer.cs b/src/AxCopilot/Services/MarkdownRenderer.cs index f393950..fbe687d 100644 --- a/src/AxCopilot/Services/MarkdownRenderer.cs +++ b/src/AxCopilot/Services/MarkdownRenderer.cs @@ -11,7 +11,7 @@ namespace AxCopilot.Services; /// 간이 마크다운 → WPF UIElement 변환기. /// 지원: **볼드**, *이탤릭*, `인라인코드`, ```코드블록```, ### 헤더, - 리스트, --- 구분선 /// -public static class MarkdownRenderer +public static partial class MarkdownRenderer { public static StackPanel Render(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg) { @@ -618,208 +618,4 @@ public static class MarkdownRenderer win.Show(); } - // ─── 구문 하이라이팅 ────────────────────────────────────────────────── - - private static readonly Brush KeywordBrush = new SolidColorBrush(Color.FromRgb(0xC5, 0x86, 0xC0)); // purple - private static readonly Brush TypeBrush = new SolidColorBrush(Color.FromRgb(0x4E, 0xC9, 0xB0)); // teal - private static readonly Brush StringBrush = new SolidColorBrush(Color.FromRgb(0xCE, 0x91, 0x78)); // orange - private static readonly Brush CommentBrush = new SolidColorBrush(Color.FromRgb(0x6A, 0x99, 0x55)); // green - private static readonly Brush NumberBrush = new SolidColorBrush(Color.FromRgb(0xB5, 0xCE, 0xA8)); // light green - private static readonly Brush MethodBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0xDC, 0xAA)); // yellow - private static readonly Brush DirectiveBrush = new SolidColorBrush(Color.FromRgb(0x9C, 0xDC, 0xFE)); // light blue - - private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) - { - "abstract","as","async","await","base","bool","break","byte","case","catch","char","checked", - "class","const","continue","decimal","default","delegate","do","double","else","enum","event", - "explicit","extern","false","finally","fixed","float","for","foreach","goto","if","implicit", - "in","int","interface","internal","is","lock","long","namespace","new","null","object","operator", - "out","override","params","private","protected","public","readonly","ref","return","sbyte","sealed", - "short","sizeof","stackalloc","static","string","struct","switch","this","throw","true","try", - "typeof","uint","ulong","unchecked","unsafe","ushort","using","var","virtual","void","volatile", - "while","yield","record","init","required","get","set","value","where","when","and","or","not", - }; - - private static readonly HashSet PythonKeywords = new(StringComparer.Ordinal) - { - "False","None","True","and","as","assert","async","await","break","class","continue","def", - "del","elif","else","except","finally","for","from","global","if","import","in","is","lambda", - "nonlocal","not","or","pass","raise","return","try","while","with","yield", - }; - - private static readonly HashSet JsKeywords = new(StringComparer.Ordinal) - { - "abstract","arguments","async","await","boolean","break","byte","case","catch","char","class", - "const","continue","debugger","default","delete","do","double","else","enum","eval","export", - "extends","false","final","finally","float","for","from","function","goto","if","implements", - "import","in","instanceof","int","interface","let","long","native","new","null","of","package", - "private","protected","public","return","short","static","super","switch","synchronized","this", - "throw","throws","transient","true","try","typeof","undefined","var","void","volatile","while", - "with","yield", - }; - - private static readonly HashSet JavaKeywords = new(StringComparer.Ordinal) - { - "abstract","assert","boolean","break","byte","case","catch","char","class","const","continue", - "default","do","double","else","enum","extends","false","final","finally","float","for","goto", - "if","implements","import","instanceof","int","interface","long","native","new","null","package", - "private","protected","public","return","short","static","strictfp","super","switch","synchronized", - "this","throw","throws","transient","true","try","void","volatile","while","var","record","sealed", - "permits","yield", - }; - - private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase) - { - "SELECT","FROM","WHERE","INSERT","INTO","UPDATE","SET","DELETE","CREATE","DROP","ALTER","TABLE", - "INDEX","VIEW","JOIN","INNER","LEFT","RIGHT","OUTER","FULL","CROSS","ON","AND","OR","NOT","IN", - "IS","NULL","LIKE","BETWEEN","EXISTS","HAVING","GROUP","BY","ORDER","ASC","DESC","LIMIT","OFFSET", - "UNION","ALL","DISTINCT","AS","CASE","WHEN","THEN","ELSE","END","VALUES","PRIMARY","KEY","FOREIGN", - "REFERENCES","CONSTRAINT","UNIQUE","CHECK","DEFAULT","COUNT","SUM","AVG","MIN","MAX","CAST", - "COALESCE","IF","BEGIN","COMMIT","ROLLBACK","GRANT","REVOKE","TRUNCATE","WITH","RECURSIVE", - }; - - private static HashSet GetKeywordsForLang(string lang) => lang.ToLowerInvariant() switch - { - "csharp" or "cs" or "c#" => CSharpKeywords, - "python" or "py" => PythonKeywords, - "javascript" or "js" or "jsx" => JsKeywords, - "typescript" or "ts" or "tsx" => JsKeywords, - "java" or "kotlin" or "kt" => JavaKeywords, - "sql" or "mysql" or "postgresql" or "sqlite" => SqlKeywords, - "go" or "golang" => new(StringComparer.Ordinal) - { - "break","case","chan","const","continue","default","defer","else","fallthrough","for","func", - "go","goto","if","import","interface","map","package","range","return","select","struct", - "switch","type","var","nil","true","false","append","len","cap","make","new","panic","recover", - }, - "rust" or "rs" => new(StringComparer.Ordinal) - { - "as","async","await","break","const","continue","crate","dyn","else","enum","extern","false", - "fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return", - "self","Self","static","struct","super","trait","true","type","unsafe","use","where","while", - "yield","Box","Vec","String","Option","Result","Some","None","Ok","Err","println","macro_rules", - }, - "cpp" or "c++" or "c" or "h" or "hpp" => new(StringComparer.Ordinal) - { - "auto","break","case","char","const","continue","default","do","double","else","enum","extern", - "float","for","goto","if","inline","int","long","register","return","short","signed","sizeof", - "static","struct","switch","typedef","union","unsigned","void","volatile","while","class", - "namespace","using","public","private","protected","virtual","override","template","typename", - "nullptr","true","false","new","delete","throw","try","catch","const_cast","dynamic_cast", - "static_cast","reinterpret_cast","bool","string","include","define","ifdef","ifndef","endif", - }, - _ => CSharpKeywords, // default - }; - - private static readonly Regex SyntaxPattern = new( - @"(//[^\n]*|#[^\n]*)" + // single-line comment (// or #) - @"|(""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*')" + // strings - @"|(\b\d+\.?\d*[fFdDmMlL]?\b)" + // numbers - @"|(\b[A-Z]\w*(?=\s*[\.<\(]))" + // type-like (PascalCase before . < () - @"|(\b\w+(?=\s*\())" + // method call - @"|(\b\w+\b)", // identifier / keyword - RegexOptions.Compiled); - - private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor) - { - if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code)) - { - tb.Text = code; - return; - } - - var keywords = GetKeywordsForLang(lang); - var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell" - or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1"; - - foreach (Match m in SyntaxPattern.Matches(code)) - { - if (m.Groups[1].Success) // comment - { - var commentText = m.Groups[1].Value; - // # 주석은 해당 언어에서만 적용 - if (commentText.StartsWith('#') && !isCommentHash) - { - // # 이 주석이 아닌 언어: 일반 텍스트로 처리 - tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); - } - else - { - tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); - } - } - else if (m.Groups[2].Success) // string - { - tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); - } - else if (m.Groups[3].Success) // number - { - tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); - } - else if (m.Groups[4].Success) // type - { - var word = m.Groups[4].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); - } - else if (m.Groups[5].Success) // method - { - var word = m.Groups[5].Value; - if (keywords.Contains(word)) - tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); - else - tb.Inlines.Add(new Run(word) { Foreground = MethodBrush }); - } - else if (m.Groups[6].Success) // identifier - { - var word = m.Groups[6].Value; - if (keywords.Contains(word)) - tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush }); - else - tb.Inlines.Add(new Run(word) { Foreground = defaultColor }); - } - } - - // Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움 - // Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완 - // 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근 - tb.Inlines.Clear(); - int lastEnd = 0; - foreach (Match m in SyntaxPattern.Matches(code)) - { - if (m.Index > lastEnd) - tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor }); - - if (m.Groups[1].Success) - { - var commentText = m.Groups[1].Value; - if (commentText.StartsWith('#') && !isCommentHash) - tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor }); - else - tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic }); - } - else if (m.Groups[2].Success) - tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush }); - else if (m.Groups[3].Success) - tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush }); - else if (m.Groups[4].Success) - { - var word = m.Groups[4].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush }); - } - else if (m.Groups[5].Success) - { - var word = m.Groups[5].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush }); - } - else if (m.Groups[6].Success) - { - var word = m.Groups[6].Value; - tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor }); - } - - lastEnd = m.Index + m.Length; - } - if (lastEnd < code.Length) - tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor }); - } } diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.Commands.cs b/src/AxCopilot/ViewModels/LauncherViewModel.Commands.cs new file mode 100644 index 0000000..d9ad0fe --- /dev/null +++ b/src/AxCopilot/ViewModels/LauncherViewModel.Commands.cs @@ -0,0 +1,273 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.ViewModels; + +public partial class LauncherViewModel +{ + // ─── 단축키 지원 메서드 ────────────────────────────────────────────────── + + /// 선택된 항목의 경로를 클립보드에 복사 + public bool CopySelectedPath() + { + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Path.GetFileName(Environment.ExpandEnvironmentVariables(entry.Path)); + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); + return true; + } + return false; + } + + /// 선택된 항목의 전체 경로를 클립보드에 복사 + public bool CopySelectedFullPath() + { + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Environment.ExpandEnvironmentVariables(entry.Path); + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); + return true; + } + return false; + } + + /// 선택된 항목을 탐색기에서 열기 + public bool OpenSelectedInExplorer() + { + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Environment.ExpandEnvironmentVariables(entry.Path); + if (File.Exists(path)) + Process.Start("explorer.exe", $"/select,\"{path}\""); + else if (Directory.Exists(path)) + Process.Start("explorer.exe", $"\"{path}\""); + return true; + } + return false; + } + + /// 선택된 항목을 관리자 권한으로 실행 + public bool RunSelectedAsAdmin() + { + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Environment.ExpandEnvironmentVariables(entry.Path); + try + { + Process.Start(new ProcessStartInfo(path) + { UseShellExecute = true, Verb = "runas" }); + return true; + } + catch (Exception ex) { LogService.Warn($"관리자 실행 취소: {ex.Message}"); } + } + return false; + } + + /// 선택된 항목의 속성 창 열기 + public bool ShowSelectedProperties() + { + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Environment.ExpandEnvironmentVariables(entry.Path); + try + { + Process.Start(new ProcessStartInfo(path) + { UseShellExecute = true, Verb = "properties" }); + return true; + } + catch (Exception) + { + // properties verb 미지원 시 탐색기에서 선택 + Process.Start("explorer.exe", $"/select,\"{path}\""); + return true; + } + } + return false; + } + + /// 최근 기록에서 항목 삭제 (Delete 키용) + public bool RemoveSelectedFromRecent() + { + if (SelectedItem == null || Results.Count == 0) return false; + + var idx = Results.IndexOf(SelectedItem); + Results.Remove(SelectedItem); + + if (Results.Count > 0) + SelectedItem = Results[Math.Min(idx, Results.Count - 1)]; + else + SelectedItem = null; + + return true; + } + + /// 입력창 초기화 + public void ClearInput() + { + InputText = ""; + } + + /// 첫 번째 결과 항목 선택 + public void SelectFirst() + { + if (Results.Count > 0) SelectedItem = Results[0]; + } + + /// 마지막 결과 항목 선택 + public void SelectLast() + { + if (Results.Count > 0) SelectedItem = Results[^1]; + } + + /// 현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다. + /// (추가됐으면 true, 제거됐으면 false, 대상 없으면 null) + public bool? ToggleFavorite() + { + if (SelectedItem?.Data is not IndexEntry entry) return null; + + var path = Environment.ExpandEnvironmentVariables(entry.Path); + var name = Path.GetFileNameWithoutExtension(path); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileName(path); + + var favFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "favorites.json"); + + try + { + var opts = new System.Text.Json.JsonSerializerOptions + { WriteIndented = true, PropertyNameCaseInsensitive = true }; + + List list = new(); + if (File.Exists(favFile)) + list = System.Text.Json.JsonSerializer.Deserialize>( + File.ReadAllText(favFile), opts) ?? new(); + + var existing = list.FirstOrDefault(f => + f.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + list.Remove(existing); + Directory.CreateDirectory(Path.GetDirectoryName(favFile)!); + File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts)); + return false; // 제거됨 + } + else + { + list.Insert(0, new FavJson { Name = name, Path = path }); + Directory.CreateDirectory(Path.GetDirectoryName(favFile)!); + File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts)); + return true; // 추가됨 + } + } + catch (Exception ex) + { + LogService.Warn($"즐겨찾기 토글 실패: {ex.Message}"); + return null; + } + } + + /// 선택 항목의 디렉터리에서 터미널을 열기. + /// 성공 여부 + public bool OpenSelectedInTerminal() + { + string dir; + if (SelectedItem?.Data is IndexEntry entry) + { + var path = Environment.ExpandEnvironmentVariables(entry.Path); + dir = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; + } + else + { + dir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + try { Process.Start("wt.exe", $"-d \"{dir}\""); return true; } + catch (Exception) + { + try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); return true; } + catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); return false; } + } + } + + /// 다운로드 폴더를 cd 프리픽스로 탐색합니다. + public void NavigateToDownloads() + { + var downloads = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + InputText = $"cd {downloads}"; + } + + // 즐겨찾기 직렬화용 내부 레코드 + private sealed class FavJson + { + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("path")] + public string Path { get; set; } = ""; + } + + // ─── 클립보드 병합 ──────────────────────────────────────────────────────── + + public void ToggleMergeItem(LauncherItem? item) + { + if (item?.Data is not ClipboardEntry entry || !entry.IsText) return; + + if (!_mergeQueue.Remove(entry)) + _mergeQueue.Add(entry); + + OnPropertyChanged(nameof(MergeCount)); + OnPropertyChanged(nameof(ShowMergeHint)); + OnPropertyChanged(nameof(MergeHintText)); + } + + /// 선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사 + public void ExecuteMerge() + { + if (_mergeQueue.Count == 0) return; + + // 선택 순서 보존: Results에서 보이는 순서 기준 + var ordered = Results + .Where(r => r.Data is ClipboardEntry e && _mergeQueue.Contains(e)) + .Select(r => ((ClipboardEntry)r.Data!).Text) + .ToList(); + + if (ordered.Count == 0) + ordered = _mergeQueue.Select(e => e.Text).ToList(); + + var merged = string.Join("\n", ordered); + try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(merged)); } + catch (Exception ex) { LogService.Warn($"병합 클립보드 실패: {ex.Message}"); } + + ClearMerge(); + CloseRequested?.Invoke(this, EventArgs.Empty); + LogService.Info($"클립보드 병합: {ordered.Count}개 항목"); + } + + public void ClearMerge() + { + _mergeQueue.Clear(); + OnPropertyChanged(nameof(MergeCount)); + OnPropertyChanged(nameof(ShowMergeHint)); + OnPropertyChanged(nameof(MergeHintText)); + } + + // ─── INotifyPropertyChanged ─────────────────────────────────────────────── + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +// ─── 파일 액션 데이터 타입 ──────────────────────────────────────────────────── + +public enum FileAction { CopyPath, CopyFullPath, OpenExplorer, RunAsAdmin, OpenTerminal, ShowProperties, Rename, DeleteToRecycleBin } + +public record FileActionData(string Path, FileAction Action); diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.FileAction.cs b/src/AxCopilot/ViewModels/LauncherViewModel.FileAction.cs new file mode 100644 index 0000000..0997889 --- /dev/null +++ b/src/AxCopilot/ViewModels/LauncherViewModel.FileAction.cs @@ -0,0 +1,155 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.ViewModels; + +public partial class LauncherViewModel +{ + // ─── 파일 액션 서브메뉴 ─────────────────────────────────────────────────── + + public void EnterActionMode(LauncherItem item) + { + if (!_settings.Settings.Launcher.EnableActionMode) return; + if (item.Data is not IndexEntry entry) return; + + _actionSourceItem = item; + _savedQuery = _inputText; + IsActionMode = true; + + var path = Environment.ExpandEnvironmentVariables(entry.Path); + var isDir = Directory.Exists(path); + var name = Path.GetFileName(path); + + Results.Clear(); + Results.Add(MakeAction("경로 복사", + path, FileAction.CopyPath, Symbols.Clipboard, "#8764B8")); + + Results.Add(MakeAction("전체 경로 복사", + path, FileAction.CopyFullPath, Symbols.Clipboard, "#C55A11")); + + Results.Add(MakeAction("파일 탐색기에서 열기", + "Explorer에서 위치 선택됨으로 표시", FileAction.OpenExplorer, Symbols.Folder, "#107C10")); + + if (!isDir) + Results.Add(MakeAction("관리자 권한으로 실행", + "UAC 권한 상승 후 실행", FileAction.RunAsAdmin, Symbols.Lock, "#C50F1F")); + + Results.Add(MakeAction("터미널에서 열기", + isDir ? path : Path.GetDirectoryName(path) ?? path, + FileAction.OpenTerminal, Symbols.Terminal, "#323130")); + + if (!isDir) + Results.Add(MakeAction("파일 속성 보기", + "Windows 속성 대화 상자 열기", FileAction.ShowProperties, Symbols.Info, "#6B2C91")); + + Results.Add(MakeAction("이름 바꾸기", + name, FileAction.Rename, Symbols.Rename, "#D97706")); + + Results.Add(MakeAction("휴지통으로 삭제", + "복구 가능한 삭제 · 확인 후 실행", FileAction.DeleteToRecycleBin, Symbols.Delete, "#C50F1F")); + + SelectedItem = Results.FirstOrDefault(); + + static LauncherItem MakeAction(string title, string subtitle, + FileAction action, string symbol, string colorHex) + { + var data = new FileActionData(subtitle, action); + return new LauncherItem(title, subtitle, null, data, Symbol: symbol); + } + } + + public void ExitActionMode() + { + IsActionMode = false; + _actionSourceItem = null; + // 이전 검색 쿼리 복원 + var q = _savedQuery; + _savedQuery = ""; + _ = SearchAsync(q); + } + + private static void ExecuteFileAction(FileActionData data) + { + var path = data.Path; + switch (data.Action) + { + case FileAction.CopyPath: + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); + break; + + case FileAction.OpenExplorer: + if (File.Exists(path)) + Process.Start("explorer.exe", $"/select,\"{path}\""); + else + Process.Start("explorer.exe", $"\"{path}\""); + break; + + case FileAction.RunAsAdmin: + try + { + Process.Start(new ProcessStartInfo(path) + { UseShellExecute = true, Verb = "runas" }); + } + catch (Exception ex) + { + LogService.Warn($"관리자 실행 취소: {ex.Message}"); + } + break; + + case FileAction.CopyFullPath: + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); + break; + + case FileAction.ShowProperties: + try + { + var psi = new ProcessStartInfo("explorer.exe") + { + Arguments = $"/select,\"{path}\"", + UseShellExecute = true + }; + Process.Start(psi); + // Shell property dialog + var propInfo = new ProcessStartInfo + { + FileName = "rundll32.exe", + Arguments = $"shell32.dll,ShellExec_RunDLL \"properties\" \"{path}\"", + UseShellExecute = false + }; + // 대안: Shell verb "properties" + try + { + Process.Start(new ProcessStartInfo(path) + { UseShellExecute = true, Verb = "properties" }); + } + catch (Exception) { /* 일부 파일 형식에서 지원 안됨 */ } + } + catch (Exception ex) { LogService.Warn($"속성 열기 실패: {ex.Message}"); } + break; + + case FileAction.Rename: + // 런처에서 rename 핸들러로 전달 + // ExitActionMode 후 InputText가 rename 프리픽스로 설정됨 + break; + + case FileAction.DeleteToRecycleBin: + // LauncherWindow.xaml.cs의 ExecuteSelected에서 확인 다이얼로그 처리 + break; + + case FileAction.OpenTerminal: + var dir = File.Exists(path) ? Path.GetDirectoryName(path) ?? path : path; + try { Process.Start("wt.exe", $"-d \"{dir}\""); } + catch (Exception) + { + try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); } + catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); } + } + break; + } + } +} diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index babfa65..3eec816 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -13,7 +13,7 @@ using AxCopilot.Themes; namespace AxCopilot.ViewModels; -public class LauncherViewModel : INotifyPropertyChanged +public partial class LauncherViewModel : INotifyPropertyChanged { private static App? CurrentApp => System.Windows.Application.Current as App; @@ -399,407 +399,4 @@ public class LauncherViewModel : INotifyPropertyChanged // 기본: 제목 return SelectedItem.Title; } - - // ─── 파일 액션 서브메뉴 ─────────────────────────────────────────────────── - - public void EnterActionMode(LauncherItem item) - { - if (!_settings.Settings.Launcher.EnableActionMode) return; - if (item.Data is not IndexEntry entry) return; - - _actionSourceItem = item; - _savedQuery = _inputText; - IsActionMode = true; - - var path = Environment.ExpandEnvironmentVariables(entry.Path); - var isDir = Directory.Exists(path); - var name = Path.GetFileName(path); - - Results.Clear(); - Results.Add(MakeAction("경로 복사", - path, FileAction.CopyPath, Symbols.Clipboard, "#8764B8")); - - Results.Add(MakeAction("전체 경로 복사", - path, FileAction.CopyFullPath, Symbols.Clipboard, "#C55A11")); - - Results.Add(MakeAction("파일 탐색기에서 열기", - "Explorer에서 위치 선택됨으로 표시", FileAction.OpenExplorer, Symbols.Folder, "#107C10")); - - if (!isDir) - Results.Add(MakeAction("관리자 권한으로 실행", - "UAC 권한 상승 후 실행", FileAction.RunAsAdmin, Symbols.Lock, "#C50F1F")); - - Results.Add(MakeAction("터미널에서 열기", - isDir ? path : Path.GetDirectoryName(path) ?? path, - FileAction.OpenTerminal, Symbols.Terminal, "#323130")); - - if (!isDir) - Results.Add(MakeAction("파일 속성 보기", - "Windows 속성 대화 상자 열기", FileAction.ShowProperties, Symbols.Info, "#6B2C91")); - - Results.Add(MakeAction("이름 바꾸기", - name, FileAction.Rename, Symbols.Rename, "#D97706")); - - Results.Add(MakeAction("휴지통으로 삭제", - "복구 가능한 삭제 · 확인 후 실행", FileAction.DeleteToRecycleBin, Symbols.Delete, "#C50F1F")); - - SelectedItem = Results.FirstOrDefault(); - - static LauncherItem MakeAction(string title, string subtitle, - FileAction action, string symbol, string colorHex) - { - var data = new FileActionData(subtitle, action); - return new LauncherItem(title, subtitle, null, data, Symbol: symbol); - } - } - - public void ExitActionMode() - { - IsActionMode = false; - _actionSourceItem = null; - // 이전 검색 쿼리 복원 - var q = _savedQuery; - _savedQuery = ""; - _ = SearchAsync(q); - } - - private static void ExecuteFileAction(FileActionData data) - { - var path = data.Path; - switch (data.Action) - { - case FileAction.CopyPath: - Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); - break; - - case FileAction.OpenExplorer: - if (File.Exists(path)) - Process.Start("explorer.exe", $"/select,\"{path}\""); - else - Process.Start("explorer.exe", $"\"{path}\""); - break; - - case FileAction.RunAsAdmin: - try - { - Process.Start(new ProcessStartInfo(path) - { UseShellExecute = true, Verb = "runas" }); - } - catch (Exception ex) - { - LogService.Warn($"관리자 실행 취소: {ex.Message}"); - } - break; - - case FileAction.CopyFullPath: - Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); - break; - - case FileAction.ShowProperties: - try - { - var psi = new ProcessStartInfo("explorer.exe") - { - Arguments = $"/select,\"{path}\"", - UseShellExecute = true - }; - Process.Start(psi); - // Shell property dialog - var propInfo = new ProcessStartInfo - { - FileName = "rundll32.exe", - Arguments = $"shell32.dll,ShellExec_RunDLL \"properties\" \"{path}\"", - UseShellExecute = false - }; - // 대안: Shell verb "properties" - try - { - Process.Start(new ProcessStartInfo(path) - { UseShellExecute = true, Verb = "properties" }); - } - catch (Exception) { /* 일부 파일 형식에서 지원 안됨 */ } - } - catch (Exception ex) { LogService.Warn($"속성 열기 실패: {ex.Message}"); } - break; - - case FileAction.Rename: - // 런처에서 rename 핸들러로 전달 - // ExitActionMode 후 InputText가 rename 프리픽스로 설정됨 - break; - - case FileAction.DeleteToRecycleBin: - // LauncherWindow.xaml.cs의 ExecuteSelected에서 확인 다이얼로그 처리 - break; - - case FileAction.OpenTerminal: - var dir = File.Exists(path) ? Path.GetDirectoryName(path) ?? path : path; - try { Process.Start("wt.exe", $"-d \"{dir}\""); } - catch (Exception) - { - try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); } - catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); } - } - break; - } - } - - // ─── 단축키 지원 메서드 ────────────────────────────────────────────────── - - /// 선택된 항목의 경로를 클립보드에 복사 - public bool CopySelectedPath() - { - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Path.GetFileName(Environment.ExpandEnvironmentVariables(entry.Path)); - Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); - return true; - } - return false; - } - - /// 선택된 항목의 전체 경로를 클립보드에 복사 - public bool CopySelectedFullPath() - { - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Environment.ExpandEnvironmentVariables(entry.Path); - Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); - return true; - } - return false; - } - - /// 선택된 항목을 탐색기에서 열기 - public bool OpenSelectedInExplorer() - { - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Environment.ExpandEnvironmentVariables(entry.Path); - if (File.Exists(path)) - Process.Start("explorer.exe", $"/select,\"{path}\""); - else if (Directory.Exists(path)) - Process.Start("explorer.exe", $"\"{path}\""); - return true; - } - return false; - } - - /// 선택된 항목을 관리자 권한으로 실행 - public bool RunSelectedAsAdmin() - { - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Environment.ExpandEnvironmentVariables(entry.Path); - try - { - Process.Start(new ProcessStartInfo(path) - { UseShellExecute = true, Verb = "runas" }); - return true; - } - catch (Exception ex) { LogService.Warn($"관리자 실행 취소: {ex.Message}"); } - } - return false; - } - - /// 선택된 항목의 속성 창 열기 - public bool ShowSelectedProperties() - { - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Environment.ExpandEnvironmentVariables(entry.Path); - try - { - Process.Start(new ProcessStartInfo(path) - { UseShellExecute = true, Verb = "properties" }); - return true; - } - catch (Exception) - { - // properties verb 미지원 시 탐색기에서 선택 - Process.Start("explorer.exe", $"/select,\"{path}\""); - return true; - } - } - return false; - } - - /// 최근 기록에서 항목 삭제 (Delete 키용) - public bool RemoveSelectedFromRecent() - { - if (SelectedItem == null || Results.Count == 0) return false; - - var idx = Results.IndexOf(SelectedItem); - Results.Remove(SelectedItem); - - if (Results.Count > 0) - SelectedItem = Results[Math.Min(idx, Results.Count - 1)]; - else - SelectedItem = null; - - return true; - } - - /// 입력창 초기화 - public void ClearInput() - { - InputText = ""; - } - - /// 첫 번째 결과 항목 선택 - public void SelectFirst() - { - if (Results.Count > 0) SelectedItem = Results[0]; - } - - /// 마지막 결과 항목 선택 - public void SelectLast() - { - if (Results.Count > 0) SelectedItem = Results[^1]; - } - - /// 현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다. - /// (추가됐으면 true, 제거됐으면 false, 대상 없으면 null) - public bool? ToggleFavorite() - { - if (SelectedItem?.Data is not IndexEntry entry) return null; - - var path = Environment.ExpandEnvironmentVariables(entry.Path); - var name = Path.GetFileNameWithoutExtension(path); - if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileName(path); - - var favFile = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AxCopilot", "favorites.json"); - - try - { - var opts = new System.Text.Json.JsonSerializerOptions - { WriteIndented = true, PropertyNameCaseInsensitive = true }; - - List list = new(); - if (File.Exists(favFile)) - list = System.Text.Json.JsonSerializer.Deserialize>( - File.ReadAllText(favFile), opts) ?? new(); - - var existing = list.FirstOrDefault(f => - f.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); - - if (existing != null) - { - list.Remove(existing); - Directory.CreateDirectory(Path.GetDirectoryName(favFile)!); - File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts)); - return false; // 제거됨 - } - else - { - list.Insert(0, new FavJson { Name = name, Path = path }); - Directory.CreateDirectory(Path.GetDirectoryName(favFile)!); - File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts)); - return true; // 추가됨 - } - } - catch (Exception ex) - { - LogService.Warn($"즐겨찾기 토글 실패: {ex.Message}"); - return null; - } - } - - /// 선택 항목의 디렉터리에서 터미널을 열기. - /// 성공 여부 - public bool OpenSelectedInTerminal() - { - string dir; - if (SelectedItem?.Data is IndexEntry entry) - { - var path = Environment.ExpandEnvironmentVariables(entry.Path); - dir = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; - } - else - { - dir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - } - - try { Process.Start("wt.exe", $"-d \"{dir}\""); return true; } - catch (Exception) - { - try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); return true; } - catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); return false; } - } - } - - /// 다운로드 폴더를 cd 프리픽스로 탐색합니다. - public void NavigateToDownloads() - { - var downloads = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - InputText = $"cd {downloads}"; - } - - // 즐겨찾기 직렬화용 내부 레코드 - private sealed class FavJson - { - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string Name { get; set; } = ""; - [System.Text.Json.Serialization.JsonPropertyName("path")] - public string Path { get; set; } = ""; - } - - // ─── 클립보드 병합 ──────────────────────────────────────────────────────── - - public void ToggleMergeItem(LauncherItem? item) - { - if (item?.Data is not ClipboardEntry entry || !entry.IsText) return; - - if (!_mergeQueue.Remove(entry)) - _mergeQueue.Add(entry); - - OnPropertyChanged(nameof(MergeCount)); - OnPropertyChanged(nameof(ShowMergeHint)); - OnPropertyChanged(nameof(MergeHintText)); - } - - /// 선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사 - public void ExecuteMerge() - { - if (_mergeQueue.Count == 0) return; - - // 선택 순서 보존: Results에서 보이는 순서 기준 - var ordered = Results - .Where(r => r.Data is ClipboardEntry e && _mergeQueue.Contains(e)) - .Select(r => ((ClipboardEntry)r.Data!).Text) - .ToList(); - - if (ordered.Count == 0) - ordered = _mergeQueue.Select(e => e.Text).ToList(); - - var merged = string.Join("\n", ordered); - try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(merged)); } - catch (Exception ex) { LogService.Warn($"병합 클립보드 실패: {ex.Message}"); } - - ClearMerge(); - CloseRequested?.Invoke(this, EventArgs.Empty); - LogService.Info($"클립보드 병합: {ordered.Count}개 항목"); - } - - public void ClearMerge() - { - _mergeQueue.Clear(); - OnPropertyChanged(nameof(MergeCount)); - OnPropertyChanged(nameof(ShowMergeHint)); - OnPropertyChanged(nameof(MergeHintText)); - } - - // ─── INotifyPropertyChanged ─────────────────────────────────────────────── - - public event PropertyChangedEventHandler? PropertyChanged; - protected void OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } - -// ─── 파일 액션 데이터 타입 ──────────────────────────────────────────────────── - -public enum FileAction { CopyPath, CopyFullPath, OpenExplorer, RunAsAdmin, OpenTerminal, ShowProperties, Rename, DeleteToRecycleBin } - -public record FileActionData(string Path, FileAction Action); diff --git a/src/AxCopilot/Views/SettingsWindow.ToolCards.cs b/src/AxCopilot/Views/SettingsWindow.ToolCards.cs new file mode 100644 index 0000000..c06c85d --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.ToolCards.cs @@ -0,0 +1,283 @@ +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services; +using AxCopilot.ViewModels; + +namespace AxCopilot.Views; + +public partial class SettingsWindow +{ + // ─── AX Agent 서브탭 전환 ─────────────────────────────────────────── + + private void AgentSubTab_Checked(object sender, RoutedEventArgs e) + { + if (AgentPanelCommon == null) return; // 초기화 전 방어 + AgentPanelCommon.Visibility = AgentTabCommon.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + AgentPanelChat.Visibility = AgentTabChat.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + AgentPanelCoworkCode.Visibility = AgentTabCoworkCode.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + AgentPanelCowork.Visibility = AgentTabCowork.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + AgentPanelCode.Visibility = AgentTabCode.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + if (AgentPanelDev != null) + AgentPanelDev.Visibility = AgentTabDev.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + if (AgentPanelEtc != null) + AgentPanelEtc.Visibility = AgentTabEtc.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + if (AgentPanelTools != null) + { + var show = AgentTabTools.IsChecked == true; + AgentPanelTools.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + if (show) LoadToolCards(); + } + } + + // ─── 도구 관리 카드 UI ────────────────────────────────────────────── + + private bool _toolCardsLoaded; + private HashSet _disabledTools = new(StringComparer.OrdinalIgnoreCase); + + /// 도구 카드 UI를 카테고리별로 생성합니다. + private void LoadToolCards() + { + if (_toolCardsLoaded || ToolCardsPanel == null) return; + _toolCardsLoaded = true; + + var app = CurrentApp; + var settings = app?.SettingsService?.Settings.Llm; + using var tools = Services.Agent.ToolRegistry.CreateDefault(); + _disabledTools = new HashSet(settings?.DisabledTools ?? new(), StringComparer.OrdinalIgnoreCase); + var disabled = _disabledTools; + + // 카테고리 매핑 + var categories = new Dictionary> + { + ["파일/검색"] = new(), + ["문서 생성"] = new(), + ["문서 품질"] = new(), + ["코드/개발"] = new(), + ["데이터/유틸"] = new(), + ["시스템"] = new(), + }; + + var toolCategoryMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // 파일/검색 + ["file_read"] = "파일/검색", ["file_write"] = "파일/검색", ["file_edit"] = "파일/검색", + ["glob"] = "파일/검색", ["grep"] = "파일/검색", ["folder_map"] = "파일/검색", + ["document_read"] = "파일/검색", ["file_watch"] = "파일/검색", + // 문서 생성 + ["excel_skill"] = "문서 생성", ["docx_skill"] = "문서 생성", ["csv_skill"] = "문서 생성", + ["markdown_skill"] = "문서 생성", ["html_skill"] = "문서 생성", ["chart_skill"] = "문서 생성", + ["batch_skill"] = "문서 생성", ["pptx_skill"] = "문서 생성", + ["document_planner"] = "문서 생성", ["document_assembler"] = "문서 생성", + // 문서 품질 + ["document_review"] = "문서 품질", ["format_convert"] = "문서 품질", + ["template_render"] = "문서 품질", ["text_summarize"] = "문서 품질", + // 코드/개발 + ["dev_env_detect"] = "코드/개발", ["build_run"] = "코드/개발", ["git_tool"] = "코드/개발", + ["lsp"] = "코드/개발", ["sub_agent"] = "코드/개발", ["wait_agents"] = "코드/개발", + ["code_search"] = "코드/개발", ["test_loop"] = "코드/개발", + ["code_review"] = "코드/개발", ["project_rule"] = "코드/개발", + // 시스템 + ["process"] = "시스템", ["skill_manager"] = "시스템", ["memory"] = "시스템", + ["clipboard"] = "시스템", ["notify"] = "시스템", ["env"] = "시스템", + ["image_analyze"] = "시스템", + }; + + foreach (var tool in tools.All) + { + var cat = toolCategoryMap.TryGetValue(tool.Name, out var c) ? c : "데이터/유틸"; + if (categories.ContainsKey(cat)) + categories[cat].Add(tool); + else + categories["데이터/유틸"].Add(tool); + } + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xF5, 0xF5, 0xF8)); + + foreach (var kv in categories) + { + if (kv.Value.Count == 0) continue; + + // 카테고리 헤더 + var header = new TextBlock + { + Text = $"{kv.Key} ({kv.Value.Count})", + FontSize = 13, FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, + Margin = new Thickness(0, 8, 0, 6), + }; + ToolCardsPanel.Children.Add(header); + + // 카드 WrapPanel + var wrap = new WrapPanel { Margin = new Thickness(0, 0, 0, 4) }; + foreach (var tool in kv.Value.OrderBy(t => t.Name)) + { + var isEnabled = !disabled.Contains(tool.Name); + var card = CreateToolCard(tool, isEnabled, disabled, accentBrush, secondaryText, itemBg); + wrap.Children.Add(card); + } + ToolCardsPanel.Children.Add(wrap); + } + + // MCP 서버 상태 + LoadMcpStatus(); + } + + /// 개별 도구 카드를 생성합니다 (이름 + 설명 + 토글). + private Border CreateToolCard(Services.Agent.IAgentTool tool, bool isEnabled, + HashSet disabled, Brush accentBrush, Brush secondaryText, Brush itemBg) + { + var card = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 8, 8), + Width = 240, + BorderBrush = isEnabled ? Brushes.Transparent : new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), + BorderThickness = new Thickness(1), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 이름 + 설명 + var textStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var nameBlock = new TextBlock + { + Text = tool.Name, + FontSize = 12, FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + textStack.Children.Add(nameBlock); + + var desc = tool.Description; + if (desc.Length > 50) desc = desc[..50] + "…"; + var descBlock = new TextBlock + { + Text = desc, + FontSize = 10.5, + Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 2, 0, 0), + }; + textStack.Children.Add(descBlock); + Grid.SetColumn(textStack, 0); + grid.Children.Add(textStack); + + // 토글 (CheckBox + ToggleSwitch 스타일) + var toggle = new CheckBox + { + IsChecked = isEnabled, + Style = TryFindResource("ToggleSwitch") as Style, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + }; + toggle.Checked += (_, _) => + { + disabled.Remove(tool.Name); + card.BorderBrush = Brushes.Transparent; + }; + toggle.Unchecked += (_, _) => + { + disabled.Add(tool.Name); + card.BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)); + }; + Grid.SetColumn(toggle, 1); + grid.Children.Add(toggle); + + card.Child = grid; + return card; + } + + /// MCP 서버 연결 상태 표시를 생성합니다. + private void LoadMcpStatus() + { + if (McpStatusPanel == null) return; + McpStatusPanel.Children.Clear(); + + var app = CurrentApp; + var settings = app?.SettingsService?.Settings.Llm; + var mcpServers = settings?.McpServers; + + if (mcpServers == null || mcpServers.Count == 0) + { + McpStatusPanel.Children.Add(new TextBlock + { + Text = "등록된 MCP 서버가 없습니다.", + FontSize = 12, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Margin = new Thickness(0, 4, 0, 0), + }); + return; + } + + var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xF5, 0xF5, 0xF8)); + + foreach (var server in mcpServers) + { + var row = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(0, 0, 0, 6), + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // 상태 아이콘 + var statusDot = new Border + { + Width = 8, Height = 8, + CornerRadius = new CornerRadius(4), + Background = new SolidColorBrush(Color.FromRgb(0x34, 0xA8, 0x53)), // 녹색 (등록됨) + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }; + Grid.SetColumn(statusDot, 0); + grid.Children.Add(statusDot); + + // 서버 이름 + 명령 + var infoStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + infoStack.Children.Add(new TextBlock + { + Text = server.Name ?? "(이름 없음)", + FontSize = 12, FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, + }); + infoStack.Children.Add(new TextBlock + { + Text = server.Command ?? "", + FontSize = 10.5, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + Grid.SetColumn(infoStack, 1); + grid.Children.Add(infoStack); + + // 상태 텍스트 + var statusText = new TextBlock + { + Text = "등록됨", + FontSize = 11, + Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xA8, 0x53)), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(statusText, 2); + grid.Children.Add(statusText); + + row.Child = grid; + McpStatusPanel.Children.Add(row); + } + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.Tools.cs b/src/AxCopilot/Views/SettingsWindow.Tools.cs index e8e456d..91b259d 100644 --- a/src/AxCopilot/Views/SettingsWindow.Tools.cs +++ b/src/AxCopilot/Views/SettingsWindow.Tools.cs @@ -602,274 +602,4 @@ public partial class SettingsWindow return card; } - // ─── AX Agent 서브탭 전환 ─────────────────────────────────────────── - - private void AgentSubTab_Checked(object sender, RoutedEventArgs e) - { - if (AgentPanelCommon == null) return; // 초기화 전 방어 - AgentPanelCommon.Visibility = AgentTabCommon.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - AgentPanelChat.Visibility = AgentTabChat.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - AgentPanelCoworkCode.Visibility = AgentTabCoworkCode.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - AgentPanelCowork.Visibility = AgentTabCowork.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - AgentPanelCode.Visibility = AgentTabCode.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - if (AgentPanelDev != null) - AgentPanelDev.Visibility = AgentTabDev.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - if (AgentPanelEtc != null) - AgentPanelEtc.Visibility = AgentTabEtc.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; - if (AgentPanelTools != null) - { - var show = AgentTabTools.IsChecked == true; - AgentPanelTools.Visibility = show ? Visibility.Visible : Visibility.Collapsed; - if (show) LoadToolCards(); - } - } - - // ─── 도구 관리 카드 UI ────────────────────────────────────────────── - - private bool _toolCardsLoaded; - private HashSet _disabledTools = new(StringComparer.OrdinalIgnoreCase); - - /// 도구 카드 UI를 카테고리별로 생성합니다. - private void LoadToolCards() - { - if (_toolCardsLoaded || ToolCardsPanel == null) return; - _toolCardsLoaded = true; - - var app = CurrentApp; - var settings = app?.SettingsService?.Settings.Llm; - using var tools = Services.Agent.ToolRegistry.CreateDefault(); - _disabledTools = new HashSet(settings?.DisabledTools ?? new(), StringComparer.OrdinalIgnoreCase); - var disabled = _disabledTools; - - // 카테고리 매핑 - var categories = new Dictionary> - { - ["파일/검색"] = new(), - ["문서 생성"] = new(), - ["문서 품질"] = new(), - ["코드/개발"] = new(), - ["데이터/유틸"] = new(), - ["시스템"] = new(), - }; - - var toolCategoryMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - // 파일/검색 - ["file_read"] = "파일/검색", ["file_write"] = "파일/검색", ["file_edit"] = "파일/검색", - ["glob"] = "파일/검색", ["grep"] = "파일/검색", ["folder_map"] = "파일/검색", - ["document_read"] = "파일/검색", ["file_watch"] = "파일/검색", - // 문서 생성 - ["excel_skill"] = "문서 생성", ["docx_skill"] = "문서 생성", ["csv_skill"] = "문서 생성", - ["markdown_skill"] = "문서 생성", ["html_skill"] = "문서 생성", ["chart_skill"] = "문서 생성", - ["batch_skill"] = "문서 생성", ["pptx_skill"] = "문서 생성", - ["document_planner"] = "문서 생성", ["document_assembler"] = "문서 생성", - // 문서 품질 - ["document_review"] = "문서 품질", ["format_convert"] = "문서 품질", - ["template_render"] = "문서 품질", ["text_summarize"] = "문서 품질", - // 코드/개발 - ["dev_env_detect"] = "코드/개발", ["build_run"] = "코드/개발", ["git_tool"] = "코드/개발", - ["lsp"] = "코드/개발", ["sub_agent"] = "코드/개발", ["wait_agents"] = "코드/개발", - ["code_search"] = "코드/개발", ["test_loop"] = "코드/개발", - ["code_review"] = "코드/개발", ["project_rule"] = "코드/개발", - // 시스템 - ["process"] = "시스템", ["skill_manager"] = "시스템", ["memory"] = "시스템", - ["clipboard"] = "시스템", ["notify"] = "시스템", ["env"] = "시스템", - ["image_analyze"] = "시스템", - }; - - foreach (var tool in tools.All) - { - var cat = toolCategoryMap.TryGetValue(tool.Name, out var c) ? c : "데이터/유틸"; - if (categories.ContainsKey(cat)) - categories[cat].Add(tool); - else - categories["데이터/유틸"].Add(tool); - } - - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xF5, 0xF5, 0xF8)); - - foreach (var kv in categories) - { - if (kv.Value.Count == 0) continue; - - // 카테고리 헤더 - var header = new TextBlock - { - Text = $"{kv.Key} ({kv.Value.Count})", - FontSize = 13, FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, - Margin = new Thickness(0, 8, 0, 6), - }; - ToolCardsPanel.Children.Add(header); - - // 카드 WrapPanel - var wrap = new WrapPanel { Margin = new Thickness(0, 0, 0, 4) }; - foreach (var tool in kv.Value.OrderBy(t => t.Name)) - { - var isEnabled = !disabled.Contains(tool.Name); - var card = CreateToolCard(tool, isEnabled, disabled, accentBrush, secondaryText, itemBg); - wrap.Children.Add(card); - } - ToolCardsPanel.Children.Add(wrap); - } - - // MCP 서버 상태 - LoadMcpStatus(); - } - - /// 개별 도구 카드를 생성합니다 (이름 + 설명 + 토글). - private Border CreateToolCard(Services.Agent.IAgentTool tool, bool isEnabled, - HashSet disabled, Brush accentBrush, Brush secondaryText, Brush itemBg) - { - var card = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 8, 8), - Width = 240, - BorderBrush = isEnabled ? Brushes.Transparent : new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)), - BorderThickness = new Thickness(1), - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - // 이름 + 설명 - var textStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - var nameBlock = new TextBlock - { - Text = tool.Name, - FontSize = 12, FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, - TextTrimming = TextTrimming.CharacterEllipsis, - }; - textStack.Children.Add(nameBlock); - - var desc = tool.Description; - if (desc.Length > 50) desc = desc[..50] + "…"; - var descBlock = new TextBlock - { - Text = desc, - FontSize = 10.5, - Foreground = secondaryText, - TextTrimming = TextTrimming.CharacterEllipsis, - Margin = new Thickness(0, 2, 0, 0), - }; - textStack.Children.Add(descBlock); - Grid.SetColumn(textStack, 0); - grid.Children.Add(textStack); - - // 토글 (CheckBox + ToggleSwitch 스타일) - var toggle = new CheckBox - { - IsChecked = isEnabled, - Style = TryFindResource("ToggleSwitch") as Style, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(8, 0, 0, 0), - }; - toggle.Checked += (_, _) => - { - disabled.Remove(tool.Name); - card.BorderBrush = Brushes.Transparent; - }; - toggle.Unchecked += (_, _) => - { - disabled.Add(tool.Name); - card.BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)); - }; - Grid.SetColumn(toggle, 1); - grid.Children.Add(toggle); - - card.Child = grid; - return card; - } - - /// MCP 서버 연결 상태 표시를 생성합니다. - private void LoadMcpStatus() - { - if (McpStatusPanel == null) return; - McpStatusPanel.Children.Clear(); - - var app = CurrentApp; - var settings = app?.SettingsService?.Settings.Llm; - var mcpServers = settings?.McpServers; - - if (mcpServers == null || mcpServers.Count == 0) - { - McpStatusPanel.Children.Add(new TextBlock - { - Text = "등록된 MCP 서버가 없습니다.", - FontSize = 12, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - Margin = new Thickness(0, 4, 0, 0), - }); - return; - } - - var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0xF5, 0xF5, 0xF8)); - - foreach (var server in mcpServers) - { - var row = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(0, 0, 0, 6), - }; - - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - // 상태 아이콘 - var statusDot = new Border - { - Width = 8, Height = 8, - CornerRadius = new CornerRadius(4), - Background = new SolidColorBrush(Color.FromRgb(0x34, 0xA8, 0x53)), // 녹색 (등록됨) - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - }; - Grid.SetColumn(statusDot, 0); - grid.Children.Add(statusDot); - - // 서버 이름 + 명령 - var infoStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - infoStack.Children.Add(new TextBlock - { - Text = server.Name ?? "(이름 없음)", - FontSize = 12, FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black, - }); - infoStack.Children.Add(new TextBlock - { - Text = server.Command ?? "", - FontSize = 10.5, - Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, - TextTrimming = TextTrimming.CharacterEllipsis, - }); - Grid.SetColumn(infoStack, 1); - grid.Children.Add(infoStack); - - // 상태 텍스트 - var statusText = new TextBlock - { - Text = "등록됨", - FontSize = 11, - Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xA8, 0x53)), - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(statusText, 2); - grid.Children.Add(statusText); - - row.Child = grid; - McpStatusPanel.Children.Add(row); - } - } }