Compare commits

...

2 Commits

Author SHA1 Message Date
d4a1532d81 [Phase L4] 파일탐색기·QuickLook·단위변환 단축 3종 완료
FileBrowserHandler (185줄) — L4-1 인라인 파일 탐색기:
- Handlers/FileBrowserHandler.cs: Prefix=null, 경로 패턴 감지(C:\, D:\, \, ~\)
- 폴더/파일 나열: 상위폴더(..) + 하위폴더 40개 + 파일 30개
- 확장자별 MDL2 아이콘, 파일 크기 포맷(B/KB/MB/GB), 필터링 지원
- FileBrowserEntry(Path, IsFolder) record 정의
- App.xaml.cs: Phase L4 섹션에 FileBrowserHandler 등록

CommandResolver (18줄 추가) — 경로 쿼리 우선 처리:
- 퍼지 검색 전 IsPathQuery() 감지 → 파일탐색기 단독 결과 반환(항목 수 제한 없음)
- FileBrowserEntry 실행 라우팅 → ExecuteNullPrefixAsync 위임

LauncherWindow.Keyboard.cs (41줄 추가) — 키보드 탐색:
- Key.Right: FileBrowserEntry {IsFolder:true} 선택 시 해당 경로로 InputText 업데이트
- Key.Left: 경로 쿼리 상태에서 상위 폴더로 이동(Path.GetDirectoryName)
- 기존 → 키 액션모드 진입 로직 유지

QuickLookWindow (L4-2 F3 미리보기 강화):
- XAML: 줄번호 열(LineNumBg+LineNumbers), PDF 패널(빨강 배지), Office 패널(파랑 배지) 추가
- Code-behind: PDF(PdfPig), Word(OpenXml), Excel(OpenXml) 미리보기 구현
- ApplyCodeStyle(): 언어별 배경 색조(C#=파랑, Python=녹색, JS=앰버 등)
- 빌드: 경고 0, 오류 0
2026-04-04 11:23:18 +09:00
75cb4ba6e9 [Phase L4-4/L4-6] 검색 히스토리 + 계산기 단위변환 단축 문법
L4-4 검색 히스토리 (↑/↓ 키 탐색):
- Services/SearchHistoryService.cs (신규 100줄): 50개 FIFO JSON 저장
  Add() · GetAll() · Clear(). 2자 미만/중복 최상단 무시
- ViewModels/LauncherViewModel.cs:
  _historyIndex·_isHistoryNavigation 필드 추가
  NavigateHistoryPrev() / NavigateHistoryNext() / SetInputFromHistory()
  InputText setter: 직접 입력 시 _historyIndex 초기화
  ExecuteSelectedAsync: 실행 전 히스토리 저장 (2자 이상)
  OnShown: _historyIndex = -1 초기화
- Views/LauncherWindow.Keyboard.cs:
  Key.Up/Down — Results.Count==0 분기: 히스토리 탐색 / 목록 탐색 분기

L4-3 클립보드 핀/카테고리: 기존 완전 구현 확인 (IsPinned, Category,
  TogglePin, Ctrl+P, #pin/#url/#코드/#경로 필터)

L4-6 계산기 단위 변환 단축 문법:
- Handlers/UnitConverter.cs:
  AutoSuggest(): "20km", "100f", "5lb" 등 목표 없이 주요 단위 자동 제안
  _suggestions 테이블: 길이/무게/속도/데이터/온도 40개 단위 매핑
  DateShortcut: "today+30d", "today-7w" → = 접두어에서 날짜 계산
- Handlers/CalculatorHandler.cs:
  DateShortcut.TryMatch 분기 추가 (통화 감지 전)
  UnitConverter.AutoSuggest 분기 추가 (명시 변환 후)
- 힌트 텍스트: "20km · 100°F · today+30d" 추가

docs/LAUNCHER_ROADMAP.md: Phase L4 계획 테이블 추가

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:15:11 +09:00
11 changed files with 1227 additions and 8 deletions

View File

@@ -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 해결)
| 항목 | 상태 |

View File

@@ -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();

View File

@@ -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)
{

View File

@@ -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
{

View 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);

View File

@@ -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;
}
}

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

View File

@@ -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);

View File

@@ -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,13 +151,44 @@ 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)
{
_vm.EnterActionMode(_vm.SelectedItem!);
e.Handled = true;
// 파일 탐색기: 선택된 항목이 폴더이면 해당 경로로 진입
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;
@@ -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)
{

View 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="&#xE7C3;"
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="&#xE711;" 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="&#xEA90;"
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="&#xE7C3;"
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>

View 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"
};
}