[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:
2026-04-03 20:03:25 +09:00
parent 2cf1fcd411
commit 35e6e4c060
8 changed files with 946 additions and 880 deletions

View File

@@ -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차)

View 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 });
}
}

View File

@@ -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 });
}
}

View 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);

View 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;
}
}
}

View File

@@ -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);

View 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);
}
}
}

View File

@@ -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);
}
}
}