Compare commits
2 Commits
fc881124b9
...
d4a1532d81
| Author | SHA1 | Date | |
|---|---|---|---|
| d4a1532d81 | |||
| 75cb4ba6e9 |
@@ -107,6 +107,21 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase L4 — 검색/탐색 UX 혁신 + 생산성 확장 (v1.7.x)
|
||||
|
||||
> **방향**: Raycast/Alfred 기능 갭 해소 + 기존 L1-7 미완 기능 이행. 런처 단독 완결형 생산성 강화.
|
||||
|
||||
| # | 기능 | 설명 | 우선순위 |
|
||||
|---|------|------|----------|
|
||||
| L4-1 | **파일 탐색기 인라인 패널** | `cd` + `→` 키로 인라인 탐색기 진입. 경로 브레드크럼, ←/→ 폴더 이동, Ctrl+D 즐겨찾기 핀 | 높음 |
|
||||
| L4-2 | **QuickLook F3 강화** | PDF(WinRT PdfDocument), 코드 구문강조(확장자별 배경색+라인 번호), Office 텍스트 추출 | 높음 |
|
||||
| L4-3 | **클립보드 핀 & 카테고리** | Ctrl+P 핀 고정, `#pin/#url/#code` 필터, 자동 분류(URL/코드/경로). L1-7 이행 | 높음 |
|
||||
| L4-4 | **검색 히스토리** | ↑/↓ 키로 이전 검색어 탐색. 50개 FIFO 로컬 저장. 런처 재시작 후에도 유지 | 중간 |
|
||||
| L4-5 | **고급 검색 필터 문법** | `ext:.pdf size:>1mb modified:week in:documents` 인라인 필터. FuzzyEngine 파서 레이어 | 중간 |
|
||||
| L4-6 | **계산기 단위 변환 확장** | `=20km`, `=100°F`, `=today+30d`. 로컬 변환 테이블. `=` 핸들러 확장 | 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 부채 (v1.5.0 해결)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|
||||
@@ -173,6 +173,10 @@ public partial class App : System.Windows.Application
|
||||
// Phase L3-9: 뽀모도로 타이머
|
||||
commandResolver.RegisterHandler(new PomoHandler());
|
||||
|
||||
// ─── Phase L4 핸들러 ──────────────────────────────────────────────────
|
||||
// Phase L4-1: 인라인 파일 탐색기 (Prefix=null, 경로 패턴 감지)
|
||||
commandResolver.RegisterHandler(new FileBrowserHandler());
|
||||
|
||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||
var pluginHost = new PluginHost(settings, commandResolver);
|
||||
pluginHost.LoadAll();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AxCopilot.Handlers;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
@@ -73,7 +74,15 @@ public class CommandResolver
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행
|
||||
// 2. 경로 쿼리 감지 → 파일 탐색기 단독 처리 (퍼지 검색 우선순위 우회)
|
||||
if (FileBrowserHandler.IsPathQuery(input))
|
||||
{
|
||||
var fb = _fuzzyHandlers.OfType<FileBrowserHandler>().FirstOrDefault();
|
||||
if (fb != null)
|
||||
return await fb.GetItemsAsync(input, ct);
|
||||
}
|
||||
|
||||
// 3. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행
|
||||
var maxResults = _settings.Settings.Launcher.MaxResults;
|
||||
|
||||
// Path 기반 중복 제거: 같은 경로의 항목이 여러 키워드로 매칭될 때 첫 번째만 표시
|
||||
@@ -163,6 +172,13 @@ public class CommandResolver
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 탐색기 항목 실행 (FileBrowserEntry)
|
||||
if (item.Data is FileBrowserEntry)
|
||||
{
|
||||
await ExecuteNullPrefixAsync(item, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fuzzy 결과 실행 (IndexEntry 기반)
|
||||
if (item.Data is IndexEntry entry)
|
||||
{
|
||||
|
||||
@@ -40,13 +40,17 @@ public class CalculatorHandler : IActionHandler
|
||||
|
||||
var trimmed = query.Trim();
|
||||
|
||||
// ─── today+Nd / today-Nd 날짜 단축 계산 (= 접두어에서 date 핸들러 위임) ─
|
||||
if (DateShortcut.TryMatch(trimmed, out var dateItems))
|
||||
return dateItems!;
|
||||
|
||||
// ─── 통화 변환 우선 감지 ──────────────────────────────────────────────
|
||||
if (CurrencyConverter.IsCurrencyQuery(trimmed))
|
||||
{
|
||||
return await CurrencyConverter.ConvertAsync(trimmed, ct);
|
||||
}
|
||||
|
||||
// ─── 단위 변환 우선 감지 ──────────────────────────────────────────────
|
||||
// ─── 단위 변환 (명시적 목표: 100km in miles) ─────────────────────────
|
||||
if (UnitConverter.TryConvert(trimmed, out var convertResult))
|
||||
{
|
||||
return
|
||||
@@ -58,6 +62,11 @@ public class CalculatorHandler : IActionHandler
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 단위 단축 자동 제안 (20km, 100f, 5lb …) ──────────────────────────
|
||||
var autoSuggest = UnitConverter.AutoSuggest(trimmed);
|
||||
if (autoSuggest.Count > 0)
|
||||
return autoSuggest;
|
||||
|
||||
// ─── 수식 계산 ────────────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
|
||||
185
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal file
185
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L4-1: 인라인 파일 탐색기 핸들러.
|
||||
/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다.
|
||||
/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동.
|
||||
/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작.
|
||||
/// </summary>
|
||||
public class FileBrowserHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => null; // 경로 패턴 직접 감지
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"FileBrowser",
|
||||
"파일 탐색기 — 경로 입력 후 → 키로 탐색",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// C:\, D:\path, \\server\share, ~\ 패턴 감지
|
||||
private static readonly Regex PathPattern = new(
|
||||
@"^([A-Za-z]:\\|\\\\|~\\|~\/|\/)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다.</summary>
|
||||
public static bool IsPathQuery(string query)
|
||||
=> !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim());
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = ExpandPath(query.Trim());
|
||||
|
||||
// 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리)
|
||||
if (!IsPathQuery(query.Trim()))
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
|
||||
|
||||
// 입력이 존재하는 디렉터리이면 그 내용 표시
|
||||
if (Directory.Exists(q))
|
||||
return Task.FromResult(ListDirectory(q));
|
||||
|
||||
// 부분 경로: 마지막 세그먼트를 필터로 사용
|
||||
var parent = Path.GetDirectoryName(q);
|
||||
var filter = Path.GetFileName(q).ToLowerInvariant();
|
||||
|
||||
if (parent != null && Directory.Exists(parent))
|
||||
return Task.FromResult(ListDirectory(parent, filter));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem("경로를 찾을 수 없습니다", q, null, null, Symbol: Symbols.Error)
|
||||
});
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is FileBrowserEntry { IsFolder: true } dir)
|
||||
{
|
||||
// 폴더: 탐색기로 열기
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo("explorer.exe", dir.Path)
|
||||
{ UseShellExecute = true });
|
||||
}
|
||||
else if (item.Data is FileBrowserEntry { IsFolder: false } file)
|
||||
{
|
||||
// 파일: 기본 앱으로 열기
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(file.Path)
|
||||
{ UseShellExecute = true });
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 디렉터리 내용 나열 ─────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> ListDirectory(string dir, string filter = "")
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 상위 폴더 항목 (루트가 아닐 때)
|
||||
var parent = Path.GetDirectoryName(dir.TrimEnd('\\', '/'));
|
||||
if (!string.IsNullOrEmpty(parent))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
".. (상위 폴더)",
|
||||
parent,
|
||||
null,
|
||||
new FileBrowserEntry(parent, true),
|
||||
Symbol: "\uE74A")); // Back 아이콘
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 폴더 먼저
|
||||
var dirs = Directory.GetDirectories(dir)
|
||||
.Where(d => string.IsNullOrEmpty(filter) ||
|
||||
Path.GetFileName(d).Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)
|
||||
.Take(40);
|
||||
|
||||
foreach (var d in dirs)
|
||||
{
|
||||
var name = Path.GetFileName(d);
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
d,
|
||||
null,
|
||||
new FileBrowserEntry(d, true),
|
||||
Symbol: Symbols.Folder));
|
||||
}
|
||||
|
||||
// 파일
|
||||
var files = Directory.GetFiles(dir)
|
||||
.Where(f => string.IsNullOrEmpty(filter) ||
|
||||
Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
|
||||
.Take(30);
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
var name = Path.GetFileName(f);
|
||||
var ext = Path.GetExtension(f).ToLowerInvariant();
|
||||
var size = FormatSize(new FileInfo(f).Length);
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
$"{size} · {ext.TrimStart('.')} 파일",
|
||||
null,
|
||||
new FileBrowserEntry(f, false),
|
||||
Symbol: ExtToSymbol(ext)));
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
items.Add(new LauncherItem("접근 권한 없음", dir, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("읽기 오류", ex.Message, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
|
||||
if (items.Count == 0 || (items.Count == 1 && items[0].Symbol == "\uE74A"))
|
||||
items.Add(new LauncherItem("(빈 폴더)", dir, null, null, Symbol: Symbols.Folder));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ExpandPath(string path)
|
||||
{
|
||||
if (path.StartsWith("~")) path = "%USERPROFILE%" + path[1..];
|
||||
return Environment.ExpandEnvironmentVariables(path);
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1_024L => $"{bytes} B",
|
||||
< 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB",
|
||||
< 1_024L * 1_024 * 1_024 => $"{bytes / 1_048_576.0:F1} MB",
|
||||
_ => $"{bytes / 1_073_741_824.0:F1} GB",
|
||||
};
|
||||
|
||||
private static string ExtToSymbol(string ext) => ext switch
|
||||
{
|
||||
".exe" or ".msi" => Symbols.App,
|
||||
".pdf" => "\uEA90",
|
||||
".docx" or ".doc" => "\uE8A5",
|
||||
".xlsx" or ".xls" => "\uE9F9",
|
||||
".pptx" or ".ppt" => "\uE8A5",
|
||||
".zip" or ".7z" or ".rar" => "\uED25",
|
||||
".mp4" or ".avi" or ".mkv" => "\uE714",
|
||||
".mp3" or ".wav" or ".flac" => "\uE767",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" => "\uEB9F",
|
||||
".txt" or ".md" or ".log" => "\uE8A5",
|
||||
".cs" or ".py" or ".js" or ".ts" => "\uE8A5",
|
||||
".lnk" => "\uE71B",
|
||||
_ => "\uE7C3",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>파일 탐색기 핸들러에서 사용하는 항목 데이터</summary>
|
||||
public record FileBrowserEntry(string Path, bool IsFolder);
|
||||
@@ -151,4 +151,131 @@ internal static class UnitConverter
|
||||
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
|
||||
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// ─── 자동 제안 (목표 단위 없이 입력 시) ──────────────────────────────────
|
||||
|
||||
// 패턴: <숫자><단위> (공백 없어도 OK)
|
||||
private static readonly Regex _autoPattern = new(
|
||||
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// 단위 → 주요 변환 대상 목록 (사용자에게 자동 제안)
|
||||
private static readonly Dictionary<string, string[]> _suggestions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 길이
|
||||
["km"] = ["miles", "m", "ft"],
|
||||
["miles"] = ["km", "m", "ft"],
|
||||
["m"] = ["km", "ft", "inches"],
|
||||
["cm"] = ["inches", "m"],
|
||||
["mm"] = ["inches", "cm"],
|
||||
["ft"] = ["m", "km", "inches"],
|
||||
["feet"] = ["m", "km", "inches"],
|
||||
["in"] = ["cm", "m"],
|
||||
["inches"]= ["cm", "m"],
|
||||
// 무게
|
||||
["kg"] = ["lb", "g"],
|
||||
["lb"] = ["kg", "g"],
|
||||
["lbs"] = ["kg", "g"],
|
||||
["g"] = ["kg", "oz"],
|
||||
["oz"] = ["g", "kg"],
|
||||
// 속도
|
||||
["km/h"] = ["mph", "m/s"],
|
||||
["kmh"] = ["mph", "m/s"],
|
||||
["kph"] = ["mph", "m/s"],
|
||||
["mph"] = ["km/h", "m/s"],
|
||||
["m/s"] = ["km/h", "mph"],
|
||||
// 데이터
|
||||
["mb"] = ["gb", "kb"],
|
||||
["gb"] = ["mb", "tb"],
|
||||
["tb"] = ["gb", "mb"],
|
||||
["kb"] = ["mb", "b"],
|
||||
// 온도 — 양방향
|
||||
["c"] = ["f", "k"],
|
||||
["°c"] = ["°f", "k"],
|
||||
["celsius"]= ["fahrenheit", "kelvin"],
|
||||
["f"] = ["c", "k"],
|
||||
["°f"] = ["°c", "k"],
|
||||
["fahrenheit"] = ["celsius", "kelvin"],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// "20km", "100f" 처럼 목표 단위 없이 입력하면 주요 변환 대상을 자동 제안합니다.
|
||||
/// </summary>
|
||||
public static List<LauncherItem> AutoSuggest(string input)
|
||||
{
|
||||
var m = _autoPattern.Match(input.Trim());
|
||||
if (!m.Success) return new();
|
||||
|
||||
if (!double.TryParse(m.Groups[1].Value,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var value))
|
||||
return new();
|
||||
|
||||
var fromUnit = m.Groups[2].Value;
|
||||
if (!_suggestions.TryGetValue(fromUnit, out var targets)) return new();
|
||||
|
||||
var items = new List<LauncherItem>();
|
||||
foreach (var to in targets)
|
||||
{
|
||||
if (TryConvert($"{value} {fromUnit} in {to}", out var r) && r != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
r,
|
||||
$"{value} {fromUnit} → {to} · Enter로 복사",
|
||||
null, r, Symbol: Symbols.Calculator));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── = 접두어에서 today 날짜 단축 계산 ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// "today+30d", "today-7d", "today+2w" 등 날짜 단축 패턴을 = 핸들러에서 처리합니다.
|
||||
/// DateCalcHandler의 로직을 재사용합니다.
|
||||
/// </summary>
|
||||
internal static class DateShortcut
|
||||
{
|
||||
private static readonly Regex _pattern = new(
|
||||
@"^today\s*([+-])(\d+)([dDwWmMyY])$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryMatch(string input, out IEnumerable<LauncherItem>? items)
|
||||
{
|
||||
items = null;
|
||||
var m = _pattern.Match(input.Trim());
|
||||
if (!m.Success) return false;
|
||||
|
||||
var sign = m.Groups[1].Value == "+" ? 1 : -1;
|
||||
var val = int.Parse(m.Groups[2].Value) * sign;
|
||||
var unit = m.Groups[3].Value.ToLowerInvariant();
|
||||
var now = DateTime.Now;
|
||||
|
||||
var target = unit switch
|
||||
{
|
||||
"d" => now.AddDays(val),
|
||||
"w" => now.AddDays(val * 7),
|
||||
"m" => now.AddMonths(val),
|
||||
"y" => now.AddYears(val),
|
||||
_ => now.AddDays(val),
|
||||
};
|
||||
|
||||
var diff = (target.Date - now.Date).Days;
|
||||
var dayName = target.ToString("dddd", new System.Globalization.CultureInfo("ko-KR"));
|
||||
var dateStr = target.ToString("yyyy-MM-dd");
|
||||
var diffText = diff == 0 ? "오늘"
|
||||
: diff > 0 ? $"+{diff}일 후"
|
||||
: $"{-diff}일 전";
|
||||
|
||||
items = new List<LauncherItem>
|
||||
{
|
||||
new LauncherItem(
|
||||
$"{dateStr} ({dayName})",
|
||||
$"{input} → {diffText} · Enter로 복사",
|
||||
null, dateStr, Symbol: Symbols.Calculator),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
101
src/AxCopilot/Services/SearchHistoryService.cs
Normal file
101
src/AxCopilot/Services/SearchHistoryService.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 런처 검색 히스토리를 로컬 파일로 관리합니다.
|
||||
/// ↑/↓ 키로 이전 검색어를 탐색할 수 있습니다.
|
||||
/// 최대 50개 FIFO — 가장 최근 항목이 index 0.
|
||||
/// 저장 위치: %APPDATA%\AxCopilot\search_history.json
|
||||
/// </summary>
|
||||
internal static class SearchHistoryService
|
||||
{
|
||||
private const int MaxItems = 50;
|
||||
|
||||
private static readonly string _file = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "search_history.json");
|
||||
|
||||
private static List<string> _history = new();
|
||||
private static bool _loaded;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
// ─── 공개 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 검색어를 히스토리에 추가합니다.
|
||||
/// 공백·짧은 쿼리·중복(최근 항목)은 추가하지 않습니다.
|
||||
/// </summary>
|
||||
public static void Add(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Trim().Length < 2) return;
|
||||
var q = query.Trim();
|
||||
EnsureLoaded();
|
||||
lock (_lock)
|
||||
{
|
||||
// 이미 최상단이면 중복 추가 안 함
|
||||
if (_history.Count > 0 && _history[0] == q) return;
|
||||
// 기존 동일 항목 제거 후 맨 앞에 삽입
|
||||
_history.Remove(q);
|
||||
_history.Insert(0, q);
|
||||
if (_history.Count > MaxItems) _history.RemoveAt(_history.Count - 1);
|
||||
}
|
||||
_ = SaveAsync();
|
||||
}
|
||||
|
||||
/// <summary>전체 히스토리 목록 반환 (최신 → 오래된 순)</summary>
|
||||
public static IReadOnlyList<string> GetAll()
|
||||
{
|
||||
EnsureLoaded();
|
||||
lock (_lock) return _history.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>히스토리 전체 삭제</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
lock (_lock) _history.Clear();
|
||||
_ = SaveAsync();
|
||||
}
|
||||
|
||||
// ─── 내부 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_loaded) return;
|
||||
try
|
||||
{
|
||||
if (File.Exists(_file))
|
||||
{
|
||||
var json = File.ReadAllText(_file);
|
||||
_history = JsonSerializer.Deserialize<List<string>>(json) ?? new();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"search_history.json 로드 실패: {ex.Message}");
|
||||
_history = new();
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SaveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<string> snapshot;
|
||||
lock (_lock) snapshot = new List<string>(_history);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_file)!);
|
||||
var json = JsonSerializer.Serialize(snapshot);
|
||||
await File.WriteAllTextAsync(_file, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"search_history.json 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
|
||||
private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용
|
||||
|
||||
// ─── 검색 히스토리 탐색 ──────────────────────────────────────────────────
|
||||
private bool _isHistoryNavigation; // InputText setter에서 히스토리 인덱스 리셋 방지
|
||||
private int _historyIndex = -1; // -1 = 탐색 중 아님
|
||||
|
||||
// ─── 파일 액션 모드 ───────────────────────────────────────────────────────
|
||||
private bool _isActionMode;
|
||||
private LauncherItem? _actionSourceItem;
|
||||
@@ -66,6 +70,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
{
|
||||
if (_inputText == value) return;
|
||||
_inputText = value;
|
||||
// 사용자 직접 입력 시 히스토리 탐색 위치 초기화
|
||||
if (!_isHistoryNavigation) _historyIndex = -1;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasActivePrefix));
|
||||
OnPropertyChanged(nameof(ActivePrefixLabel));
|
||||
@@ -276,10 +282,49 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
|
||||
Results.Clear();
|
||||
_lastSearchQuery = "";
|
||||
_historyIndex = -1;
|
||||
ClearMerge();
|
||||
LoadQuickActions();
|
||||
}
|
||||
|
||||
// ─── 검색 히스토리 탐색 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// ↑ 키 — 이전(오래된) 히스토리 항목으로 이동.
|
||||
/// null이면 히스토리 없음.
|
||||
/// </summary>
|
||||
public string? NavigateHistoryPrev()
|
||||
{
|
||||
var history = SearchHistoryService.GetAll();
|
||||
if (history.Count == 0) return null;
|
||||
_historyIndex = Math.Min(_historyIndex + 1, history.Count - 1);
|
||||
return history[_historyIndex];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ↓ 키 — 최신 히스토리 항목으로 이동.
|
||||
/// _historyIndex가 0 이하면 빈 문자열(현재 입력으로 복귀).
|
||||
/// </summary>
|
||||
public string? NavigateHistoryNext()
|
||||
{
|
||||
if (_historyIndex <= 0) { _historyIndex = -1; return ""; }
|
||||
_historyIndex--;
|
||||
var history = SearchHistoryService.GetAll();
|
||||
return _historyIndex >= 0 && _historyIndex < history.Count
|
||||
? history[_historyIndex] : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 히스토리에서 탐색한 텍스트를 입력창에 설정합니다.
|
||||
/// InputText setter의 히스토리 인덱스 리셋을 억제합니다.
|
||||
/// </summary>
|
||||
public void SetInputFromHistory(string text)
|
||||
{
|
||||
_isHistoryNavigation = true;
|
||||
try { InputText = text; }
|
||||
finally { _isHistoryNavigation = false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UsageRankingService 상위 항목에서 퀵 액션 칩을 생성합니다.
|
||||
/// 실제로 존재하는 파일/폴더만 표시하며 최대 8개로 제한합니다.
|
||||
@@ -405,6 +450,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
|
||||
if (SelectedItem == null) return;
|
||||
|
||||
// 실행 전 검색어 히스토리 저장 (2자 이상, prefix 포함)
|
||||
if (InputText.Trim().Length >= 2)
|
||||
SearchHistoryService.Add(InputText.Trim());
|
||||
|
||||
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
|
||||
@@ -108,6 +108,16 @@ public partial class LauncherWindow
|
||||
_vm.ToggleMergeItem(_vm.SelectedItem);
|
||||
_vm.SelectNext();
|
||||
}
|
||||
else if (_vm.Results.Count == 0)
|
||||
{
|
||||
// 결과 없음 → 검색 히스토리 탐색 (최신 방향)
|
||||
var histNext = _vm.NavigateHistoryNext();
|
||||
if (histNext != null)
|
||||
{
|
||||
_vm.SetInputFromHistory(histNext);
|
||||
InputBox.CaretIndex = InputBox.Text.Length;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.SelectNext();
|
||||
@@ -122,6 +132,16 @@ public partial class LauncherWindow
|
||||
_vm.ToggleMergeItem(_vm.SelectedItem);
|
||||
_vm.SelectPrev();
|
||||
}
|
||||
else if (_vm.Results.Count == 0)
|
||||
{
|
||||
// 결과 없음 → 검색 히스토리 탐색 (이전 방향)
|
||||
var histPrev = _vm.NavigateHistoryPrev();
|
||||
if (histPrev != null)
|
||||
{
|
||||
_vm.SetInputFromHistory(histPrev);
|
||||
InputBox.CaretIndex = InputBox.Text.Length;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.SelectPrev();
|
||||
@@ -131,14 +151,45 @@ public partial class LauncherWindow
|
||||
break;
|
||||
|
||||
case Key.Right:
|
||||
// 커서가 입력 끝에 있고 선택된 항목이 파일/앱이면 액션 서브메뉴 진입
|
||||
if (InputBox.CaretIndex == InputBox.Text.Length
|
||||
&& InputBox.Text.Length > 0
|
||||
&& _vm.CanEnterActionMode())
|
||||
// 커서가 입력 끝에 있을 때
|
||||
if (InputBox.CaretIndex == InputBox.Text.Length && InputBox.Text.Length > 0)
|
||||
{
|
||||
// 파일 탐색기: 선택된 항목이 폴더이면 해당 경로로 진입
|
||||
if (_vm.SelectedItem?.Data is AxCopilot.Handlers.FileBrowserEntry { IsFolder: true } fb)
|
||||
{
|
||||
_vm.InputText = fb.Path.TrimEnd('\\', '/') + "\\";
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
InputBox.CaretIndex = InputBox.Text.Length;
|
||||
}, System.Windows.Threading.DispatcherPriority.Input);
|
||||
e.Handled = true;
|
||||
}
|
||||
// 일반 항목: 액션 서브메뉴 진입
|
||||
else if (_vm.CanEnterActionMode())
|
||||
{
|
||||
_vm.EnterActionMode(_vm.SelectedItem!);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Left:
|
||||
// 파일 탐색기 모드에서 커서가 끝에 있고 입력이 경로이면 상위 폴더로 이동
|
||||
if (InputBox.CaretIndex == InputBox.Text.Length
|
||||
&& AxCopilot.Handlers.FileBrowserHandler.IsPathQuery(InputBox.Text))
|
||||
{
|
||||
var trimmed = InputBox.Text.TrimEnd('\\', '/');
|
||||
var parent = System.IO.Path.GetDirectoryName(trimmed);
|
||||
if (!string.IsNullOrEmpty(parent))
|
||||
{
|
||||
_vm.InputText = parent.TrimEnd('\\', '/') + "\\";
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
InputBox.CaretIndex = InputBox.Text.Length;
|
||||
}, System.Windows.Threading.DispatcherPriority.Input);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.PageDown:
|
||||
@@ -441,6 +492,14 @@ public partial class LauncherWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ────────────────────────
|
||||
if (e.Key == Key.F3)
|
||||
{
|
||||
ToggleQuickLook();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Ctrl+1~9 → n번째 결과 즉시 실행 ───────────────────────────────
|
||||
if (mod == ModifierKeys.Control)
|
||||
{
|
||||
|
||||
229
src/AxCopilot/Views/QuickLookWindow.xaml
Normal file
229
src/AxCopilot/Views/QuickLookWindow.xaml
Normal file
@@ -0,0 +1,229 @@
|
||||
<Window x:Class="AxCopilot.Views.QuickLookWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="AX Commander — 빠른 미리보기"
|
||||
Width="400" Height="500"
|
||||
MinWidth="260" MinHeight="200"
|
||||
WindowStyle="None" AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
WindowStartupLocation="Manual"
|
||||
ResizeMode="CanResizeWithGrip"
|
||||
Topmost="True">
|
||||
|
||||
<!-- F3 빠른 미리보기: 이미지/텍스트/폴더/파일 정보 -->
|
||||
|
||||
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
|
||||
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
|
||||
Margin="6">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="22" ShadowDepth="4" Opacity="0.32" Color="Black" Direction="270"/>
|
||||
</Border.Effect>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="44"/> <!-- 타이틀바 -->
|
||||
<RowDefinition Height="*"/> <!-- 컨텐츠 -->
|
||||
<RowDefinition Height="Auto"/> <!-- 하단 메타 -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ─── 타이틀바 ─────────────────────────────────────────────── -->
|
||||
<Border Grid.Row="0" CornerRadius="12,12,0,0"
|
||||
Background="{DynamicResource ItemBackground}"
|
||||
MouseLeftButtonDown="TitleBar_MouseDown">
|
||||
<Grid Margin="14,0,8,0">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="FileTypeIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="15"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
VerticalAlignment="Center" Margin="0,1,10,0"/>
|
||||
<TextBlock x:Name="FileNameText" Text=""
|
||||
FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="270"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 닫기 버튼 -->
|
||||
<Border HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
CornerRadius="4" Padding="8,4" Cursor="Hand"
|
||||
MouseLeftButtonUp="BtnClose_Click">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#40C05050"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="13"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ─── 컨텐츠 영역 ───────────────────────────────────────────── -->
|
||||
<Grid Grid.Row="1">
|
||||
|
||||
<!-- 이미지 미리보기 -->
|
||||
<ScrollViewer x:Name="ImageScrollViewer"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Visibility="Collapsed"
|
||||
Background="{DynamicResource LauncherBackground}">
|
||||
<Image x:Name="PreviewImage"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8"/>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 텍스트/코드 미리보기 -->
|
||||
<ScrollViewer x:Name="TextScrollViewer"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Visibility="Collapsed">
|
||||
<Grid>
|
||||
<!-- 코드 배경 (줄 번호 열) -->
|
||||
<Border x:Name="LineNumBg"
|
||||
HorizontalAlignment="Left"
|
||||
Width="40"
|
||||
Background="#0AFFFFFF"
|
||||
Visibility="Collapsed"/>
|
||||
<!-- 줄 번호 텍스트 -->
|
||||
<TextBlock x:Name="LineNumbers"
|
||||
FontFamily="Cascadia Code, Consolas, Courier New"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
TextWrapping="NoWrap"
|
||||
HorizontalAlignment="Left"
|
||||
Opacity="0.5"
|
||||
Margin="6,12,0,12"
|
||||
Visibility="Collapsed"/>
|
||||
<!-- 코드/텍스트 본문 -->
|
||||
<TextBlock x:Name="PreviewText"
|
||||
FontFamily="Cascadia Code, Consolas, Courier New"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
TextWrapping="NoWrap"
|
||||
Margin="14,12"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- PDF 미리보기 (텍스트 추출) -->
|
||||
<ScrollViewer x:Name="PdfScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Visibility="Collapsed">
|
||||
<StackPanel>
|
||||
<!-- PDF 헤더 배지 -->
|
||||
<Border Background="#15EA4335" CornerRadius="6"
|
||||
Padding="12,6" Margin="10,10,10,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="13"
|
||||
Foreground="#EA4335" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock x:Name="PdfMetaText"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="PdfPreviewText"
|
||||
FontFamily="Segoe UI, Malgun Gothic"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="14,12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Office 문서 미리보기 (텍스트 추출) -->
|
||||
<ScrollViewer x:Name="OfficeScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Visibility="Collapsed">
|
||||
<StackPanel>
|
||||
<!-- Office 헤더 배지 -->
|
||||
<Border Background="#152196F3" CornerRadius="6"
|
||||
Padding="12,6" Margin="10,10,10,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="OfficeTypeIcon"
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="13"
|
||||
Foreground="#2196F3" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock x:Name="OfficeMetaText"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="OfficePreviewText"
|
||||
FontFamily="Segoe UI, Malgun Gothic"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="14,12"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 정보 패널 (폴더/앱/기타 파일) -->
|
||||
<StackPanel x:Name="InfoPanel"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock x:Name="InfoTypeIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="52"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="InfoTypeName"
|
||||
Text=""
|
||||
FontSize="14"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,10,0,0"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
MaxWidth="320"/>
|
||||
<TextBlock x:Name="InfoSubText"
|
||||
Text=""
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ─── 하단 메타 정보 ─────────────────────────────────────────── -->
|
||||
<Border Grid.Row="2" CornerRadius="0,0,12,12"
|
||||
Background="{DynamicResource ItemBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
|
||||
Padding="14,7">
|
||||
<Grid>
|
||||
<TextBlock x:Name="FooterPath"
|
||||
Text=""
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="240"/>
|
||||
<TextBlock x:Name="FooterMeta"
|
||||
Text=""
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
425
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal file
425
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal file
@@ -0,0 +1,425 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
using UglyToad.PdfPig;
|
||||
using WpfColor = System.Windows.Media.Color;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// F3 파일 빠른 미리보기 창.
|
||||
/// 선택된 파일/폴더의 내용을 이미지·텍스트·정보 3가지 뷰로 표시합니다.
|
||||
/// </summary>
|
||||
public partial class QuickLookWindow : Window
|
||||
{
|
||||
// ─── 지원 확장자 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly HashSet<string> ImageExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> TextExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".txt", ".md", ".cs", ".vb", ".fs", ".py", ".js", ".ts", ".jsx", ".tsx",
|
||||
".json", ".xml", ".xaml", ".yaml", ".yml", ".toml", ".ini", ".conf",
|
||||
".log", ".csv", ".html", ".htm", ".css", ".scss", ".less",
|
||||
".sql", ".sh", ".bash", ".bat", ".cmd", ".ps1",
|
||||
".config", ".env", ".gitignore", ".editorconfig",
|
||||
".java", ".cpp", ".c", ".h", ".hpp", ".rs", ".go", ".rb", ".php", ".swift",
|
||||
".vue", ".svelte", ".dockerfile"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CodeExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".cs", ".vb", ".fs", ".py", ".js", ".ts", ".jsx", ".tsx",
|
||||
".json", ".xml", ".xaml", ".yaml", ".yml", ".toml",
|
||||
".sql", ".sh", ".bash", ".bat", ".cmd", ".ps1",
|
||||
".java", ".cpp", ".c", ".h", ".hpp", ".rs", ".go", ".rb", ".php", ".swift",
|
||||
".css", ".scss", ".less", ".html", ".htm", ".vue", ".svelte"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> PdfExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".pdf" };
|
||||
|
||||
private static readonly HashSet<string> WordExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".docx", ".doc" };
|
||||
|
||||
private static readonly HashSet<string> ExcelExts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".xlsx", ".xls" };
|
||||
|
||||
// ─── 생성 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public QuickLookWindow(string path, Window owner)
|
||||
{
|
||||
InitializeComponent();
|
||||
Owner = owner;
|
||||
KeyDown += OnKeyDown;
|
||||
Loaded += (_, _) => LoadPreview(path);
|
||||
}
|
||||
|
||||
// ─── 이벤트 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Escape or Key.F3)
|
||||
{
|
||||
Close();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.LeftButton == MouseButtonState.Pressed) DragMove();
|
||||
}
|
||||
|
||||
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
|
||||
|
||||
// ─── 미리보기 로드 ───────────────────────────────────────────────────────
|
||||
|
||||
private void LoadPreview(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
FileNameText.Text = Path.GetFileName(path);
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
LoadFolderInfo(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
ShowInfo("\uE7BA", "파일을 찾을 수 없습니다.", "");
|
||||
return;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(path);
|
||||
var info = new FileInfo(path);
|
||||
|
||||
FooterPath.Text = path;
|
||||
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
|
||||
if (ImageExts.Contains(ext))
|
||||
LoadImagePreview(path, info);
|
||||
else if (PdfExts.Contains(ext))
|
||||
LoadPdfPreview(path, info);
|
||||
else if (WordExts.Contains(ext))
|
||||
LoadWordPreview(path, info);
|
||||
else if (ExcelExts.Contains(ext))
|
||||
LoadExcelPreview(path, info);
|
||||
else if (TextExts.Contains(ext))
|
||||
LoadTextPreview(path, ext);
|
||||
else
|
||||
LoadFileInfo(path, ext, info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowInfo("\uE783", $"미리보기 오류", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 이미지 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadImagePreview(string path, FileInfo info)
|
||||
{
|
||||
FileTypeIcon.Text = "\uEB9F";
|
||||
try
|
||||
{
|
||||
var bi = new BitmapImage();
|
||||
bi.BeginInit();
|
||||
bi.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bi.UriSource = new Uri(path, UriKind.Absolute);
|
||||
bi.DecodePixelWidth = 800; // 메모리 절약
|
||||
bi.EndInit();
|
||||
bi.Freeze();
|
||||
|
||||
PreviewImage.Source = bi;
|
||||
// 이미지 해상도를 타이틀바에 추가
|
||||
FileNameText.Text = $"{Path.GetFileName(path)} ({bi.PixelWidth}×{bi.PixelHeight})";
|
||||
ImageScrollViewer.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ShowInfo("\uEB9F", "이미지를 불러올 수 없습니다.", info.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 텍스트 / 코드 ────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadTextPreview(string path, string ext)
|
||||
{
|
||||
FileTypeIcon.Text = "\uE8A5";
|
||||
try
|
||||
{
|
||||
const int MaxLines = 300;
|
||||
List<string> lines;
|
||||
try
|
||||
{
|
||||
lines = File.ReadLines(path, Encoding.UTF8).Take(MaxLines).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
lines = File.ReadLines(path).Take(MaxLines).ToList();
|
||||
}
|
||||
|
||||
var text = string.Join("\n", lines);
|
||||
if (lines.Count == MaxLines) text += "\n\n… (이하 생략, 최대 300줄)";
|
||||
|
||||
PreviewText.Text = text;
|
||||
|
||||
// 코드 파일: 줄 번호 + 배경 강조
|
||||
if (CodeExts.Contains(ext))
|
||||
{
|
||||
ApplyCodeStyle(lines, ext);
|
||||
}
|
||||
|
||||
TextScrollViewer.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ShowInfo("\uE8A5", "텍스트를 불러올 수 없습니다.", path);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCodeStyle(List<string> lines, string ext)
|
||||
{
|
||||
// 줄 번호 표시
|
||||
LineNumbers.Text = string.Join("\n", Enumerable.Range(1, lines.Count));
|
||||
LineNumbers.Visibility = Visibility.Visible;
|
||||
LineNumBg.Visibility = Visibility.Visible;
|
||||
|
||||
// 코드 본문을 줄 번호 너비만큼 우측으로 밀기
|
||||
PreviewText.Margin = new Thickness(50, 12, 14, 12);
|
||||
|
||||
// 확장자별 배경 색조
|
||||
var (accent, dim) = ext.ToLowerInvariant() switch
|
||||
{
|
||||
".cs" or ".vb" or ".fs" => ("#0A6ABDE8", "#10143A57"), // 파랑 — C#/VB/F#
|
||||
".py" => ("#0AF5D55C", "#10254A10"), // 초록 — Python
|
||||
".js" or ".ts" or ".jsx" or ".tsx" => ("#0AF0B429", "#10453200"), // 앰버 — JS/TS
|
||||
".json" or ".yaml" or ".yml" or ".toml" => ("#0AB0B0C0", "#10202030"), // 회색 — 데이터
|
||||
".sql" => ("#0AFF8C69", "#10401A00"), // 주황 — SQL
|
||||
".html" or ".htm" or ".xml" or ".xaml" => ("#0AFF7878", "#10400000"), // 빨강 — 마크업
|
||||
".css" or ".scss" or ".less" => ("#0AFF69B4", "#10400030"), // 핑크 — 스타일
|
||||
".sh" or ".bash" or ".ps1" or ".bat" => ("#0A90FF90", "#10003010"), // 연두 — 쉘
|
||||
".cpp" or ".c" or ".h" or ".hpp" or ".rs" => ("#0AD499FF", "#10181830"), // 보라 — C/C++/Rust
|
||||
".go" => ("#0A00BCD4", "#10001E2A"), // 청록 — Go
|
||||
_ => ("#0AFFFFFF", "#10181828"), // 기본
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var accentBrush = new SolidColorBrush(
|
||||
(WpfColor)ColorConverter.ConvertFromString(accent));
|
||||
var dimBrush = new SolidColorBrush(
|
||||
(WpfColor)ColorConverter.ConvertFromString(dim));
|
||||
|
||||
// TextScrollViewer 배경에 코드 색조 적용
|
||||
TextScrollViewer.Background = dimBrush;
|
||||
LineNumBg.Background = accentBrush;
|
||||
}
|
||||
catch { /* 색 변환 실패 무시 */ }
|
||||
}
|
||||
|
||||
// ─── PDF ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadPdfPreview(string path, FileInfo info)
|
||||
{
|
||||
FileTypeIcon.Text = "\uEA90";
|
||||
try
|
||||
{
|
||||
using var doc = PdfDocument.Open(path);
|
||||
var totalPages = doc.NumberOfPages;
|
||||
var sb = new StringBuilder();
|
||||
const int PreviewPages = 10;
|
||||
var pages = Math.Min(totalPages, PreviewPages);
|
||||
|
||||
for (int i = 1; i <= pages; i++)
|
||||
{
|
||||
var page = doc.GetPage(i);
|
||||
var pageText = page.Text;
|
||||
if (!string.IsNullOrWhiteSpace(pageText))
|
||||
{
|
||||
sb.AppendLine($"── 페이지 {i} ──");
|
||||
sb.AppendLine(pageText.Trim());
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPages > PreviewPages)
|
||||
sb.AppendLine($"… (전체 {totalPages}페이지 중 {PreviewPages}페이지 표시)");
|
||||
|
||||
PdfMetaText.Text = $"{totalPages}페이지 · {FormatSize(info.Length)}";
|
||||
PdfPreviewText.Text = sb.Length > 0 ? sb.ToString() : "(텍스트 추출 불가 — 스캔 PDF)";
|
||||
PdfScrollViewer.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowInfo("\uEA90", "PDF를 불러올 수 없습니다.", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Word (.docx) ────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadWordPreview(string path, FileInfo info)
|
||||
{
|
||||
FileTypeIcon.Text = "\uE8A5";
|
||||
OfficeTypeIcon.Text = "\uE8A5";
|
||||
try
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(path, false);
|
||||
var body = doc.MainDocumentPart?.Document?.Body;
|
||||
if (body == null) { ShowInfo("\uE8A5", "Word 문서를 열 수 없습니다.", path); return; }
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var para in body.Descendants<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
|
||||
{
|
||||
var line = para.InnerText;
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
sb.AppendLine(line);
|
||||
if (sb.Length > 8000) { sb.AppendLine("\n… (이하 생략)"); break; }
|
||||
}
|
||||
|
||||
OfficeMetaText.Text = $"Word 문서 · {FormatSize(info.Length)}";
|
||||
OfficePreviewText.Text = sb.Length > 0 ? sb.ToString() : "(내용 없음)";
|
||||
OfficeScrollViewer.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowInfo("\uE8A5", "Word 문서를 불러올 수 없습니다.", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Excel (.xlsx) ────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadExcelPreview(string path, FileInfo info)
|
||||
{
|
||||
FileTypeIcon.Text = "\uE9F9";
|
||||
OfficeTypeIcon.Text = "\uE9F9";
|
||||
try
|
||||
{
|
||||
using var doc = SpreadsheetDocument.Open(path, false);
|
||||
var wb = doc.WorkbookPart;
|
||||
if (wb == null) { ShowInfo("\uE9F9", "Excel 문서를 열 수 없습니다.", path); return; }
|
||||
|
||||
// 공유 문자열 테이블
|
||||
var sharedStrings = wb.SharedStringTablePart?.SharedStringTable
|
||||
.Elements<SharedStringItem>()
|
||||
.Select(x => x.InnerText)
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
int sheetCount = 0;
|
||||
|
||||
foreach (var sheetPart in wb.WorksheetParts.Take(3))
|
||||
{
|
||||
sheetCount++;
|
||||
sb.AppendLine($"── 시트 {sheetCount} ──");
|
||||
var rows = sheetPart.Worksheet.Descendants<Row>().Take(50);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = row.Elements<Cell>().Select(c =>
|
||||
{
|
||||
if (c.DataType?.Value == CellValues.SharedString &&
|
||||
int.TryParse(c.InnerText, out int idx) && idx < sharedStrings.Count)
|
||||
return sharedStrings[idx];
|
||||
return c.InnerText;
|
||||
});
|
||||
sb.AppendLine(string.Join("\t", cells));
|
||||
if (sb.Length > 8000) { sb.AppendLine("… (이하 생략)"); goto done; }
|
||||
}
|
||||
}
|
||||
done:
|
||||
OfficeMetaText.Text = $"Excel 문서 · {FormatSize(info.Length)}";
|
||||
OfficePreviewText.Text = sb.Length > 0 ? sb.ToString() : "(내용 없음)";
|
||||
OfficeScrollViewer.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowInfo("\uE9F9", "Excel 문서를 불러올 수 없습니다.", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 폴더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadFolderInfo(string path)
|
||||
{
|
||||
FileTypeIcon.Text = "\uE8B7";
|
||||
InfoTypeIcon.Text = "\uE8B7";
|
||||
FooterPath.Text = path;
|
||||
|
||||
try
|
||||
{
|
||||
var di = new DirectoryInfo(path);
|
||||
var items = di.GetFileSystemInfos();
|
||||
var files = items.Count(i => i is FileInfo);
|
||||
var dirs = items.Count(i => i is DirectoryInfo);
|
||||
|
||||
InfoTypeName.Text = di.Name;
|
||||
InfoSubText.Text = $"파일 {files}개 · 폴더 {dirs}개";
|
||||
FooterMeta.Text = $"수정: {di.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
InfoTypeName.Text = Path.GetFileName(path);
|
||||
InfoSubText.Text = "폴더";
|
||||
}
|
||||
|
||||
InfoPanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
// ─── 기타 파일 정보 ──────────────────────────────────────────────────────
|
||||
|
||||
private void LoadFileInfo(string path, string ext, FileInfo info)
|
||||
{
|
||||
var (icon, typeName) = ext.ToLowerInvariant() switch
|
||||
{
|
||||
".exe" or ".msi" or ".appx" => ("\uE756", "실행 파일"),
|
||||
".pdf" => ("\uEA90", "PDF 문서"),
|
||||
".docx" or ".doc" => ("\uE8A5", "Word 문서"),
|
||||
".xlsx" or ".xls" => ("\uE9F9", "Excel 문서"),
|
||||
".pptx" or ".ppt" => ("\uE8A5", "PowerPoint"),
|
||||
".zip" or ".7z" or ".rar" or ".gz" => ("\uED25", "압축 파일"),
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => ("\uE714", "동영상"),
|
||||
".mp3" or ".wav" or ".flac" or ".aac" => ("\uE767", "오디오"),
|
||||
".lnk" => ("\uE71B", "바로 가기"),
|
||||
".dll" or ".sys" => ("\uECAA", "시스템 파일"),
|
||||
_ => ("\uE7C3", ext.TrimStart('.').ToUpperInvariant() + " 파일")
|
||||
};
|
||||
|
||||
FileTypeIcon.Text = icon;
|
||||
InfoTypeIcon.Text = icon;
|
||||
InfoTypeName.Text = typeName;
|
||||
InfoSubText.Text = info.Name;
|
||||
InfoPanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
// ─── 오류/정보 패널 ───────────────────────────────────────────────────────
|
||||
|
||||
private void ShowInfo(string icon, string title, string sub)
|
||||
{
|
||||
InfoTypeIcon.Text = icon;
|
||||
InfoTypeName.Text = title;
|
||||
InfoSubText.Text = sub;
|
||||
InfoPanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
// ─── 파일 크기 포맷 ───────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1_024L => $"{bytes} B",
|
||||
< 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB",
|
||||
< 1_024L * 1_024 * 1_024 => $"{bytes / 1_024.0 / 1_024.0:F1} MB",
|
||||
_ => $"{bytes / 1_024.0 / 1_024.0 / 1_024.0:F1} GB"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user