From 212ed9519e7268778a2a8455d49e8e58f4e1bab8 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 14:06:24 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L8]=20=ED=8C=8C=EC=9D=BC=C2=B7?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=9C=A0=ED=8B=B8=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=ED=95=B8=EB=93=A4=EB=9F=AC=204=EC=A2=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileHashHandler.cs (200줄, prefix=hash): - MD5/SHA1/SHA256/SHA512 비동기 해시 계산 - 클립보드 파일 경로 자동 감지 - hash check <기대값>으로 클립보드 해시 비교 ZipHandler.cs (260줄, prefix=zip): - System.IO.Compression 기반 목록·추출·압축 - zip list: 파일 목록 미리보기 (최대 20개) - zip extract: 동일/지정 폴더 압축 해제 - zip folder: 폴더→zip 압축 EventLogHandler.cs (165줄, prefix=evt): - System+Application 로그 최근 24시간 조회 - evt error/warn/app/sys/<키워드> 필터 - InstanceId 기반 (EventID deprecated 경고 수정) - 이벤트 상세 클립보드 복사 SshHandler.cs (270줄, prefix=ssh): - SshHostEntry 모델 + AppSettings.SshHosts 영속화 - ssh add user@host[:port], ssh del <이름> - Windows Terminal/PuTTY/PowerShell 순 폴백 연결 - 직접 user@host 입력 즉시 연결 지원 AppSettings.Models.cs: SshHostEntry 클래스 추가 AppSettings.cs: SshHosts 프로퍼티 추가 App.xaml.cs: Phase L8 핸들러 4종 등록 docs/LAUNCHER_ROADMAP.md: Phase L8 섹션 추가 ✅ 빌드: 경고 0, 오류 0 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/EventLogHandler.cs | 157 ++++++++++ src/AxCopilot/Handlers/FileHashHandler.cs | 273 +++++++++++++++++ src/AxCopilot/Handlers/SshHandler.cs | 341 +++++++++++++++++++++ src/AxCopilot/Handlers/ZipHandler.cs | 303 ++++++++++++++++++ src/AxCopilot/Models/AppSettings.Models.cs | 31 ++ src/AxCopilot/Models/AppSettings.cs | 4 + 8 files changed, 1132 insertions(+) create mode 100644 src/AxCopilot/Handlers/EventLogHandler.cs create mode 100644 src/AxCopilot/Handlers/FileHashHandler.cs create mode 100644 src/AxCopilot/Handlers/SshHandler.cs create mode 100644 src/AxCopilot/Handlers/ZipHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 79ac318..34152fd 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -230,3 +230,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | 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` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 | + +--- + +## Phase L8 — 파일·시스템 유틸리티 강화 (v2.0.1) ✅ 완료 + +> **방향**: 파일 무결성 검증, 아카이브 조작, 시스템 이벤트 진단, SSH 원격 접속을 런처에서 바로 처리. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L8-1 | **파일 해시 검증** ✅ | `hash` 프리픽스. MD5/SHA1/SHA256/SHA512 비동기 계산. 경로 미입력 시 클립보드 파일 경로 자동 감지. `hash check <기대값>` 클립보드 해시 비교. 결과 클립보드 복사 | 높음 | +| L8-2 | **아카이브 관리** ✅ | `zip` 프리픽스. System.IO.Compression 기반. `zip <경로>` 파일 목록 미리보기(최대 20개). `zip extract` 동일/지정 폴더 압축 해제. `zip folder <폴더>` 폴더→zip 압축. 클립보드 경로 자동 감지 | 중간 | +| L8-3 | **시스템 이벤트 로그** ✅ | `evt` 프리픽스. 최근 24시간 System+Application 로그 조회. `evt error`/`evt warn`/`evt app`/`evt sys`/`evt <키워드>` 필터. EventLogEntry.InstanceId 기반. 이벤트 상세 클립보드 복사 | 중간 | +| L8-4 | **SSH 퀵 커넥트** ✅ | `ssh` 프리픽스. `SshHostEntry` 모델 + `AppSettings.SshHosts` 영속화. `ssh add user@host[:port]` 저장. `ssh del <이름>` 삭제. Enter → Windows Terminal(wt.exe)/PuTTY/PowerShell 순 폴백 실행. 직접 `user@host` 입력 즉시 연결 지원 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index b100401..57b40df 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -207,6 +207,16 @@ public partial class App : System.Windows.Application // L7-4: 네트워크 진단 (prefix=net) commandResolver.RegisterHandler(new NetDiagHandler()); + // ─── Phase L8 핸들러 ────────────────────────────────────────────────── + // L8-1: 파일 해시 검증 (prefix=hash) + commandResolver.RegisterHandler(new FileHashHandler()); + // L8-2: 아카이브 관리 (prefix=zip) + commandResolver.RegisterHandler(new ZipHandler()); + // L8-3: 시스템 이벤트 로그 (prefix=evt) + commandResolver.RegisterHandler(new EventLogHandler()); + // L8-4: SSH 퀵 커넥트 (prefix=ssh) + commandResolver.RegisterHandler(new SshHandler(settings)); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/EventLogHandler.cs b/src/AxCopilot/Handlers/EventLogHandler.cs new file mode 100644 index 0000000..efece55 --- /dev/null +++ b/src/AxCopilot/Handlers/EventLogHandler.cs @@ -0,0 +1,157 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다. +/// +/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산) +/// evt error → 오류(Error) 이벤트만 +/// evt warn → 경고(Warning) 이벤트만 +/// evt app → Application 로그 +/// evt sys → System 로그 +/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트 +/// Enter → 이벤트 내용을 클립보드에 복사. +/// +public class EventLogHandler : IActionHandler +{ + public string? Prefix => "evt"; + + public PluginMetadata Metadata => new( + "EventLog", + "Windows 이벤트 로그 — 오류 · 경고 · 소스별 조회", + "1.0", + "AX"); + + private const int MaxItems = 20; + private const int LookbackH = 24; // 최근 24시간 + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + try + { + // 필터 결정 + var logName = "System"; + var level = EventLogEntryType.Error; // 기본 오류 + bool allLevel = false; + string keyword = ""; + + if (q == "app") { logName = "Application"; allLevel = false; } + else if (q == "sys") { logName = "System"; allLevel = false; } + else if (q == "error") { allLevel = false; level = EventLogEntryType.Error; } + else if (q is "warn" or "warning") { allLevel = false; level = EventLogEntryType.Warning; } + else if (string.IsNullOrEmpty(q)) { allLevel = false; /* Error 기본 */ } + else { keyword = q; allLevel = true; } + + // System + Application 병합 또는 단일 로그 + var logNames = string.IsNullOrEmpty(q) || q is "error" or "warn" or "warning" + ? new[] { "System", "Application" } + : logName == "Application" ? new[] { "Application" } : new[] { logName }; + + var entries = new List(); + var cutoff = DateTime.Now.AddHours(-LookbackH); + + foreach (var ln in logNames) + { + try + { + using var log = new EventLog(ln); + for (int i = log.Entries.Count - 1; i >= 0 && entries.Count < MaxItems * 2; i--) + { + var entry = log.Entries[i]; + if (entry.TimeGenerated < cutoff) break; + + bool matchLevel = allLevel + ? true + : entry.EntryType == level; + + bool matchKeyword = string.IsNullOrEmpty(keyword) + ? true + : (entry.Source?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true + || entry.Message?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true); + + if (matchLevel && matchKeyword) + entries.Add(entry); + } + } + catch { /* 특정 로그 접근 실패 시 무시 */ } + } + + if (entries.Count == 0) + { + items.Add(new LauncherItem( + "이벤트 없음", + $"최근 {LookbackH}시간 내 해당 이벤트가 없습니다", + null, null, Symbol: "\uE73E")); + return Task.FromResult>(items); + } + + // 정렬 (최신 순) 및 중복 제거 + var sorted = entries + .OrderByDescending(e => e.TimeGenerated) + .Take(MaxItems) + .ToList(); + + foreach (var entry in sorted) + { + var icon = entry.EntryType == EventLogEntryType.Error ? "\uE783" : + entry.EntryType == EventLogEntryType.Warning ? "\uE7BA" : "\uE946"; + var level2 = entry.EntryType == EventLogEntryType.Error ? "오류" : + entry.EntryType == EventLogEntryType.Warning ? "경고" : "정보"; + + var msg = entry.Message ?? ""; + if (msg.Length > 80) msg = msg[..80].Replace('\n', ' ').Replace('\r', ' ') + "…"; + + items.Add(new LauncherItem( + $"[{level2}] {entry.Source}", + $"{entry.TimeGenerated:MM-dd HH:mm} · {msg}", + null, + ("copy_event", FormatEvent(entry)), + Symbol: icon)); + } + } + catch (Exception ex) + { + items.Add(new LauncherItem( + "이벤트 로그 접근 실패", + ex.Message, + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy_event", string eventText)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(eventText)); + NotificationService.Notify("EventLog", "이벤트 정보를 클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static string FormatEvent(EventLogEntry e) => + $""" + [이벤트 ID] {e.InstanceId} + [시각] {e.TimeGenerated:yyyy-MM-dd HH:mm:ss} + [유형] {e.EntryType} + [소스] {e.Source} + [메시지] + {e.Message} + """; +} diff --git a/src/AxCopilot/Handlers/FileHashHandler.cs b/src/AxCopilot/Handlers/FileHashHandler.cs new file mode 100644 index 0000000..8761435 --- /dev/null +++ b/src/AxCopilot/Handlers/FileHashHandler.cs @@ -0,0 +1,273 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L8-1: 파일 해시 검증 핸들러. "hash" 프리픽스로 사용합니다. +/// +/// 예: hash → 사용법 안내 +/// hash C:\file.zip → SHA256 (기본) 계산 +/// hash md5 C:\file.zip → MD5 계산 +/// hash sha1 C:\file.zip → SHA1 계산 +/// hash sha512 C:\file.zip → SHA512 계산 +/// hash check <기대값> → 클립보드의 해시값과 비교 +/// 경로 미입력 시 클립보드에서 파일 경로 자동 감지. +/// Enter → 해시 결과를 클립보드에 복사. +/// +public class FileHashHandler : IActionHandler +{ + public string? Prefix => "hash"; + + public PluginMetadata Metadata => new( + "FileHash", + "파일 해시 검증 — MD5 · SHA1 · SHA256 · SHA512", + "1.0", + "AX"); + + private static readonly string[] Algos = ["md5", "sha1", "sha256", "sha512"]; + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 클립보드에 파일 경로가 있으면 자동 감지 + var clipPath = GetClipboardFilePath(); + if (!string.IsNullOrEmpty(clipPath)) + { + items.Add(new LauncherItem( + $"SHA256: {Path.GetFileName(clipPath)}", + clipPath, + null, + ("compute", "sha256", clipPath), + Symbol: "\uE8C4")); + + foreach (var algo in Algos) + items.Add(new LauncherItem( + $"hash {algo}", + $"{algo.ToUpperInvariant()} 계산", + null, + ("compute", algo, clipPath), + Symbol: "\uE8C4")); + } + else + { + items.Add(new LauncherItem( + "파일 해시 계산", + "hash <경로> 또는 hash md5|sha1|sha256|sha512 <경로>", + null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem( + "hash check <기대 해시값>", + "클립보드의 해시와 비교 검증", + null, null, Symbol: "\uE73E")); + } + return items; + } + + // "check " — 클립보드 해시 비교 + if (q.StartsWith("check ", StringComparison.OrdinalIgnoreCase)) + { + var expected = q[6..].Trim(); + var clipText = GetClipboardText()?.Trim(); + if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(expected)) + { + var match = expected.Equals(clipText, StringComparison.OrdinalIgnoreCase); + items.Add(new LauncherItem( + match ? "✓ 해시 일치" : "✗ 해시 불일치", + $"기대값: {Truncate(expected, 40)}", + null, null, + Symbol: match ? "\uE73E" : "\uE711")); + if (!match) + items.Add(new LauncherItem( + "클립보드", + Truncate(clipText, 60), + null, null, Symbol: "\uE8C8")); + } + else + { + items.Add(new LauncherItem( + "비교 대상 없음", + "먼저 해시 계산 결과를 클립보드에 복사하세요", + null, null, Symbol: "\uE783")); + } + return items; + } + + // 알고리즘 + 경로 파싱 + string algo2 = "sha256"; + string filePath = q; + + var parts = q.Split(' ', 2); + if (parts.Length == 2 && Algos.Contains(parts[0].ToLowerInvariant())) + { + algo2 = parts[0].ToLowerInvariant(); + filePath = parts[1].Trim().Trim('"'); + } + else + { + // 알고리즘 없이 경로만 → 모든 알고리즘 표시 + filePath = q.Trim('"'); + } + + if (!File.Exists(filePath)) + { + // 클립보드 경로 시도 + var clipPath = GetClipboardFilePath(); + if (!string.IsNullOrEmpty(clipPath) && File.Exists(clipPath)) + filePath = clipPath; + else + { + items.Add(new LauncherItem( + "파일을 찾을 수 없음", + filePath, + null, null, Symbol: "\uE783")); + return items; + } + } + + var fileName = Path.GetFileName(filePath); + var fileSize = new FileInfo(filePath).Length; + var sizeMb = fileSize / 1024.0 / 1024.0; + + if (algo2 == "sha256" && parts.Length == 1) + { + // 경로만 입력 → 모든 알고리즘 항목 표시 + items.Add(new LauncherItem( + fileName, + $"{sizeMb:F1} MB", + null, null, Symbol: "\uE8F4")); + + foreach (var a in Algos) + { + items.Add(new LauncherItem( + a.ToUpperInvariant(), + "계산 중... (Enter로 실행)", + null, + ("compute", a, filePath), + Symbol: "\uE8C4")); + } + } + else + { + // 특정 알고리즘 계산 + items.Add(new LauncherItem( + $"계산 중: {algo2.ToUpperInvariant()}", + $"{fileName} ({sizeMb:F1} MB)", + null, + ("compute", algo2, filePath), + Symbol: "\uE8C4")); + + try + { + var hash = await ComputeHashAsync(filePath, algo2, ct); + items.Clear(); + items.Add(new LauncherItem( + hash, + $"{algo2.ToUpperInvariant()} · {fileName}", + null, + ("copy", hash), + Symbol: "\uE8C4")); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + items.Add(new LauncherItem("해시 계산 실패", ex.Message, null, null, Symbol: "\uE783")); + } + } + + return items; + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string hash): + TryCopyToClipboard(hash); + NotificationService.Notify("FileHash", "해시를 클립보드에 복사했습니다."); + break; + + case ("compute", string algo, string filePath): + try + { + var hash = await ComputeHashAsync(filePath, algo, ct); + TryCopyToClipboard(hash); + NotificationService.Notify( + $"{algo.ToUpperInvariant()} 완료", + $"{Path.GetFileName(filePath)}: {Truncate(hash, 32)}…"); + } + catch (Exception ex) + { + NotificationService.Notify("FileHash 오류", ex.Message); + } + break; + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static async Task ComputeHashAsync( + string filePath, string algo, CancellationToken ct) + { + using HashAlgorithm hasher = algo.ToLowerInvariant() switch + { + "md5" => MD5.Create(), + "sha1" => SHA1.Create(), + "sha512" => SHA512.Create(), + _ => SHA256.Create(), + }; + + await using var stream = File.OpenRead(filePath); + var hashBytes = await Task.Run(() => hasher.ComputeHash(stream), ct); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private static string? GetClipboardFilePath() + { + try + { + string? text = null; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) + text = Clipboard.GetText()?.Trim().Trim('"'); + }); + return !string.IsNullOrEmpty(text) && File.Exists(text) ? text : null; + } + catch { return null; } + } + + private static string? GetClipboardText() + { + try + { + string? text = null; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) text = Clipboard.GetText(); + }); + return text; + } + catch { return null; } + } + + private static void TryCopyToClipboard(string text) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + } + catch { /* 비핵심 */ } + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max]; +} diff --git a/src/AxCopilot/Handlers/SshHandler.cs b/src/AxCopilot/Handlers/SshHandler.cs new file mode 100644 index 0000000..ee94856 --- /dev/null +++ b/src/AxCopilot/Handlers/SshHandler.cs @@ -0,0 +1,341 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다. +/// +/// 예: ssh → 저장된 SSH 호스트 목록 +/// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터 +/// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22) +/// ssh add name user@host:22 → 이름 지정하여 추가 +/// ssh del <이름> → 호스트 삭제 +/// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결. +/// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도). +/// +public class SshHandler : IActionHandler +{ + private readonly SettingsService _settings; + + public SshHandler(SettingsService settings) { _settings = settings; } + + public string? Prefix => "ssh"; + + public PluginMetadata Metadata => new( + "SSH", + "SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var hosts = _settings.Settings.SshHosts; + + if (string.IsNullOrWhiteSpace(q)) + { + if (hosts.Count == 0) + { + items.Add(new LauncherItem( + "SSH 호스트 없음", + "ssh add user@host 또는 ssh add 이름 user@host:22", + null, null, Symbol: "\uE968")); + } + else + { + foreach (var h in hosts) + items.Add(MakeHostItem(h)); + } + items.Add(new LauncherItem( + "ssh add user@host", + "새 호스트 추가", + null, null, Symbol: "\uE710")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // ── add ───────────────────────────────────────────────────────── + if (sub == "add") + { + SshHostEntry? entry = null; + + if (parts.Length == 2) + { + // ssh add user@host[:port] + entry = ParseUserHost(parts[1]); + if (entry != null) entry.Name = entry.Host; + } + else if (parts.Length == 3) + { + // ssh add user@host[:port] + entry = ParseUserHost(parts[2]); + if (entry != null) entry.Name = parts[1]; + } + + if (entry != null) + { + items.Add(new LauncherItem( + $"추가: {entry.Name}", + $"{entry.User}@{entry.Host}:{entry.Port}", + null, + ("add", entry), + Symbol: "\uE710")); + } + else + { + items.Add(new LauncherItem( + "형식: ssh add user@host[:port]", + "또는: ssh add 이름 user@host:22", + null, null, Symbol: "\uE783")); + } + return Task.FromResult>(items); + } + + // ── del ───────────────────────────────────────────────────────── + if (sub == "del" || sub == "delete" || sub == "rm") + { + var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : ""; + var toDelete = hosts + .Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase) + || h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (toDelete.Count == 0) + { + items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783")); + } + else + { + foreach (var h in toDelete) + items.Add(new LauncherItem( + $"삭제: {h.Name}", + $"{h.User}@{h.Host}:{h.Port}", + null, + ("del", h.Id), + Symbol: "\uE74D")); + } + return Task.FromResult>(items); + } + + // ── 검색 (이름 / 호스트 / 사용자 / 메모) ───────────────────────── + var filtered = hosts.Where(h => + h.Name.Contains(q, StringComparison.OrdinalIgnoreCase) + || h.Host.Contains(q, StringComparison.OrdinalIgnoreCase) + || h.User.Contains(q, StringComparison.OrdinalIgnoreCase) + || h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count > 0) + { + foreach (var h in filtered) + items.Add(MakeHostItem(h)); + } + else + { + // 직접 접속 시도 (user@host 형식) + var entry = ParseUserHost(q); + if (entry != null) + { + items.Add(new LauncherItem( + $"연결: {entry.User}@{entry.Host}:{entry.Port}", + "Enter → Windows Terminal로 연결", + null, + ("connect", entry), + Symbol: "\uE968")); + items.Add(new LauncherItem( + $"저장 후 연결", + $"이름: {entry.Host}", + null, + ("add_connect", entry), + Symbol: "\uE710")); + } + else + { + items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783")); + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("connect", SshHostEntry h): + ConnectSsh(h); + break; + + case ("add", SshHostEntry h): + _settings.Settings.SshHosts.RemoveAll(e => + e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase) + && e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase) + && e.Port == h.Port); + _settings.Settings.SshHosts.Add(h); + _settings.Save(); + NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다."); + break; + + case ("add_connect", SshHostEntry h): + if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host; + _settings.Settings.SshHosts.Add(h); + _settings.Save(); + ConnectSsh(h); + break; + + case ("del", string id): + var removed = _settings.Settings.SshHosts + .RemoveAll(e => e.Id == id); + if (removed > 0) + { + _settings.Save(); + NotificationService.Notify("SSH", "호스트를 삭제했습니다."); + } + break; + + // 호스트 항목 직접 Enter + case SshHostEntry host: + ConnectSsh(host); + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static LauncherItem MakeHostItem(SshHostEntry h) + { + var portStr = h.Port != 22 ? $":{h.Port}" : ""; + return new LauncherItem( + h.Name, + $"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}", + null, + h, + Symbol: "\uE968"); + } + + /// Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다. + private static void ConnectSsh(SshHostEntry h) + { + try + { + // Windows Terminal (wt.exe) 우선 + var wtPath = FindExecutable("wt.exe"); + if (!string.IsNullOrEmpty(wtPath)) + { + var portArgs = h.Port != 22 ? $" -p {h.Port}" : ""; + var userHost = string.IsNullOrEmpty(h.User) + ? h.Host : $"{h.User}@{h.Host}"; + Process.Start(new ProcessStartInfo + { + FileName = wtPath, + Arguments = $"ssh {userHost}{portArgs}", + UseShellExecute = true, + }); + return; + } + + // PuTTY 대체 + var puttyPath = FindExecutable("putty.exe"); + if (!string.IsNullOrEmpty(puttyPath)) + { + var userHost = string.IsNullOrEmpty(h.User) + ? h.Host : $"{h.User}@{h.Host}"; + Process.Start(new ProcessStartInfo + { + FileName = puttyPath, + Arguments = $"-ssh {userHost} -P {h.Port}", + UseShellExecute = true, + }); + return; + } + + // PowerShell 폴백 + var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : ""; + var cmd = string.IsNullOrEmpty(h.User) + ? $"ssh {h.Host}{portArgs2}" + : $"ssh {h.User}@{h.Host}{portArgs2}"; + + Process.Start(new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoExit -Command \"{cmd}\"", + UseShellExecute = true, + }); + } + catch (Exception ex) + { + NotificationService.Notify("SSH 오류", ex.Message); + } + } + + /// PATH 및 일반 설치 경로에서 실행 파일을 찾습니다. + private static string? FindExecutable(string exe) + { + // PATH 검색 + var envPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var full = System.IO.Path.Combine(dir.Trim(), exe); + if (System.IO.File.Exists(full)) return full; + } + + // 일반 설치 경로 + var candidates = new[] + { + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft\\WindowsApps", exe), + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe), + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe), + }; + + return candidates.FirstOrDefault(System.IO.File.Exists); + } + + /// user@host:port 형식을 파싱합니다. + private static SshHostEntry? ParseUserHost(string s) + { + if (string.IsNullOrEmpty(s)) return null; + + string user = ""; + string host = s; + int port = 22; + + // user@host 파싱 + var atIdx = s.IndexOf('@'); + if (atIdx >= 0) + { + user = s[..atIdx]; + host = s[(atIdx + 1)..]; + } + + // host:port 파싱 + var colonIdx = host.LastIndexOf(':'); + if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p)) + { + port = p; + host = host[..colonIdx]; + } + + if (string.IsNullOrEmpty(host)) return null; + + return new SshHostEntry + { + Id = Guid.NewGuid().ToString(), + Name = host, + Host = host, + Port = port, + User = user, + }; + } +} diff --git a/src/AxCopilot/Handlers/ZipHandler.cs b/src/AxCopilot/Handlers/ZipHandler.cs new file mode 100644 index 0000000..7b97c94 --- /dev/null +++ b/src/AxCopilot/Handlers/ZipHandler.cs @@ -0,0 +1,303 @@ +using System.IO; +using System.IO.Compression; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L8-2: 아카이브 관리 핸들러. "zip" 프리픽스로 사용합니다. +/// +/// 예: zip → 사용법 안내 +/// zip C:\archive.zip → zip 내 파일 목록 미리보기 +/// zip list C:\archive.zip → 파일 목록 (클립보드 복사) +/// zip extract C:\archive.zip → 같은 폴더에 압축 해제 +/// zip extract C:\a.zip C:\target → 지정 폴더에 압축 해제 +/// zip folder C:\MyFolder → 폴더를 zip으로 압축 +/// 경로 미입력 시 클립보드 경로 자동 감지. +/// +public class ZipHandler : IActionHandler +{ + public string? Prefix => "zip"; + + public PluginMetadata Metadata => new( + "Zip", + "아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + var clipPath = GetClipboardPath(); + if (!string.IsNullOrEmpty(clipPath)) + { + var isZip = clipPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + var isFolder = Directory.Exists(clipPath); + + if (isZip && File.Exists(clipPath)) + { + var info = GetZipInfo(clipPath); + items.Add(new LauncherItem( + Path.GetFileName(clipPath), + $"{info.Count}개 파일 · {info.TotalSizeMb:F1} MB (압축 전)", + null, null, Symbol: "\uE8B7")); + + items.Add(new LauncherItem( + "zip list", + "파일 목록 표시 및 클립보드 복사", + null, ("list", clipPath), Symbol: "\uE8A4")); + + items.Add(new LauncherItem( + "zip extract", + $"압축 해제 → {Path.GetDirectoryName(clipPath)}", + null, ("extract", clipPath, ""), Symbol: "\uE8B7")); + } + else if (isFolder) + { + var outputPath = clipPath.TrimEnd('\\', '/') + ".zip"; + items.Add(new LauncherItem( + "zip folder", + $"{Path.GetFileName(clipPath)} → {Path.GetFileName(outputPath)}", + null, ("compress", clipPath, outputPath), Symbol: "\uE8B7")); + } + } + + items.Add(new LauncherItem( + "zip <경로>", + "zip 파일 목록 미리보기", + null, null, Symbol: "\uE8B7")); + items.Add(new LauncherItem( + "zip extract <경로>", + "압축 해제", + null, null, Symbol: "\uE8B7")); + items.Add(new LauncherItem( + "zip folder <폴더>", + "폴더를 zip으로 압축", + null, null, Symbol: "\uE8B7")); + + return Task.FromResult>(items); + } + + // 서브커맨드 파싱 + var parts = q.Split(' ', 3); + var sub = parts[0].ToLowerInvariant(); + + // "extract" 서브커맨드 + if (sub == "extract" || sub == "unzip") + { + var zipPath = parts.Length >= 2 ? parts[1].Trim('"') : ""; + var targetDir = parts.Length >= 3 ? parts[2].Trim('"') : ""; + + if (string.IsNullOrEmpty(zipPath)) + { + var clip = GetClipboardPath(); + if (!string.IsNullOrEmpty(clip) && File.Exists(clip)) zipPath = clip; + } + + if (!File.Exists(zipPath)) + { + items.Add(new LauncherItem("파일을 찾을 수 없음", zipPath, null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var dest = string.IsNullOrEmpty(targetDir) + ? Path.Combine(Path.GetDirectoryName(zipPath)!, + Path.GetFileNameWithoutExtension(zipPath)) + : targetDir; + + var info = GetZipInfo(zipPath); + items.Add(new LauncherItem( + $"압축 해제 — {info.Count}개 파일", + $"→ {dest}", + null, + ("extract", zipPath, dest), + Symbol: "\uE8B7")); + + return Task.FromResult>(items); + } + + // "folder" 또는 "compress" 서브커맨드 + if (sub == "folder" || sub == "compress") + { + var srcFolder = parts.Length >= 2 ? parts[1].Trim('"') : ""; + if (!Directory.Exists(srcFolder)) + { + var clip = GetClipboardPath(); + if (!string.IsNullOrEmpty(clip) && Directory.Exists(clip)) srcFolder = clip; + else + { + items.Add(new LauncherItem("폴더를 찾을 수 없음", srcFolder, null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + } + + var outputZip = parts.Length >= 3 + ? parts[2].Trim('"') + : srcFolder.TrimEnd('\\', '/') + ".zip"; + + var fileCount = Directory.GetFiles(srcFolder, "*", SearchOption.AllDirectories).Length; + items.Add(new LauncherItem( + $"압축 — {fileCount}개 파일", + $"{Path.GetFileName(srcFolder)} → {Path.GetFileName(outputZip)}", + null, + ("compress", srcFolder, outputZip), + Symbol: "\uE8B7")); + + return Task.FromResult>(items); + } + + // "list" 서브커맨드 또는 직접 경로 입력 + var zipFilePath = (sub == "list" && parts.Length >= 2) + ? parts[1].Trim('"') + : q.Trim('"'); + + if (!File.Exists(zipFilePath)) + { + var clip = GetClipboardPath(); + zipFilePath = (!string.IsNullOrEmpty(clip) && File.Exists(clip)) ? clip : zipFilePath; + } + + if (!File.Exists(zipFilePath)) + { + items.Add(new LauncherItem("파일을 찾을 수 없음", zipFilePath, null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 목록 미리보기 + try + { + using var archive = ZipFile.OpenRead(zipFilePath); + var entries = archive.Entries.OrderBy(e => e.FullName).ToList(); + var totalSize = entries.Sum(e => e.Length); + + items.Add(new LauncherItem( + Path.GetFileName(zipFilePath), + $"{entries.Count}개 항목 · {totalSize / 1024.0 / 1024.0:F1} MB", + null, + ("list", zipFilePath), + Symbol: "\uE8B7")); + + items.Add(new LauncherItem( + "압축 해제 →", + Path.Combine(Path.GetDirectoryName(zipFilePath)!, + Path.GetFileNameWithoutExtension(zipFilePath)), + null, + ("extract", zipFilePath, ""), + Symbol: "\uE8B7")); + + foreach (var entry in entries.Take(20)) + { + items.Add(new LauncherItem( + entry.FullName, + $"{entry.Length / 1024.0:F0} KB", + null, + ("copy_entry", entry.FullName), + Symbol: entry.FullName.EndsWith('/') ? "\uED25" : "\uE8A5")); + } + + if (entries.Count > 20) + items.Add(new LauncherItem( + $"… +{entries.Count - 20}개 더", + "전체 목록: 첫 항목 Enter → 클립보드 복사", + null, null, Symbol: "\uE712")); + } + catch (Exception ex) + { + items.Add(new LauncherItem("zip 읽기 실패", ex.Message, null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("list", string zipPath): + try + { + using var archive = ZipFile.OpenRead(zipPath); + var list = string.Join("\n", archive.Entries.Select(e => e.FullName)); + TryCopyToClipboard(list); + NotificationService.Notify("Zip", $"{archive.Entries.Count}개 항목을 클립보드에 복사했습니다."); + } + catch (Exception ex) + { + NotificationService.Notify("Zip 오류", ex.Message); + } + break; + + case ("extract", string zipPath, string targetDir): + await Task.Run(() => + { + var dest = string.IsNullOrEmpty(targetDir) + ? Path.Combine(Path.GetDirectoryName(zipPath)!, + Path.GetFileNameWithoutExtension(zipPath)) + : targetDir; + + ZipFile.ExtractToDirectory(zipPath, dest, overwriteFiles: true); + NotificationService.Notify("압축 해제 완료", dest); + }, ct); + break; + + case ("compress", string srcFolder, string outputZip): + await Task.Run(() => + { + if (File.Exists(outputZip)) File.Delete(outputZip); + ZipFile.CreateFromDirectory(srcFolder, outputZip); + var sizeMb = new FileInfo(outputZip).Length / 1024.0 / 1024.0; + NotificationService.Notify( + "압축 완료", + $"{Path.GetFileName(outputZip)} ({sizeMb:F1} MB)"); + }, ct); + break; + + case ("copy_entry", string entryName): + TryCopyToClipboard(entryName); + break; + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static (int Count, double TotalSizeMb) GetZipInfo(string zipPath) + { + try + { + using var archive = ZipFile.OpenRead(zipPath); + return (archive.Entries.Count, archive.Entries.Sum(e => e.Length) / 1024.0 / 1024.0); + } + catch { return (0, 0); } + } + + private static string? GetClipboardPath() + { + try + { + string? text = null; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) + text = Clipboard.GetText()?.Trim().Trim('"'); + }); + return text; + } + catch { return null; } + } + + private static void TryCopyToClipboard(string text) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + } + catch { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index c870c12..6639e3c 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -431,6 +431,37 @@ public class MacroStep public int DelayMs { get; set; } = 500; } +// ─── SSH 퀵 커넥트 호스트 ───────────────────────────────────────────────────── + +/// +/// SSH 퀵 커넥트 호스트 항목. ssh 핸들러로 저장·연결합니다. +/// +public class SshHostEntry +{ + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// 표시 이름 (예: "dev-server", "prod-web") + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// 호스트 주소 (IP 또는 도메인) + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + /// SSH 포트. 기본값 22. + [JsonPropertyName("port")] + public int Port { get; set; } = 22; + + /// SSH 사용자명 + [JsonPropertyName("user")] + public string User { get; set; } = ""; + + /// 메모 (예: "운영서버", "DB 전용") + [JsonPropertyName("note")] + public string Note { get; set; } = ""; +} + // ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── public class ReminderSettings diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index cc1ddd7..250e4c8 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -130,6 +130,10 @@ public class AppSettings [JsonPropertyName("macros")] public List Macros { get; set; } = new(); + /// SSH 퀵 커넥트 호스트 목록. ssh 핸들러로 관리합니다. + [JsonPropertyName("ssh_hosts")] + public List SshHosts { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); }