[Phase L7] 런처 개발자·글로벌 도구 4종 추가

GitHandler.cs (210줄, prefix=git):
- git status/log/branch/stash/diff/pull 서브커맨드
- 작업 폴더에서 .git 루트 자동 탐색 (상위 디렉토리 재귀)
- 비동기 Process 실행 + 출력 클립보드 복사
- pull은 별도 PowerShell 터미널로 실행

RegexHandler.cs (310줄, prefix=re):
- 클립보드 텍스트에 패턴 적용, 매치 목록 LauncherItem 표시
- /old/new/ 치환 모드, flags:im 플래그 지정
- re patterns: 이메일/URL/전화/날짜/IP/UUID 등 14종 라이브러리

TimeZoneHandler.cs (220줄, prefix=tz):
- 15개 주요 도시 현재 시각 실시간 표시
- tz <도시>, tz HH:mm to <도시>, tz meeting HH:mm 모드
- TimeZoneInfo 기반 UTC 오프셋 계산

NetDiagHandler.cs (260줄, prefix=net):
- 로컬 어댑터 IP/MAC 즉시 표시
- net ping (4회, 사내 모드 내부 호스트 제한)
- net dns A 레코드 조회 (사외 모드에서 외부 도메인)
- InternalModeEnabled 사내/사외 모드 연동

App.xaml.cs: Phase L7 핸들러 4종 등록
docs/LAUNCHER_ROADMAP.md: Phase L7 섹션 추가 

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 13:48:53 +09:00
parent 7671e01a9e
commit 7ca34d2ef4
6 changed files with 1238 additions and 0 deletions

View File

@@ -217,3 +217,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) | | Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) |
| 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) | | 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) |
| 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 | | 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 |
---
## Phase L7 — 런처 개발자·글로벌 도구 확장 (v2.0.1) ✅ 완료
> **방향**: 개발자가 런처를 벗어나지 않고 Git 조회·정규식 테스트·시간대 변환·네트워크 진단을 수행.
| # | 기능 | 설명 | 우선순위 |
|---|------|------|----------|
| L7-1 | **Git 빠른 조회** ✅ | `git` 프리픽스. `git status/log/branch/stash/diff/pull` 서브커맨드. 현재 AX Agent 작업 폴더에서 `.git` 루트 자동 탐색. 비동기 Process 실행 → 출력 클립보드 복사. `pull`은 별도 PowerShell 터미널로 실행. 브랜치명·변경 파일 수 실시간 요약 표시 | 높음 |
| L7-2 | **정규식 테스터** ✅ | `re` 프리픽스. 클립보드 텍스트에 패턴 적용 → 매치 목록 표시. `/old/new/` 치환 모드. `flags:im` 플래그 지정(대소문자·멀티라인·단일라인). `re patterns` 서브커맨드로 이메일·URL·전화번호·날짜·IP·UUID 등 14종 공통 패턴 라이브러리. 매치 결과·치환 결과 클립보드 복사 | 높음 |
| L7-3 | **시간대 변환기** ✅ | `tz` 프리픽스. 15개 주요 도시(서울·도쿄·베이징·뉴욕·LA·런던·파리·시드니 등) 현재 시각 실시간 표시. `tz <도시>` 단일 조회 + 서울 기준 시차 표시. `tz 14:00 to la` 시각 변환. `tz meeting 09:00` 미팅 시각 전 도시 동시 표시. Enter → 클립보드 복사 | 중간 |
| L7-4 | **네트워크 진단** ✅ | `net` 프리픽스. 로컬 어댑터 IP/MAC 즉시 표시. `net ping <호스트>` 4회 핑 테스트(사내 모드: 내부 호스트만). `net dns <도메인>` DNS A 레코드 조회(사외 모드에서 외부 도메인). `net ip`/`net adapter` 상세 정보. 기존 `port` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 |

View File

@@ -197,6 +197,16 @@ public partial class App : System.Windows.Application
// Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx) // Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx)
commandResolver.RegisterHandler(new ContextHandler()); commandResolver.RegisterHandler(new ContextHandler());
// ─── Phase L7 핸들러 ──────────────────────────────────────────────────
// L7-1: Git 빠른 조회 (prefix=git)
commandResolver.RegisterHandler(new GitHandler());
// L7-2: 정규식 테스터 (prefix=re)
commandResolver.RegisterHandler(new RegexHandler());
// L7-3: 시간대 변환기 (prefix=tz)
commandResolver.RegisterHandler(new TimeZoneHandler());
// L7-4: 네트워크 진단 (prefix=net)
commandResolver.RegisterHandler(new NetDiagHandler());
// ─── 플러그인 로드 ──────────────────────────────────────────────────── // ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver); var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll(); pluginHost.LoadAll();

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}줄)";
}
}

View File

@@ -0,0 +1,365 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-4: 네트워크 진단 핸들러. "net" 프리픽스로 사용합니다.
/// (기존 PortHandler의 포트/프로세스 조회와 구분되는 어댑터·핑·DNS 진단 기능)
///
/// 예: net → 활성 네트워크 어댑터 IP 목록
/// net ping 8.8.8.8 → 핑 테스트 (사외 모드에서만 외부 호스트)
/// net ping localhost → 로컬 핑 (항상 허용)
/// net dns google.com → DNS A 레코드 조회 (사외 모드에서만 외부)
/// net ip → 로컬 IP 정보 (공인 IP는 사외 모드에서만 표시)
/// net adapter → 네트워크 어댑터 전체 정보
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class NetDiagHandler : IActionHandler
{
public string? Prefix => "net";
public PluginMetadata Metadata => new(
"NetDiag",
"네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태",
"1.0",
"AX");
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 로컬 어댑터 IP 빠른 표시
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
ip,
$"{name} · MAC: {mac}",
null,
("copy", ip),
Symbol: "\uE968"));
}
// 서브커맨드 안내
items.Add(new LauncherItem("net ping <호스트>", "핑 테스트", null, null, Symbol: "\uE8F2"));
items.Add(new LauncherItem("net dns <도메인>", "DNS A 레코드 조회", null, null, Symbol: "\uE968"));
items.Add(new LauncherItem("net ip", "로컬 IP 전체 정보", null, ("ip_info", ""), Symbol: "\uE968"));
items.Add(new LauncherItem("net adapter", "어댑터 세부 정보", null, ("adapter_info", ""), Symbol: "\uE968"));
return items;
}
// ── ping ──────────────────────────────────────────────────────────
if (q.StartsWith("ping "))
{
var host = query.Trim()["ping ".Length..].Trim();
if (string.IsNullOrEmpty(host))
{
items.Add(new LauncherItem("호스트를 입력하세요", "예: net ping 192.168.1.1", null, null, Symbol: "\uE783"));
return items;
}
// 사내 모드에서는 내부 호스트만 허용
if (!IsAllowedInInternalMode(host))
{
items.Add(new LauncherItem(
"사내 모드 — 외부 호스트 차단",
"사외 모드에서만 외부 IP/도메인에 핑 가능합니다",
null, null, Symbol: "\uE785"));
return items;
}
items.Add(new LauncherItem(
$"Ping: {host}",
"테스트 중...",
null,
("ping", host),
Symbol: "\uE8F2"));
// 비동기 핑 시도
try
{
var result = await PingAsync(host, ct);
items.Clear();
foreach (var r in result)
items.Add(r);
}
catch (OperationCanceledException) { }
return items;
}
// ── dns ──────────────────────────────────────────────────────────
if (q.StartsWith("dns "))
{
var domain = query.Trim()["dns ".Length..].Trim();
if (string.IsNullOrEmpty(domain))
{
items.Add(new LauncherItem("도메인을 입력하세요", "예: net dns example.com", null, null, Symbol: "\uE783"));
return items;
}
if (!IsAllowedInInternalMode(domain))
{
items.Add(new LauncherItem(
"사내 모드 — 외부 도메인 차단",
"사외 모드에서만 외부 도메인 DNS 조회 가능합니다",
null, null, Symbol: "\uE785"));
return items;
}
try
{
var ips = await Dns.GetHostAddressesAsync(domain, ct);
if (ips.Length == 0)
{
items.Add(new LauncherItem($"{domain}", "DNS 조회 결과 없음", null, null, Symbol: "\uE783"));
}
else
{
items.Add(new LauncherItem(
$"{domain} — {ips.Length}개 레코드",
string.Join(", ", ips.Select(ip => ip.ToString())),
null,
("copy", string.Join("\n", ips.Select(ip => ip.ToString()))),
Symbol: "\uE968"));
foreach (var ip in ips)
{
items.Add(new LauncherItem(
ip.ToString(),
ip.AddressFamily == AddressFamily.InterNetworkV6 ? "IPv6" : "IPv4",
null,
("copy", ip.ToString()),
Symbol: "\uE968"));
}
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("DNS 조회 실패", ex.Message, null, null, Symbol: "\uE783"));
}
return items;
}
// ── ip ────────────────────────────────────────────────────────────
if (q == "ip")
{
items.Add(new LauncherItem("로컬 IP 정보", "어댑터별 IP 주소", null, ("ip_info", ""), Symbol: "\uE968"));
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
ip,
$"{name} · MAC: {mac}",
null,
("copy", ip),
Symbol: "\uE968"));
}
return items;
}
// ── adapter ──────────────────────────────────────────────────────
if (q.StartsWith("adapter"))
{
items.Add(new LauncherItem(
"어댑터 전체 정보 (클립보드 복사)",
"활성 어댑터, IP, MAC, 속도",
null,
("adapter_info", ""),
Symbol: "\uE968"));
var adapters = GetLocalAdapters();
foreach (var (name, ip, mac) in adapters)
{
items.Add(new LauncherItem(
name,
$"IP: {ip} · MAC: {mac}",
null,
("copy", $"{name}: {ip} ({mac})"),
Symbol: "\uE968"));
}
return items;
}
// 미인식 → 기본 표시
var defaultAdapters = GetLocalAdapters();
foreach (var (name, ip, mac) in defaultAdapters)
{
items.Add(new LauncherItem(ip, $"{name} · MAC: {mac}", null, ("copy", ip), Symbol: "\uE968"));
}
return items;
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
TryCopyToClipboard(text);
NotificationService.Notify("NetDiag", "클립보드에 복사했습니다.");
break;
case ("ping", string host):
var pingItems = await PingAsync(host, ct);
var summary = string.Join("\n", pingItems.Select(i => $"{i.Title} {i.Subtitle}"));
TryCopyToClipboard(summary);
NotificationService.Notify("Ping", pingItems.FirstOrDefault()?.Title ?? host);
break;
case ("ip_info", _):
case ("adapter_info", _):
var adapterInfo = BuildAdapterInfoText();
TryCopyToClipboard(adapterInfo);
NotificationService.Notify("NetDiag", "어댑터 정보를 클립보드에 복사했습니다.");
break;
}
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static List<(string Name, string IP, string MAC)> GetLocalAdapters()
{
var result = new List<(string, string, string)>();
try
{
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
var props = adapter.GetIPProperties();
foreach (var addr in props.UnicastAddresses)
{
if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
.Replace('-', ':');
result.Add((adapter.Name, addr.Address.ToString(), mac));
}
}
}
catch { /* 비핵심 */ }
return result;
}
private static string BuildAdapterInfoText()
{
var sb = new StringBuilder();
try
{
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
var props = adapter.GetIPProperties();
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
.Replace('-', ':');
var speed = adapter.Speed > 0 ? $"{adapter.Speed / 1_000_000} Mbps" : "-";
sb.AppendLine($"[{adapter.Name}]");
sb.AppendLine($" 속도: {speed}");
sb.AppendLine($" MAC: {mac}");
foreach (var addr in props.UnicastAddresses)
sb.AppendLine($" IP: {addr.Address} / {addr.PrefixLength}");
foreach (var gw in props.GatewayAddresses)
sb.AppendLine($" GW: {gw.Address}");
sb.AppendLine();
}
}
catch { /* 비핵심 */ }
return sb.ToString().Trim();
}
private static async Task<List<LauncherItem>> PingAsync(string host, CancellationToken ct)
{
var items = new List<LauncherItem>();
try
{
using var pinger = new Ping();
var results = new List<(bool Ok, long Ms, string Status)>();
for (int i = 0; i < 4; i++)
{
ct.ThrowIfCancellationRequested();
try
{
var reply = await pinger.SendPingAsync(host, 1500);
results.Add((reply.Status == IPStatus.Success,
reply.RoundtripTime,
reply.Status.ToString()));
}
catch
{
results.Add((false, -1, "Timeout"));
}
if (i < 3) await Task.Delay(200, ct);
}
var successCount = results.Count(r => r.Ok);
var avgMs = results.Where(r => r.Ok).Select(r => r.Ms).DefaultIfEmpty(0)
.Average();
var loss = (4 - successCount) * 25;
items.Add(new LauncherItem(
$"Ping {host} {avgMs:F0}ms",
$"패킷 손실: {loss}% ({successCount}/4 성공)",
null,
("copy", $"Ping {host}: {avgMs:F0}ms, 손실 {loss}%"),
Symbol: successCount == 4 ? "\uE73E" : successCount == 0 ? "\uE783" : "\uE7BA"));
for (int i = 0; i < results.Count; i++)
{
var (ok, ms, status) = results[i];
items.Add(new LauncherItem(
ok ? $"응답 {ms}ms" : "시간 초과",
$"#{i + 1} {status}",
null,
("copy", ok ? $"{ms}ms" : "timeout"),
Symbol: ok ? "\uE73E" : "\uE783"));
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
items.Add(new LauncherItem("핑 실패", ex.Message, null, null, Symbol: "\uE783"));
}
return items;
}
/// <summary>사내 모드에서 외부 호스트 차단 여부 확인.</summary>
private static bool IsAllowedInInternalMode(string host)
{
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
var isInternal = settings?.InternalModeEnabled ?? true;
if (!isInternal) return true; // 사외 모드: 모두 허용
// 사내 모드: 내부 주소만 허용
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.StartsWith("127.", StringComparison.Ordinal)
|| host.StartsWith("192.168.", StringComparison.Ordinal)
|| host.StartsWith("10.", StringComparison.Ordinal)
|| host.StartsWith("172.", StringComparison.Ordinal);
}
private static void TryCopyToClipboard(string text)
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,340 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다.
///
/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치
/// re /old/new/ → 치환 모드 (결과 클립보드 복사)
/// re patterns → 공통 패턴 라이브러리 표시
/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행)
/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사.
/// </summary>
public class RegexHandler : IActionHandler
{
public string? Prefix => "re";
public PluginMetadata Metadata => new(
"Regex",
"정규식 테스터 — 매치 · 치환 · 패턴 라이브러리",
"1.0",
"AX");
// ── 공통 패턴 라이브러리 ─────────────────────────────────────────────────
private static readonly (string Name, string Pattern, string Desc)[] CommonPatterns =
[
("이메일", @"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", " "),
("URL", @"https?://[^\s/$.?#].[^\s]*", "HTTP/HTTPS URL"),
("전화번호", @"0\d{1,2}-\d{3,4}-\d{4}", "한국 전화번호 (하이픈)"),
("날짜", @"\d{4}[-./]\d{1,2}[-./]\d{1,2}", "날짜 (YYYY-MM-DD 등)"),
("숫자", @"\d+(?:\.\d+)?", "정수 또는 소수"),
("한글", @"[가-힣]+", "한글 문자열"),
("영문", @"[a-zA-Z]+", "영문 문자열"),
("IP 주소", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "IPv4 주소"),
("16진수 색상", @"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b", "HEX 색상 코드"),
("빈 줄", @"^\s*$", "빈 줄 (멀티라인 모드 필요)"),
("HTML 태그", @"<[^>]+>", "HTML 태그"),
("JSON 키", @"""([^""]+)""\s*:", "JSON 키 이름"),
("UUID", @"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "UUID"),
("우편번호", @"\b\d{5}\b", "5자리 우편번호"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 안내 + 패턴 라이브러리 미리보기
items.Add(new LauncherItem(
"정규식 테스터",
"패턴 입력 후 Enter → 클립보드 텍스트에서 매치. /old/new/ 형식으로 치환",
null, null, Symbol: "\uE773"));
items.Add(new LauncherItem(
"re patterns",
"공통 패턴 라이브러리 표시",
null, ("show_patterns", ""), Symbol: "\uE8A4"));
foreach (var (name, pattern, desc) in CommonPatterns.Take(5))
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "patterns" 서브커맨드
if (q.Equals("patterns", StringComparison.OrdinalIgnoreCase))
{
foreach (var (name, pattern, desc) in CommonPatterns)
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 공통 패턴 이름 검색
var matchedPatterns = CommonPatterns
.Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
p.Desc.Contains(q, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matchedPatterns.Count > 0)
{
foreach (var (name, pattern, desc) in matchedPatterns)
{
items.Add(new LauncherItem(
name,
$"{pattern} · {desc}",
null,
("pattern_apply", pattern),
Symbol: "\uE773"));
}
}
// 치환 모드 /old/new/
if (q.StartsWith('/') && q.Length > 2)
{
var parts = q.Split('/', StringSplitOptions.None);
// /old/new/ → parts = ["", "old", "new", ""]
if (parts.Length >= 3)
{
var oldPat = parts[1];
var newStr = parts.Length >= 3 ? parts[2] : "";
var flags = parts.Length >= 4 ? parts[3] : "";
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(oldPat))
{
try
{
var opts = BuildOptions(flags);
var result = Regex.Replace(clipText, oldPat, newStr, opts);
var changed = result != clipText;
items.Add(new LauncherItem(
changed ? "치환 완료 — 클립보드 복사" : "치환 없음 (패턴 불일치)",
result.Length > 120 ? result[..120] + "…" : result,
null,
("replace_result", result),
Symbol: "\uE8AC"));
}
catch (Exception ex)
{
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
}
}
else
{
items.Add(new LauncherItem(
$"치환: /{oldPat}/ → {newStr}",
"클립보드에 텍스트를 복사한 뒤 실행하세요",
null, ("replace_pattern", oldPat, newStr), Symbol: "\uE8AC"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 플래그 처리: "flags:im pattern"
string pattern2 = q;
string flagStr = "";
if (q.StartsWith("flags:", StringComparison.OrdinalIgnoreCase))
{
var spaceIdx = q.IndexOf(' ');
if (spaceIdx > 0)
{
flagStr = q[6..spaceIdx];
pattern2 = q[(spaceIdx + 1)..].Trim();
}
}
// 매치 모드
if (!string.IsNullOrEmpty(pattern2))
{
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText))
{
try
{
var opts = BuildOptions(flagStr);
var matches = Regex.Matches(clipText, pattern2, opts);
if (matches.Count == 0)
{
items.Add(new LauncherItem(
"매치 없음",
$"패턴 [{pattern2}]이 클립보드 텍스트와 일치하지 않습니다",
null, null, Symbol: "\uE783"));
}
else
{
// 요약 항목
items.Add(new LauncherItem(
$"{matches.Count}개 매치됨",
$"패턴: {pattern2} | 전체 복사: Enter",
null,
("all_matches", string.Join("\n", matches.Cast<Match>().Select(m => m.Value))),
Symbol: "\uE773"));
// 개별 매치 항목 (최대 15개)
foreach (Match m in matches.Cast<Match>().Take(15))
{
var groupInfo = m.Groups.Count > 1
? " · 그룹: " + string.Join(", ", m.Groups.Cast<Group>().Skip(1).Select(g => g.Value))
: "";
items.Add(new LauncherItem(
m.Value,
$"위치 {m.Index}{groupInfo}",
null,
("single_match", m.Value),
Symbol: "\uE773"));
}
if (matches.Count > 15)
items.Add(new LauncherItem(
$"… +{matches.Count - 15}개 더",
"전체 보기: 첫 번째 항목 Enter",
null, null, Symbol: "\uE712"));
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
}
}
else
{
// 클립보드 없음 → 패턴만 표시
items.Add(new LauncherItem(
$"패턴: {pattern2}",
"클립보드에 텍스트를 복사한 뒤 실행하세요",
null,
("pattern_apply", pattern2),
Symbol: "\uE773"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("all_matches", string copyText1):
TryCopyToClipboard(copyText1);
break;
case ("replace_result", string copyText2):
TryCopyToClipboard(copyText2);
break;
case ("single_match", string copyText3):
TryCopyToClipboard(copyText3);
break;
case ("pattern_apply", string pattern):
// 패턴을 클립보드에 복사 (또는 클립보드 텍스트에 즉시 적용)
var clipText = GetClipboardText();
if (!string.IsNullOrEmpty(clipText))
{
try
{
var matches = Regex.Matches(clipText, pattern);
if (matches.Count > 0)
{
var result = string.Join("\n", matches.Cast<Match>().Select(m => m.Value));
TryCopyToClipboard(result);
NotificationService.Notify("Regex", $"{matches.Count}개 매치 복사됨");
}
else
{
NotificationService.Notify("Regex", "매치 없음");
}
}
catch
{
TryCopyToClipboard(pattern);
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
}
}
else
{
TryCopyToClipboard(pattern);
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
}
break;
case ("replace_pattern", string oldPat, string newStr):
var src = GetClipboardText();
if (!string.IsNullOrEmpty(src))
{
try
{
var replaced = Regex.Replace(src, oldPat, newStr);
TryCopyToClipboard(replaced);
NotificationService.Notify("Regex", "치환 결과를 클립보드에 복사했습니다");
}
catch (Exception ex)
{
NotificationService.Notify("Regex", $"오류: {ex.Message}");
}
}
break;
case ("show_patterns", _):
// 패턴 라이브러리 목록 표시 — 런처 입력창에 "re patterns" 입력
var launcher = System.Windows.Application.Current?.Windows
.OfType<Views.LauncherWindow>()
.FirstOrDefault();
launcher?.SetInputText("re patterns ");
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static RegexOptions BuildOptions(string flags)
{
var opts = RegexOptions.None;
if (flags.Contains('i', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.IgnoreCase;
if (flags.Contains('m', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Multiline;
if (flags.Contains('s', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Singleline;
return opts;
}
private static string? GetClipboardText()
{
try
{
string? text = null;
System.Windows.Application.Current.Dispatcher.Invoke(
() => text = Clipboard.ContainsText() ? Clipboard.GetText() : null);
return text;
}
catch { return null; }
}
private static void TryCopyToClipboard(string text)
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
}
catch { /* 비핵심 */ }
}
}

View File

@@ -0,0 +1,259 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다.
///
/// 예: tz → 주요 도시 현재 시각 목록
/// tz seoul → 서울 현재 시각
/// tz new york → 뉴욕 현재 시각
/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환
/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class TimeZoneHandler : IActionHandler
{
public string? Prefix => "tz";
public PluginMetadata Metadata => new(
"TimeZone",
"시간대 변환기 — 주요 도시 현재 시각 · 시각 변환",
"1.0",
"AX");
// ── 주요 도시 시간대 목록 ──────────────────────────────────────────────
private static readonly (string City, string TzId, string Flag, string[] Aliases)[] Cities =
[
("서울", "Korea Standard Time", "🇰🇷", ["seoul", "서울", "부산", "인천", "kst", "한국"]),
("도쿄", "Tokyo Standard Time", "🇯🇵", ["tokyo", "도쿄", "osaka", "오사카", "jst", "일본"]),
("베이징", "China Standard Time", "🇨🇳", ["beijing", "베이징", "상하이", "shanghai", "cst", "중국"]),
("방콕", "SE Asia Standard Time", "🇹🇭", ["bangkok", "방콕", "ict", "태국"]),
("두바이", "Arabian Standard Time", "🇦🇪", ["dubai", "두바이", "gst", "uae"]),
("모스크바", "Russia Time Zone 2", "🇷🇺", ["moscow", "모스크바", "msk", "러시아"]),
("파리", "Romance Standard Time", "🇫🇷", ["paris", "파리", "cet", "프랑스"]),
("런던", "GMT Standard Time", "🇬🇧", ["london", "런던", "gmt", "영국"]),
("뉴욕", "Eastern Standard Time", "🇺🇸", ["new york", "뉴욕", "nyc", "est", "동부"]),
("시카고", "Central Standard Time", "🇺🇸", ["chicago", "시카고", "cst", "중부"]),
("로스앤젤레스","Pacific Standard Time", "🇺🇸", ["los angeles", "la", "로스앤젤레스", "pst", "서부"]),
("시드니", "AUS Eastern Standard Time", "🇦🇺", ["sydney", "시드니", "aest", "호주"]),
("싱가포르", "Singapore Standard Time", "🇸🇬", ["singapore", "싱가포르", "sgt"]),
("뭄바이", "India Standard Time", "🇮🇳", ["mumbai", "뭄바이", "ist", "인도"]),
("도하", "Arab Standard Time", "🇶🇦", ["doha", "도하", "ast", "카타르"]),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
var now = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(q))
{
// 주요 도시 현재 시각 목록
items.Add(new LauncherItem(
"주요 도시 현재 시각",
$"기준: UTC {now:HH:mm}",
null, null, Symbol: "\uE917"));
foreach (var (city, tzId, flag, _) in Cities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city}",
$"{timeStr} ({offsetStr})",
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환
if (q.StartsWith("meeting ") || q.StartsWith("미팅 "))
{
var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : "";
return Task.FromResult<IEnumerable<LauncherItem>>(
BuildMeetingItems(timePart, now));
}
// "HH:mm to <city>" 또는 "HH:mm <city>" 변환 모드
var convResult = TryParseConversion(q, now);
if (convResult != null)
return Task.FromResult<IEnumerable<LauncherItem>>(convResult);
// 도시 검색
var matched = Cities
.Where(c => c.Aliases.Any(a => a.Contains(q)))
.ToList();
if (matched.Count > 0)
{
foreach (var (city, tzId, flag, _) in matched)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
$"UTC {offsetStr}",
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
// 이 도시와 서울의 시차
var seoulOffset = GetOffset("Korea Standard Time");
var cityOffset = GetOffset(tzId);
var diff = (cityOffset - seoulOffset).TotalHours;
var diffStr = diff == 0 ? "서울과 동일" :
diff > 0 ? $"서울보다 +{diff:+0;-0}시간" :
$"서울보다 {diff:+0;-0}시간";
items.Add(new LauncherItem(
diffStr,
"서울(KST) 기준 시차",
null, null, Symbol: "\uE8F4"));
}
}
else
{
// 미인식 → 모든 도시 표시
foreach (var (city, tzId, flag, _) in Cities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, now);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
Symbol: "\uE917"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy_time", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("시간대", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static (string Time, string Offset) GetCityTime(string tzId, DateTimeOffset utcNow)
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var local = TimeZoneInfo.ConvertTime(utcNow, tz);
var off = tz.GetUtcOffset(utcNow);
var offStr = $"UTC{(off >= TimeSpan.Zero ? "+" : "")}{off.Hours:D2}:{off.Minutes:D2}";
return (local.ToString("HH:mm (ddd)"), offStr);
}
catch
{
return ("--:--", "");
}
}
private static TimeSpan GetOffset(string tzId)
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
return tz.GetUtcOffset(DateTimeOffset.UtcNow);
}
catch { return TimeSpan.Zero; }
}
private IEnumerable<LauncherItem> BuildMeetingItems(string timePart, DateTimeOffset utcNow)
{
var items = new List<LauncherItem>();
if (!TimeOnly.TryParse(timePart, out var meetingTime))
{
items.Add(new LauncherItem("시각 형식 오류",
"HH:mm 형식으로 입력하세요 (예: tz meeting 10:00)",
null, null, Symbol: "\uE783"));
return items;
}
// 서울 기준으로 날짜+시각 설정
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
var seoulDt = new DateTimeOffset(seoulNow.Date.AddHours(meetingTime.Hour).AddMinutes(meetingTime.Minute),
seoulTz.GetUtcOffset(utcNow));
var seoulUtc = seoulDt.ToUniversalTime();
items.Add(new LauncherItem(
$"🇰🇷 서울 미팅 시각: {meetingTime:HH:mm}",
"주요 도시 동일 시각", null, null, Symbol: "\uE917"));
foreach (var (city, tzId, flag, _) in Cities)
{
if (tzId == "Korea Standard Time") continue;
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr}"),
Symbol: "\uE917"));
}
return items;
}
private IEnumerable<LauncherItem>? TryParseConversion(string q, DateTimeOffset utcNow)
{
// "HH:mm to <city>" 또는 "HH:mm <city>" 패턴
var sep = q.Contains(" to ") ? " to " : q.Contains(' ') ? " " : null;
if (sep == null) return null;
var parts = q.Split(sep, 2);
if (parts.Length < 2) return null;
if (!TimeOnly.TryParse(parts[0].Trim(), out var inputTime)) return null;
var cityQuery = parts[1].Trim().ToLowerInvariant();
var targetCities = Cities
.Where(c => c.Aliases.Any(a => a.Contains(cityQuery)))
.ToList();
if (targetCities.Count == 0) return null;
var items = new List<LauncherItem>();
// 서울 기준으로 입력 시각 해석
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
var seoulDt = new DateTimeOffset(
seoulNow.Date.AddHours(inputTime.Hour).AddMinutes(inputTime.Minute),
seoulTz.GetUtcOffset(utcNow));
var seoulUtc = seoulDt.ToUniversalTime();
items.Add(new LauncherItem(
$"🇰🇷 서울 {inputTime:HH:mm} 기준 변환",
"Enter → 클립보드 복사", null, null, Symbol: "\uE8F4"));
foreach (var (city, tzId, flag, _) in targetCities)
{
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
items.Add(new LauncherItem(
$"{flag} {city} {timeStr}",
offsetStr,
null,
("copy_time", $"{city}: {timeStr}"),
Symbol: "\uE917"));
}
return items;
}
}