[Phase 44] LauncherViewModel·SettingsWindow.Tools·MarkdownRenderer 파셜 분할
LauncherViewModel (805줄 → 402줄, 50% 감소): - LauncherViewModel.FileAction.cs (154줄): 파일 액션 서브메뉴, EnterActionMode, ExitActionMode - LauncherViewModel.Commands.cs (273줄): CopySelectedPath, Favorite, Terminal, 클립보드 병합, INotifyPropertyChanged - 오류 수정: FileAction.cs에 using AxCopilot.Themes 누락 → 추가 SettingsWindow.Tools (875줄 → 605줄): - SettingsWindow.ToolCards.cs (283줄): AX Agent 서브탭 전환 + 도구 관리 카드 UI MarkdownRenderer (825줄 → 621줄): - MarkdownRenderer.Highlighting.cs (215줄): 구문 하이라이팅 전체 분리 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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차)
|
||||
|
||||
|
||||
215
src/AxCopilot/Services/MarkdownRenderer.Highlighting.cs
Normal file
215
src/AxCopilot/Services/MarkdownRenderer.Highlighting.cs
Normal file
@@ -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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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 });
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace AxCopilot.Services;
|
||||
/// 간이 마크다운 → WPF UIElement 변환기.
|
||||
/// 지원: **볼드**, *이탤릭*, `인라인코드`, ```코드블록```, ### 헤더, - 리스트, --- 구분선
|
||||
/// </summary>
|
||||
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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
273
src/AxCopilot/ViewModels/LauncherViewModel.Commands.cs
Normal file
273
src/AxCopilot/ViewModels/LauncherViewModel.Commands.cs
Normal file
@@ -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
|
||||
{
|
||||
// ─── 단축키 지원 메서드 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>선택된 항목의 경로를 클립보드에 복사</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 전체 경로를 클립보드에 복사</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 탐색기에서 열기</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 관리자 권한으로 실행</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 속성 창 열기</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>최근 기록에서 항목 삭제 (Delete 키용)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>입력창 초기화</summary>
|
||||
public void ClearInput()
|
||||
{
|
||||
InputText = "";
|
||||
}
|
||||
|
||||
/// <summary>첫 번째 결과 항목 선택</summary>
|
||||
public void SelectFirst()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[0];
|
||||
}
|
||||
|
||||
/// <summary>마지막 결과 항목 선택</summary>
|
||||
public void SelectLast()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[^1];
|
||||
}
|
||||
|
||||
/// <summary>현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다.</summary>
|
||||
/// <returns>(추가됐으면 true, 제거됐으면 false, 대상 없으면 null)</returns>
|
||||
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<FavJson> list = new();
|
||||
if (File.Exists(favFile))
|
||||
list = System.Text.Json.JsonSerializer.Deserialize<List<FavJson>>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 디렉터리에서 터미널을 열기.</summary>
|
||||
/// <returns>성공 여부</returns>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>다운로드 폴더를 cd 프리픽스로 탐색합니다.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사</summary>
|
||||
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);
|
||||
155
src/AxCopilot/ViewModels/LauncherViewModel.FileAction.cs
Normal file
155
src/AxCopilot/ViewModels/LauncherViewModel.FileAction.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 단축키 지원 메서드 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>선택된 항목의 경로를 클립보드에 복사</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 전체 경로를 클립보드에 복사</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 탐색기에서 열기</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 관리자 권한으로 실행</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 속성 창 열기</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>최근 기록에서 항목 삭제 (Delete 키용)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>입력창 초기화</summary>
|
||||
public void ClearInput()
|
||||
{
|
||||
InputText = "";
|
||||
}
|
||||
|
||||
/// <summary>첫 번째 결과 항목 선택</summary>
|
||||
public void SelectFirst()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[0];
|
||||
}
|
||||
|
||||
/// <summary>마지막 결과 항목 선택</summary>
|
||||
public void SelectLast()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[^1];
|
||||
}
|
||||
|
||||
/// <summary>현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다.</summary>
|
||||
/// <returns>(추가됐으면 true, 제거됐으면 false, 대상 없으면 null)</returns>
|
||||
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<FavJson> list = new();
|
||||
if (File.Exists(favFile))
|
||||
list = System.Text.Json.JsonSerializer.Deserialize<List<FavJson>>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 디렉터리에서 터미널을 열기.</summary>
|
||||
/// <returns>성공 여부</returns>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>다운로드 폴더를 cd 프리픽스로 탐색합니다.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사</summary>
|
||||
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);
|
||||
|
||||
283
src/AxCopilot/Views/SettingsWindow.ToolCards.cs
Normal file
283
src/AxCopilot/Views/SettingsWindow.ToolCards.cs
Normal file
@@ -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<string> _disabledTools = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>도구 카드 UI를 카테고리별로 생성합니다.</summary>
|
||||
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<string>(settings?.DisabledTools ?? new(), StringComparer.OrdinalIgnoreCase);
|
||||
var disabled = _disabledTools;
|
||||
|
||||
// 카테고리 매핑
|
||||
var categories = new Dictionary<string, List<Services.Agent.IAgentTool>>
|
||||
{
|
||||
["파일/검색"] = new(),
|
||||
["문서 생성"] = new(),
|
||||
["문서 품질"] = new(),
|
||||
["코드/개발"] = new(),
|
||||
["데이터/유틸"] = new(),
|
||||
["시스템"] = new(),
|
||||
};
|
||||
|
||||
var toolCategoryMap = new Dictionary<string, string>(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();
|
||||
}
|
||||
|
||||
/// <summary>개별 도구 카드를 생성합니다 (이름 + 설명 + 토글).</summary>
|
||||
private Border CreateToolCard(Services.Agent.IAgentTool tool, bool isEnabled,
|
||||
HashSet<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>MCP 서버 연결 상태 표시를 생성합니다.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> _disabledTools = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>도구 카드 UI를 카테고리별로 생성합니다.</summary>
|
||||
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<string>(settings?.DisabledTools ?? new(), StringComparer.OrdinalIgnoreCase);
|
||||
var disabled = _disabledTools;
|
||||
|
||||
// 카테고리 매핑
|
||||
var categories = new Dictionary<string, List<Services.Agent.IAgentTool>>
|
||||
{
|
||||
["파일/검색"] = new(),
|
||||
["문서 생성"] = new(),
|
||||
["문서 품질"] = new(),
|
||||
["코드/개발"] = new(),
|
||||
["데이터/유틸"] = new(),
|
||||
["시스템"] = new(),
|
||||
};
|
||||
|
||||
var toolCategoryMap = new Dictionary<string, string>(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();
|
||||
}
|
||||
|
||||
/// <summary>개별 도구 카드를 생성합니다 (이름 + 설명 + 토글).</summary>
|
||||
private Border CreateToolCard(Services.Agent.IAgentTool tool, bool isEnabled,
|
||||
HashSet<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>MCP 서버 연결 상태 표시를 생성합니다.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user