[Phase L8] 파일·시스템 유틸리티 핸들러 4종 추가
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
This commit is contained in:
@@ -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-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-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` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 |
|
| 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` 입력 즉시 연결 지원 | 높음 |
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ public partial class App : System.Windows.Application
|
|||||||
// L7-4: 네트워크 진단 (prefix=net)
|
// L7-4: 네트워크 진단 (prefix=net)
|
||||||
commandResolver.RegisterHandler(new NetDiagHandler());
|
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);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
pluginHost.LoadAll();
|
pluginHost.LoadAll();
|
||||||
|
|||||||
157
src/AxCopilot/Handlers/EventLogHandler.cs
Normal file
157
src/AxCopilot/Handlers/EventLogHandler.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산)
|
||||||
|
/// evt error → 오류(Error) 이벤트만
|
||||||
|
/// evt warn → 경고(Warning) 이벤트만
|
||||||
|
/// evt app → Application 로그
|
||||||
|
/// evt sys → System 로그
|
||||||
|
/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트
|
||||||
|
/// Enter → 이벤트 내용을 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim().ToLowerInvariant();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
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<EventLogEntry>();
|
||||||
|
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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}
|
||||||
|
""";
|
||||||
|
}
|
||||||
273
src/AxCopilot/Handlers/FileHashHandler.cs
Normal file
273
src/AxCopilot/Handlers/FileHashHandler.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 → 해시 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
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 <hashValue>" — 클립보드 해시 비교
|
||||||
|
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<string> 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];
|
||||||
|
}
|
||||||
341
src/AxCopilot/Handlers/SshHandler.cs
Normal file
341
src/AxCopilot/Handlers/SshHandler.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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는 내부 서버 접속이 주 용도).
|
||||||
|
/// </summary>
|
||||||
|
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
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<IEnumerable<LauncherItem>>(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 <name> 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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>user@host:port 형식을 파싱합니다.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
303
src/AxCopilot/Handlers/ZipHandler.cs
Normal file
303
src/AxCopilot/Handlers/ZipHandler.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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으로 압축
|
||||||
|
/// 경로 미입력 시 클립보드 경로 자동 감지.
|
||||||
|
/// </summary>
|
||||||
|
public class ZipHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "zip";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Zip",
|
||||||
|
"아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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 { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -431,6 +431,37 @@ public class MacroStep
|
|||||||
public int DelayMs { get; set; } = 500;
|
public int DelayMs { get; set; } = 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SSH 퀵 커넥트 호스트 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SSH 퀵 커넥트 호스트 항목. ssh 핸들러로 저장·연결합니다.
|
||||||
|
/// </summary>
|
||||||
|
public class SshHostEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
/// <summary>표시 이름 (예: "dev-server", "prod-web")</summary>
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>호스트 주소 (IP 또는 도메인)</summary>
|
||||||
|
[JsonPropertyName("host")]
|
||||||
|
public string Host { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>SSH 포트. 기본값 22.</summary>
|
||||||
|
[JsonPropertyName("port")]
|
||||||
|
public int Port { get; set; } = 22;
|
||||||
|
|
||||||
|
/// <summary>SSH 사용자명</summary>
|
||||||
|
[JsonPropertyName("user")]
|
||||||
|
public string User { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>메모 (예: "운영서버", "DB 전용")</summary>
|
||||||
|
[JsonPropertyName("note")]
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
|
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class ReminderSettings
|
public class ReminderSettings
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ public class AppSettings
|
|||||||
[JsonPropertyName("macros")]
|
[JsonPropertyName("macros")]
|
||||||
public List<MacroEntry> Macros { get; set; } = new();
|
public List<MacroEntry> Macros { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>SSH 퀵 커넥트 호스트 목록. ssh 핸들러로 관리합니다.</summary>
|
||||||
|
[JsonPropertyName("ssh_hosts")]
|
||||||
|
public List<SshHostEntry> SshHosts { get; set; } = new();
|
||||||
|
|
||||||
[JsonPropertyName("llm")]
|
[JsonPropertyName("llm")]
|
||||||
public LlmSettings Llm { get; set; } = new();
|
public LlmSettings Llm { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user