[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

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