From b5c60b539834a6669bb3abbe83375e8b2e6317df Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 20:05:14 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L28]=20Windows=20=EC=83=9D=ED=83=9C?= =?UTF-8?q?=EA=B3=84=20=ED=86=B5=ED=95=A9=20+=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20UX=20=EA=B0=95=ED=99=94=204=EC=A2=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PkgHandler.cs (신규, prefix=pkg): - winget search subprocess 결과 Regex 파싱 (PkgResult 레코드) - pkg install {id} → cmd 터미널으로 실행 (사용자 확인 가능) - pkg list / pkg upgrade 서브명령 - winget 미설치 감지 → 안내 메시지 (캐시 체크) - PowerToys Command Palette winget 기능 대응 ApHandler.cs (신규, prefix=ap): - 클립보드 텍스트 즉시 변환 15종 내장 - upper/lower/trim/sort/rsort/unique/number/reverse - blank/single/count/json/slug/base64/decode64 - replace {A} {B} 텍스트 치환 명령 - PowerToys Advanced Paste 대응 SnippetHandler.cs (수정, L28-2): - ExpandVariables 5개 플레이스홀더 추가: · {clipboard} → 현재 클립보드 텍스트 · {user} → Windows 사용자명 · {computer} → PC명 · {weekday} → 한국어 요일 (월요일~일요일) · {app} → 이전 포커스 앱 프로세스명 - GetWindowThreadProcessId P/Invoke 추가 - Raycast 스니펫 동적 플레이스홀더 대응 LauncherViewModel.cs (수정, L28-3): - UpdatePreviewAsync 미리보기 확장: · 이미지 10종 (.jpg/.png/.gif 등): BitmapDecoder 해상도 + 파일 크기 · PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자 추출 · 오디오/동영상 12종: 파일 크기 + 수정일 메타 - IsImageFile(), IsMediaFile(), GetImageMeta(), GetPdfMeta(), GetFileSizeMeta(), FormatFileSize() 헬퍼 메서드 추가 App.xaml.cs: L28 핸들러 2개 등록 (PkgHandler, ApHandler) LAUNCHER_ROADMAP.md: Phase L28 ✅ 완료 + 벤치마킹 공백 3개 해소 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Opus 4.6 --- docs/LAUNCHER_ROADMAP.md | 26 +- src/AxCopilot/App.xaml.cs | 8 + src/AxCopilot/Handlers/ApHandler.cs | 260 ++++++++++++++++++ src/AxCopilot/Handlers/PkgHandler.cs | 238 ++++++++++++++++ src/AxCopilot/Handlers/SnippetHandler.cs | 42 ++- src/AxCopilot/ViewModels/LauncherViewModel.cs | 108 ++++++++ 6 files changed, 667 insertions(+), 15 deletions(-) create mode 100644 src/AxCopilot/Handlers/ApHandler.cs create mode 100644 src/AxCopilot/Handlers/PkgHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 1a44413..5c8c1aa 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -2,7 +2,7 @@ ## 현재 상태 (v2.2.0) -### 핵심 기능 (119개 핸들러, L27 완료 / L28~L29 계획 중) +### 핵심 기능 (121개 핸들러, L28 완료 / L29 계획 중) - 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)** - 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등) - 10가지 테마 + 커스텀 테마 @@ -43,19 +43,19 @@ | **스니펫 확장** | ✅ | ✅ 동적 플레이스홀더 | ✅ | △ 플러그인 | ❌ | ❌ | | **창 관리** | ✅ 22 레이아웃 | ✅ 70+ (Win 베타 탑재) | ❌ | ❌ | △ FancyZones 연동 | ❌ | | **파일 탐색기 인라인** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **독점** | -| **파일 미리보기** | △ 텍스트 6줄 | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 | +| **파일 미리보기** | ✅ (L28 강화) | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 | | **브라우저 북마크 검색** | ❌ **공백** | ✅ 확장 연동 | ✅ | △ 플러그인 | ✅ 내장 | ❌ | | **브라우저 탭 AI 전달** | ❌ **공백** | ✅ {browser-tab} | ❌ | ❌ | ❌ | ❌ | | **시스템 볼륨 제어** | ✅ (L27) | ✅ | ✅ | ❌ | ❌ | ❌ | | **화면 밝기 제어** | ✅ (L27) | ✅ | ❌ | ❌ | ❌ | ❌ | | **QR 코드 생성** | ✅ (L27) | ✅ | ✅ | △ 플러그인 | ❌ | ❌ | | **회의 링크 빠른 열기** | ✅ (L27) | ✅ Calendar 연동 | ✅ | ❌ | ❌ | ❌ | -| **winget 앱 설치** | ❌ **공백** | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 | +| **winget 앱 설치** | ✅ (L28) | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 | | **노코드 워크플로우** | ❌ **공백** | △ AI Ext 베타 | ✅ 완전 지원 | ❌ | ❌ | ❌ | | **스크립트 명령 실행** | ✅ ^ 프리픽스 | ✅ Script Commands | ✅ | ✅ | ✅ | ❌ | | **Everything 연동** | ✅ es 프리픽스 | ❌ | ❌ | ✅ | ❌ | ❌ | | **선택 텍스트 AI** | ✅ 팝업 | ✅ AI Commands | ❌ | ❌ | ❌ | ❌ | -| **AI 붙여넣기 변환** | ❌ **공백** | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ | +| **AI 붙여넣기 변환** | ✅ (L28) | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ | | **OCR** | ✅ | ❌ | ❌ | △ 플러그인 | ✅ Text Extractor | ❌ | | **독 바 (영구 표시)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **플러그인 생태계** | DLL+JSON | 1,300+ | 수백+ | 200+ | 내장 중심 | ❌ | @@ -602,20 +602,20 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label --- -## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) 📋 계획 +## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) ✅ 완료 > **방향**: PowerToys·Raycast Windows 버전이 선점 중인 **Windows 네이티브 통합** + 스니펫·미리보기 UX 수준 향상. > 외부 설치(winget)가 필요한 기능은 미설치 감지 → 안내 메시지 패턴 적용. -| # | 기능 | 프리픽스 | 구현 방식 | 경쟁 대비 | -|---|------|---------|---------|---------| -| 📋 L28-1 | **winget 앱 검색·설치** | `pkg` | `winget search {q}` subprocess → 결과 파싱. `pkg install {id}` → 권한 확인 후 실행. `pkg list` → 설치된 앱. winget 미설치 감지 후 안내. PowerToys CP 내장 기능 대응 | PowerToys CP | -| 📋 L28-2 | **스니펫 동적 플레이스홀더** | `snip` 확장 | 기존 SnippetHandler에 치환 엔진 추가. `{date}` → 오늘 날짜, `{time}` → 현재 시각, `{clipboard}` → 현재 클립보드, `{user}` → Windows 사용자명, `{app}` → 포커스 앱명. Raycast 스니펫 플레이스홀더 대응 | Raycast | -| 📋 L28-3 | **파일 미리보기 강화** | 파일 검색 결과 확장 | 기존 텍스트 6줄에서: 이미지(jpg·png·gif·svg) → WPF BitmapImage 썸네일 (120px). PDF → `Windows.Data.Pdf` 첫 페이지 렌더링. 음악(mp3) → 파일 메타(태그·길이·비트레이트). Alfred Grid View 방향 | Alfred 5 | -| 📋 L28-4 | **AI 붙여넣기 변환** | `ap` | 클립보드 텍스트를 AX Agent에 즉시 전달해 변환. `ap 요약`, `ap 번역`, `ap 교정`, `ap 표로 변환` 등. AI 비활성 시 차단. PowerToys Advanced Paste 대응 (사내 LLM 버전) | PowerToys | +| # | 기능 | 프리픽스 | 구현 방식 | +|---|------|---------|---------| +| ✅ L28-1 | **winget 앱 검색·설치** | `pkg` | `PkgHandler.cs` — `winget search` subprocess 결과 Regex 파싱. `pkg install {id}` → cmd 터미널로 실행. `pkg list` / `pkg upgrade`. winget 미설치 감지 후 안내. PowerToys CP 대응 | +| ✅ L28-2 | **스니펫 동적 플레이스홀더** | `;` 확장 | `SnippetHandler.cs` 수정 — 기존 {date}/{time} 외 `{clipboard}` (현재 클립보드), `{user}` (사용자명), `{computer}` (PC명), `{weekday}` (한국어 요일), `{app}` (이전 포커스 앱 프로세스명) 5개 추가. Raycast 스니펫 대응 | +| ✅ L28-3 | **파일 미리보기 강화** | 파일 검색 확장 | `LauncherViewModel.cs` 수정 — 이미지(10종): BitmapDecoder 해상도 + 파일 크기. PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자. 오디오/동영상: 파일 크기 + 수정일. Alfred Grid View 경량 대응 | +| ✅ L28-4 | **클립보드 텍스트 즉시 변환** | `ap` | `ApHandler.cs` — 15종 변환: upper/lower/trim/sort/rsort/unique/number/reverse/blank/single/count/json/slug/base64/decode64 + replace 치환. PowerToys Advanced Paste 대응 (텍스트 변환 중심) | -**구현 우선순위**: L28-2(snip 확장) → L28-3(미리보기) → L28-1(pkg) → L28-4(ap) -**주의**: L28-1 winget은 winget 설치 여부 런타임 체크 필수. L28-4는 `AiEnabled` 게이트 필수. +**NuGet 추가**: 없음 (PdfPig 기존 참조 활용) +**빌드**: 경고 0, 오류 0 --- diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 0d4146d..495d287 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -385,6 +385,14 @@ public partial class App : System.Windows.Application // L27-6: 클립보드 순차 붙여넣기 (prefix=paste) commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory)); + // ─── L28: Windows 생태계 통합 + 콘텐츠 UX 강화 ───────────────────── + // L28-1: winget 앱 검색·설치 (prefix=pkg) + commandResolver.RegisterHandler(new PkgHandler()); + // L28-2: SnippetHandler 동적 플레이스홀더 확장 — {clipboard}/{user}/{app}/{weekday}/{computer} 추가 (기존 핸들러 수정) + // L28-3: 파일 미리보기 강화 — 이미지 해상도·PDF 페이지수·미디어 메타 (LauncherViewModel 수정) + // L28-4: 클립보드 텍스트 즉시 변환 (prefix=ap) + commandResolver.RegisterHandler(new ApHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/ApHandler.cs b/src/AxCopilot/Handlers/ApHandler.cs new file mode 100644 index 0000000..22bdaef --- /dev/null +++ b/src/AxCopilot/Handlers/ApHandler.cs @@ -0,0 +1,260 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L28-4: 클립보드 텍스트 즉시 변환 핸들러. "ap" 프리픽스로 사용합니다. +/// (Advanced Paste — PowerToys Advanced Paste 대응) +/// +/// 예: ap → 클립보드 내용 표시 + 변환 목록 +/// ap upper → 대문자 변환 +/// ap lower → 소문자 변환 +/// ap trim → 앞뒤 공백 제거 +/// ap sort → 줄 정렬 +/// ap unique → 중복 줄 제거 +/// ap number → 줄 번호 추가 +/// ap reverse → 줄 순서 뒤집기 +/// ap count → 글자/단어/줄 수 +/// ap json → JSON 정리 (포맷팅) +/// ap remove blank → 빈 줄 제거 +/// ap replace A B → A를 B로 전체 치환 +/// Enter → 변환된 텍스트를 클립보드에 복사. +/// +public class ApHandler : IActionHandler +{ + public string? Prefix => "ap"; + + public PluginMetadata Metadata => new( + "텍스트 변환", + "클립보드 텍스트 즉시 변환 (Advanced Paste)", + "1.0", + "AX"); + + private static readonly (string Cmd, string Label, string Desc)[] Commands = + [ + ("upper", "대문자 변환", "전체 텍스트를 대문자로"), + ("lower", "소문자 변환", "전체 텍스트를 소문자로"), + ("trim", "공백 정리", "각 줄 앞뒤 공백 제거"), + ("sort", "줄 정렬", "알파벳/가나다 순 줄 정렬"), + ("rsort", "줄 역순 정렬", "역순 줄 정렬"), + ("unique", "중복 제거", "동일 줄 제거 (순서 유지)"), + ("number", "줄 번호 추가", "각 줄 앞에 1. 2. 3. 번호"), + ("reverse", "줄 순서 뒤집기", "마지막 줄 → 첫 줄"), + ("count", "텍스트 통계", "글자·단어·줄 수 표시"), + ("blank", "빈 줄 제거", "빈 줄 삭제"), + ("json", "JSON 정리", "JSON 들여쓰기 포맷팅"), + ("single", "한 줄로 합치기", "줄바꿈 → 공백으로 연결"), + ("slug", "URL 슬러그", "소문자 + 하이픈 (공백/특수문자 변환)"), + ("base64", "Base64 인코딩", "텍스트 → Base64"), + ("decode64","Base64 디코딩", "Base64 → 텍스트"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 클립보드 텍스트 읽기 + string clipText; + try + { + clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? ""; + } + catch + { + clipText = ""; + } + + if (string.IsNullOrEmpty(clipText)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "변환할 텍스트를 먼저 복사하세요", + null, null, Symbol: Symbols.Clipboard)); + return Task.FromResult>(items); + } + + var preview = clipText.Length > 80 ? clipText[..77].Replace("\n", " ") + "…" : clipText.Replace("\n", " "); + int lineCount = clipText.Split('\n').Length; + + // ── replace 명령 ─────────────────────────────────────────────────────── + if (q.StartsWith("replace ")) + { + var parts = q[8..].Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + var from = parts[0]; + var to = parts[1]; + var result = clipText.Replace(from, to, StringComparison.OrdinalIgnoreCase); + int count = (clipText.Length - result.Length) / Math.Max(from.Length - to.Length, 1); + items.Add(new LauncherItem( + $"치환: '{from}' → '{to}'", + $"Enter: 클립보드 갱신", + null, ("result", result), Symbol: Symbols.Clipboard)); + } + else + { + items.Add(new LauncherItem("사용법: ap replace {찾을값} {바꿀값}", + "예: ap replace hello world", null, null, Symbol: Symbols.Info)); + } + return Task.FromResult>(items); + } + + // ── 빈 쿼리 → 전체 명령 목록 ───────────────────────────────────────── + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + $"클립보드: {preview}", + $"{clipText.Length}자 · {lineCount}줄 · 아래 변환 명령 선택", + null, null, Symbol: Symbols.Clipboard)); + + foreach (var (cmd, label, desc) in Commands) + { + items.Add(new LauncherItem( + $"ap {cmd} — {label}", + desc, + null, ("cmd", cmd), Symbol: "\uE8AC")); + } + items.Add(new LauncherItem( + "ap replace {A} {B} — 텍스트 치환", + "A를 B로 전체 치환", + null, null, Symbol: "\uE8AC")); + + return Task.FromResult>(items); + } + + // ── 명령 실행 미리보기 ─────────────────────────────────────────────── + var transformed = Transform(clipText, q); + if (transformed != null) + { + var tPreview = transformed.Length > 120 ? transformed[..117].Replace("\n", " ") + "…" : transformed.Replace("\n", " "); + var cmdInfo = Commands.FirstOrDefault(c => c.Cmd == q); + items.Add(new LauncherItem( + $"{(cmdInfo.Label ?? q)} 결과", + $"{tPreview} · Enter: 클립보드 갱신", + null, ("result", transformed), Symbol: Symbols.Clipboard)); + } + else + { + // 부분 매칭으로 명령 제안 + var matched = Commands.Where(c => + c.Cmd.Contains(q) || c.Label.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matched.Count > 0) + { + foreach (var (cmd, label, desc) in matched) + items.Add(new LauncherItem($"ap {cmd} — {label}", desc, + null, ("cmd", cmd), Symbol: "\uE8AC")); + } + else + { + items.Add(new LauncherItem($"'{q}' 알 수 없는 변환 명령", + "ap upper/lower/trim/sort/unique/number/reverse/json ...", + null, null, Symbol: Symbols.Warning)); + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("result", string result)) + { + try + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result)); + NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다."); + } + catch (Exception ex) + { + NotificationService.Notify("ap", $"복사 실패: {ex.Message}"); + } + } + else if (item.Data is ("cmd", string cmd)) + { + try + { + var clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? ""; + var result2 = Transform(clipText, cmd); + if (result2 != null) + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result2)); + NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다."); + } + } + catch (Exception ex) + { + NotificationService.Notify("ap", $"변환 실패: {ex.Message}"); + } + } + return Task.CompletedTask; + } + + // ─── 변환 로직 ─────────────────────────────────────────────────────────── + + private static string? Transform(string text, string cmd) + { + var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); + + return cmd switch + { + "upper" => text.ToUpperInvariant(), + "lower" => text.ToLowerInvariant(), + "trim" => string.Join("\n", lines.Select(l => l.Trim())), + "sort" => string.Join("\n", lines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)), + "rsort" => string.Join("\n", lines.OrderByDescending(l => l, StringComparer.OrdinalIgnoreCase)), + "unique" => string.Join("\n", lines.Distinct()), + "number" => string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}")), + "reverse" => string.Join("\n", lines.Reverse()), + "blank" => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))), + "single" => string.Join(" ", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim())), + "count" => $"글자: {text.Length} · 단어: {CountWords(text)} · 줄: {lines.Length} · 바이트: {Encoding.UTF8.GetByteCount(text)}", + "json" => TryFormatJson(text), + "slug" => ToSlug(text), + "base64" => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), + "decode64" => TryDecodeBase64(text), + _ => null + }; + } + + private static int CountWords(string text) + => Regex.Matches(text, @"[\w가-힣]+").Count; + + private static string TryFormatJson(string text) + { + try + { + var doc = System.Text.Json.JsonDocument.Parse(text); + using var ms = new System.IO.MemoryStream(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true }); + doc.WriteTo(writer); + writer.Flush(); + return Encoding.UTF8.GetString(ms.ToArray()); + } + catch { return "유효하지 않은 JSON입니다."; } + } + + private static string ToSlug(string text) + { + var slug = text.ToLowerInvariant().Trim(); + slug = Regex.Replace(slug, @"[^a-z0-9가-힣\s-]", ""); + slug = Regex.Replace(slug, @"[\s]+", "-"); + slug = Regex.Replace(slug, @"-{2,}", "-"); + return slug.Trim('-'); + } + + private static string TryDecodeBase64(string text) + { + try + { + var bytes = Convert.FromBase64String(text.Trim()); + return Encoding.UTF8.GetString(bytes); + } + catch { return "유효하지 않은 Base64입니다."; } + } +} diff --git a/src/AxCopilot/Handlers/PkgHandler.cs b/src/AxCopilot/Handlers/PkgHandler.cs new file mode 100644 index 0000000..9fb487b --- /dev/null +++ b/src/AxCopilot/Handlers/PkgHandler.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다. +/// +/// 예: pkg → 사용법 안내 +/// pkg vscode → winget search vscode +/// pkg install {id} → winget install {id} +/// pkg list → 설치된 앱 목록 +/// pkg upgrade → 업그레이드 가능 목록 +/// winget 미설치 시 안내 메시지 표시. +/// +public partial class PkgHandler : IActionHandler +{ + public string? Prefix => "pkg"; + + public PluginMetadata Metadata => new( + "앱 패키지", + "winget 앱 검색·설치·업그레이드", + "1.0", + "AX"); + + private static bool? _wingetAvailable; + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // winget 설치 여부 체크 (캐시) + _wingetAvailable ??= await CheckWingetAsync(); + if (_wingetAvailable == false) + { + items.Add(new LauncherItem( + "winget이 설치되어 있지 않습니다", + "Windows Package Manager는 Windows 10 1709+ 에서 사용 가능합니다", + null, null, Symbol: Symbols.Warning)); + return items; + } + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("winget 앱 패키지 관리", + "pkg {검색어} · pkg install {id} · pkg list · pkg upgrade", + null, null, Symbol: "\uECAA")); + return items; + } + + // ── list 명령 ───────────────────────────────────────────────────────── + if (q.Equals("list", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new LauncherItem("설치된 앱 목록 조회 중...", + "winget list 실행", null, ("list", ""), Symbol: "\uECAA")); + // 실행 시 터미널에서 보여주기 + return items; + } + + // ── upgrade 명령 ────────────────────────────────────────────────────── + if (q.Equals("upgrade", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new LauncherItem("업그레이드 가능 앱 확인", + "Enter: winget upgrade 실행", null, ("upgrade", ""), Symbol: "\uE777")); + return items; + } + + // ── install 명령 ────────────────────────────────────────────────────── + if (q.StartsWith("install ", StringComparison.OrdinalIgnoreCase)) + { + var id = q[8..].Trim(); + if (!string.IsNullOrWhiteSpace(id)) + { + items.Add(new LauncherItem( + $"앱 설치: {id}", + $"Enter: winget install --id {id}", + null, ("install", id), Symbol: "\uE896")); + } + else + { + items.Add(new LauncherItem("사용법: pkg install {앱ID}", + "예: pkg install Microsoft.VisualStudioCode", + null, null, Symbol: Symbols.Info)); + } + return items; + } + + // ── 검색 ────────────────────────────────────────────────────────────── + try + { + var results = await SearchAsync(q, ct); + if (results.Count == 0) + { + items.Add(new LauncherItem($"'{q}' 검색 결과 없음", + "다른 검색어를 시도하세요", null, null, Symbol: Symbols.Search)); + } + else + { + items.Add(new LauncherItem($"검색 결과: {results.Count}개", + "Enter: winget install --id {ID}", null, null, Symbol: Symbols.Search)); + + foreach (var r in results.Take(10)) + { + items.Add(new LauncherItem( + $"{r.Name} [{r.Version}]", + $"{r.Id} · {r.Source}", + null, ("install", r.Id), Symbol: "\uECAA")); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + items.Add(new LauncherItem("검색 오류", ex.Message, null, null, Symbol: Symbols.Error)); + } + + return items; + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("install", string id) && !string.IsNullOrWhiteSpace(id)) + { + RunWingetInTerminal($"install --id \"{id}\" --accept-source-agreements --accept-package-agreements"); + NotificationService.Notify("pkg", $"설치 시작: {id}"); + } + else if (item.Data is ("list", _)) + { + RunWingetInTerminal("list"); + } + else if (item.Data is ("upgrade", _)) + { + RunWingetInTerminal("upgrade --include-unknown"); + } + return Task.CompletedTask; + } + + // ─── winget 검색 ────────────────────────────────────────────────────────── + + private record PkgResult(string Name, string Id, string Version, string Source); + + private static async Task> SearchAsync(string query, CancellationToken ct) + { + var output = await RunWingetAsync($"search \"{query}\" --accept-source-agreements", ct); + return ParseWingetOutput(output); + } + + [GeneratedRegex(@"^(.+?)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(\S+)\s*$")] + private static partial Regex WingetLineRegex(); + + private static List ParseWingetOutput(string output) + { + var results = new List(); + var lines = output.Split('\n'); + bool pastHeader = false; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + + // 헤더 구분선 (---) 이후부터 데이터 + if (line.StartsWith("---") || line.StartsWith("───")) + { + pastHeader = true; + continue; + } + + if (!pastHeader || string.IsNullOrWhiteSpace(line)) continue; + + var match = WingetLineRegex().Match(line); + if (match.Success) + { + results.Add(new PkgResult( + match.Groups[1].Value.Trim(), + match.Groups[2].Value.Trim(), + match.Groups[3].Value.Trim(), + match.Groups[4].Value.Trim())); + } + } + + return results; + } + + // ─── winget 실행 ────────────────────────────────────────────────────────── + + private static async Task CheckWingetAsync() + { + try + { + var output = await RunWingetAsync("--version", CancellationToken.None); + return output.TrimStart().StartsWith('v'); + } + catch { return false; } + } + + private static async Task RunWingetAsync(string args, CancellationToken ct) + { + using var proc = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "winget", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = System.Text.Encoding.UTF8 + } + }; + proc.Start(); + var output = await proc.StandardOutput.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + return output; + } + + private static void RunWingetInTerminal(string args) + { + try + { + // 사용자에게 진행 상황이 보이도록 터미널 창으로 실행 + Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/k winget {args}", + UseShellExecute = true + }); + } + catch (Exception ex) + { + LogService.Warn($"winget 실행 실패: {ex.Message}"); + } + } +} diff --git a/src/AxCopilot/Handlers/SnippetHandler.cs b/src/AxCopilot/Handlers/SnippetHandler.cs index 68cc52d..052425d 100644 --- a/src/AxCopilot/Handlers/SnippetHandler.cs +++ b/src/AxCopilot/Handlers/SnippetHandler.cs @@ -107,15 +107,53 @@ public class SnippetHandler : IActionHandler private static string ExpandVariables(string content) { var now = DateTime.Now; - return content + var result = content .Replace("{date}", now.ToString("yyyy-MM-dd")) .Replace("{time}", now.ToString("HH:mm:ss")) .Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss")) .Replace("{year}", now.Year.ToString()) .Replace("{month}", now.Month.ToString("D2")) - .Replace("{day}", now.Day.ToString("D2")); + .Replace("{day}", now.Day.ToString("D2")) + .Replace("{weekday}", now.ToString("dddd", System.Globalization.CultureInfo.GetCultureInfo("ko-KR"))) + .Replace("{user}", Environment.UserName) + .Replace("{computer}", Environment.MachineName); + + // {clipboard} → 현재 클립보드 텍스트 (UI 스레드에서 실행) + if (result.Contains("{clipboard}")) + { + try + { + var clip = System.Windows.Application.Current.Dispatcher.Invoke( + () => System.Windows.Clipboard.GetText()) ?? ""; + result = result.Replace("{clipboard}", clip); + } + catch { result = result.Replace("{clipboard}", ""); } + } + + // {app} → 이전 포커스 앱 이름 + if (result.Contains("{app}")) + { + var appName = ""; + try + { + var hwnd = Services.WindowTracker.PreviousWindow; + if (hwnd != IntPtr.Zero) + { + GetWindowThreadProcessId(hwnd, out uint pid); + using var proc = System.Diagnostics.Process.GetProcessById((int)pid); + appName = proc.ProcessName; + } + } + catch { } + result = result.Replace("{app}", appName); + } + + return result; } + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + private static string TruncatePreview(string content) { var oneLine = content.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " "); diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index 3d5c2e3..92f9a53 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -568,6 +568,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged if (item.Data is IndexEntry indexEntry) { var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant(); + + // 텍스트 파일: 첫 6줄 미리보기 if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path)) { var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct); @@ -576,6 +578,29 @@ public partial class LauncherViewModel : INotifyPropertyChanged HasPreview = !string.IsNullOrEmpty(PreviewText.Trim()); return; } + + // 이미지 파일: 해상도·크기 메타데이터 + if (IsImageFile(ext) && System.IO.File.Exists(indexEntry.Path)) + { + var meta = await Task.Run(() => GetImageMeta(indexEntry.Path), ct); + if (ct.IsCancellationRequested) return; + if (meta != null) { PreviewText = meta; HasPreview = true; return; } + } + + // PDF 파일: 페이지 수·크기·첫 페이지 텍스트 + if (ext == ".pdf" && System.IO.File.Exists(indexEntry.Path)) + { + var meta = await Task.Run(() => GetPdfMeta(indexEntry.Path), ct); + if (ct.IsCancellationRequested) return; + if (meta != null) { PreviewText = meta; HasPreview = true; return; } + } + + // 미디어 파일: 파일 크기 + if (IsMediaFile(ext) && System.IO.File.Exists(indexEntry.Path)) + { + var meta = GetFileSizeMeta(indexEntry.Path, ext); + if (meta != null) { PreviewText = meta; HasPreview = true; return; } + } } HasPreview = false; @@ -589,6 +614,89 @@ public partial class LauncherViewModel : INotifyPropertyChanged or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf" or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css"; + private static bool IsImageFile(string ext) => ext is + ".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp" + or ".svg" or ".ico" or ".tiff" or ".tif"; + + private static bool IsMediaFile(string ext) => ext is + ".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" + or ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm"; + + private static string? GetImageMeta(string path) + { + try + { + var fi = new System.IO.FileInfo(path); + var size = FormatFileSize(fi.Length); + var ext = fi.Extension.TrimStart('.').ToUpperInvariant(); + + // BitmapDecoder로 해상도 읽기 + using var stream = new System.IO.FileStream(path, + System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read); + var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create( + stream, System.Windows.Media.Imaging.BitmapCreateOptions.DelayCreation, + System.Windows.Media.Imaging.BitmapCacheOption.None); + var frame = decoder.Frames[0]; + return $"🖼 {ext} 이미지 · {frame.PixelWidth}×{frame.PixelHeight} · {size}\n" + + $"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}"; + } + catch { return null; } + } + + private static string? GetPdfMeta(string path) + { + try + { + var fi = new System.IO.FileInfo(path); + var size = FormatFileSize(fi.Length); + + using var doc = UglyToad.PdfPig.PdfDocument.Open(path); + int pages = doc.NumberOfPages; + var firstPageText = ""; + if (pages > 0) + { + var page = doc.GetPage(1); + firstPageText = page.Text; + if (firstPageText.Length > 200) + firstPageText = firstPageText[..200] + "…"; + firstPageText = firstPageText.Replace("\r\n", " ").Replace("\n", " "); + } + + var meta = $"📄 PDF · {pages}페이지 · {size}"; + if (!string.IsNullOrWhiteSpace(firstPageText)) + meta += $"\n{firstPageText}"; + + return meta; + } + catch { return null; } + } + + private static string? GetFileSizeMeta(string path, string ext) + { + try + { + var fi = new System.IO.FileInfo(path); + var size = FormatFileSize(fi.Length); + var type = ext switch + { + ".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" => "🎵 오디오", + ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm" => "🎬 동영상", + _ => "📁 파일" + }; + return $"{type} · {ext.TrimStart('.').ToUpperInvariant()} · {size}\n" + + $"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}"; + } + catch { return null; } + } + + private static string FormatFileSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1048576 => $"{bytes / 1024.0:F1} KB", + < 1073741824 => $"{bytes / 1048576.0:F1} MB", + _ => $"{bytes / 1073741824.0:F2} GB" + }; + private static async Task> ReadFirstLinesAsync( string path, int maxLines, CancellationToken ct) {