AX Commander 비교본 런처 기능 대량 이식

변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,251 @@
using System.Diagnostics;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-1: Git 빠른 조회 핸들러. "git" 프리픽스로 사용합니다.
///
/// 예: git → 최근 작업 폴더(또는 현재 앱 폴더)의 git 상태 요약
/// git status → git status --short 출력
/// git log → 최근 커밋 10개
/// git branch → 브랜치 목록 (현재 브랜치 강조)
/// git stash → stash 목록
/// git diff → git diff --stat 요약
/// git pull → git pull 실행
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class GitHandler : IActionHandler
{
public string? Prefix => "git";
public PluginMetadata Metadata => new(
"Git",
"Git 빠른 조회 — git status · log · branch · stash",
"1.0",
"AX");
// ── 서브커맨드 정의 ──────────────────────────────────────────────────────
private static readonly (string Sub, string Args, string Label, string Icon)[] SubCommands =
[
("status", "status --short", "변경 파일 목록", "\uE9F5"),
("log", "log --oneline -10", "최근 커밋 10개", "\uE81C"),
("branch", "branch -a", "브랜치 목록", "\uE8FB"),
("stash", "stash list", "Stash 목록", "\uE7C4"),
("diff", "diff --stat", "변경 통계", "\uE8A1"),
("pull", "pull", "git pull 실행", "\uE8AF"),
];
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 작업 디렉토리 결정
var workDir = FindGitRoot();
if (string.IsNullOrEmpty(q))
{
// 빠른 상태 요약
if (!string.IsNullOrEmpty(workDir))
{
var branch = await RunGitAsync("branch --show-current", workDir, ct);
var statusOut = await RunGitAsync("status --short", workDir, ct);
var changed = statusOut?.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length ?? 0;
items.Add(new LauncherItem(
$"Git: {branch?.Trim() ?? "unknown"}",
changed == 0 ? "변경 없음" : $"{changed}개 파일 변경됨 · {System.IO.Path.GetFileName(workDir)}",
null,
("status_summary", workDir),
Symbol: "\uE9F5"));
}
else
{
items.Add(new LauncherItem("Git 저장소 없음",
"현재 작업 폴더에 .git 디렉토리가 없습니다", null, null,
Symbol: "\uE783"));
}
// 서브커맨드 목록
foreach (var (sub, args, label, icon) in SubCommands)
{
items.Add(new LauncherItem(
$"git {sub}",
label,
null,
(sub, workDir ?? ""),
Symbol: icon));
}
return items;
}
// 서브커맨드 매칭
var matched = SubCommands
.Where(sc => sc.Sub.StartsWith(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matched.Count > 0)
{
foreach (var (sub, args, label, icon) in matched)
{
string? preview = null;
if (!string.IsNullOrEmpty(workDir))
preview = await RunGitAsync(args, workDir, ct);
var subtitle = preview != null
? TruncateLines(preview, 3)
: label;
items.Add(new LauncherItem(
$"git {sub}",
subtitle,
null,
(sub, workDir ?? "", args),
Symbol: icon));
}
}
else
{
// 자유 명령 실행 (git <query>)
string? output = null;
if (!string.IsNullOrEmpty(workDir))
output = await RunGitAsync(q, workDir, ct);
items.Add(new LauncherItem(
$"git {query.Trim()}",
output != null ? TruncateLines(output, 3) : "실행 후 결과 클립보드 복사",
null,
("custom", workDir ?? "", query.Trim()),
Symbol: "\uE9F5"));
}
return items;
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
string? result = null;
switch (item.Data)
{
// 상태 요약 항목
case ("status_summary", string workDir):
result = await RunGitAsync("status", workDir, ct);
break;
// 서브커맨드 항목 (sub, workDir, args)
case (string sub, string workDir, string args):
if (sub == "pull")
{
// pull은 별도 터미널 창으로 실행
if (!string.IsNullOrEmpty(workDir))
{
Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -Command \"cd '{workDir}'; git pull; Read-Host 'Enter '\"",
UseShellExecute = true,
});
}
return;
}
result = await RunGitAsync(args, workDir, ct);
break;
// 서브커맨드 항목 (sub, workDir) — args 없는 경우
case (string sub2, string workDir2):
var found = SubCommands.FirstOrDefault(sc => sc.Sub == sub2);
if (found != default)
result = await RunGitAsync(found.Args, workDir2, ct);
break;
default:
break;
}
if (!string.IsNullOrWhiteSpace(result))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(result));
NotificationService.Notify("Git", "결과를 클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
/// <summary>현재 앱 설정의 작업 폴더에서 .git root를 찾습니다.</summary>
private static string? FindGitRoot()
{
var app = System.Windows.Application.Current as App;
var workDir = app?.SettingsService?.Settings.Llm.WorkFolder ?? "";
if (string.IsNullOrEmpty(workDir) || !System.IO.Directory.Exists(workDir))
workDir = AppDomain.CurrentDomain.BaseDirectory;
// .git 폴더를 찾아 상위로 이동
var dir = new System.IO.DirectoryInfo(workDir);
while (dir != null)
{
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
/// <summary>git 명령을 비동기로 실행하고 출력을 반환합니다.</summary>
private static async Task<string?> RunGitAsync(string args, string workDir, CancellationToken ct)
{
if (string.IsNullOrEmpty(workDir)) return null;
try
{
var psi = new ProcessStartInfo("git", args)
{
WorkingDirectory = workDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var output = await proc.StandardOutput.ReadToEndAsync(ct);
var error = await proc.StandardError.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
var text = output.Trim();
if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(error))
text = error.Trim();
return string.IsNullOrWhiteSpace(text) ? "(출력 없음)" : text;
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
return $"오류: {ex.Message}";
}
}
/// <summary>긴 출력을 maxLines줄로 자릅니다.</summary>
private static string TruncateLines(string text, int maxLines)
{
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length <= maxLines) return string.Join(" · ", lines.Take(maxLines)).Trim();
return string.Join(" · ", lines.Take(maxLines)) + $" … (+{lines.Length - maxLines}줄)";
}
}