런처 Agent Compare 기능 1차 이식 및 현재 런처 구조 연결

- Agent Compare 기준으로 런처 빠른 실행 칩, 검색 히스토리 탐색, 선택 항목 미리보기 패널을 현재 런처에 이식
- 하단 위젯 바, QuickLook(F3), 화면 OCR(F4), 관련 서비스/partial 파일을 현재 LauncherWindow/LauncherViewModel 구조에 연결
- UsageRankingService 상위 항목 조회와 SearchHistoryService를 추가해 실행 상위 경로/검색 기록이 실제 런처 동작에 반영되도록 정리
- README.md, docs/DEVELOPMENT.md에 이식 범위와 검증 결과를 2026-04-05 11:58 (KST) 기준으로 기록

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
This commit is contained in:
2026-04-05 11:51:43 +09:00
parent 0336904258
commit f7cafe0cfc
17 changed files with 2518 additions and 24 deletions

View File

@@ -0,0 +1,67 @@
using System.Windows;
using System.Windows.Input;
using AxCopilot.Models;
namespace AxCopilot.Views;
public partial class LauncherWindow
{
private QuickLookWindow? _quickLookWindow;
private async void QuickActionChip_Click(object sender, MouseButtonEventArgs e)
{
if (((FrameworkElement)sender).DataContext is not QuickActionChip chip)
return;
var expanded = Environment.ExpandEnvironmentVariables(chip.Path);
Hide();
try
{
await Task.Run(() =>
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(expanded)
{
UseShellExecute = true
}));
_ = Task.Run(() => Services.UsageRankingService.RecordExecution(chip.Path));
}
catch (Exception ex)
{
Services.LogService.Error($"빠른 실행 칩 열기 실패: {expanded} - {ex.Message}");
}
}
internal void ToggleQuickLook()
{
if (_quickLookWindow != null)
{
_quickLookWindow.Close();
_quickLookWindow = null;
return;
}
if (_vm.SelectedItem?.Data is not Services.IndexEntry indexEntry)
return;
var path = Environment.ExpandEnvironmentVariables(indexEntry.Path);
if (!System.IO.File.Exists(path) && !System.IO.Directory.Exists(path))
return;
var qlLeft = Left + ActualWidth + 8;
var qlTop = Top;
var screen = System.Windows.Forms.Screen.FromHandle(
new System.Windows.Interop.WindowInteropHelper(this).Handle);
if (qlLeft + 400 > screen.WorkingArea.Right)
qlLeft = Left - 408;
_quickLookWindow = new QuickLookWindow(path, this)
{
Left = qlLeft,
Top = qlTop
};
_quickLookWindow.Closed += (_, _) => _quickLookWindow = null;
_quickLookWindow.Show();
}
}

View File

@@ -0,0 +1,209 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class LauncherWindow
{
private DispatcherTimer? _widgetTimer;
private static readonly SolidColorBrush DotOnline = new(Color.FromRgb(0x10, 0xB9, 0x81));
private static readonly SolidColorBrush DotOffline = new(Color.FromRgb(0x9E, 0x9E, 0x9E));
private int _widgetBatteryTick;
private int _widgetWeatherTick;
internal void StartWidgetUpdates()
{
var settings = CurrentApp?.SettingsService?.Settings;
PerformanceMonitorService.Instance.StartPolling();
ServerStatusService.Instance.Start(settings);
PomodoroService.Instance.StateChanged -= OnPomoStateChanged;
PomodoroService.Instance.StateChanged += OnPomoStateChanged;
ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged;
ServerStatusService.Instance.StatusChanged += OnServerStatusChanged;
_vm.UpdateWidgets();
UpdateServerDots();
UpdateBatteryWidget();
_ = RefreshWeatherAsync();
if (_widgetTimer == null)
{
_widgetTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
_widgetTimer.Tick += (_, _) =>
{
_vm.UpdateWidgets();
UpdateServerDots();
if (_vm.Widget_PerfText.Length > 0 && _widgetBatteryTick++ % 30 == 0)
UpdateBatteryWidget();
if (_widgetWeatherTick++ % 120 == 0)
_ = RefreshWeatherAsync();
};
}
_widgetTimer.Start();
UpdatePomoWidgetStyle();
}
internal void StopWidgetUpdates()
{
_widgetTimer?.Stop();
PerformanceMonitorService.Instance.StopPolling();
PomodoroService.Instance.StateChanged -= OnPomoStateChanged;
ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged;
}
private void OnPomoStateChanged(object? sender, EventArgs e)
{
Dispatcher.InvokeAsync(() =>
{
_vm.UpdateWidgets();
UpdatePomoWidgetStyle();
});
}
private void OnServerStatusChanged(object? sender, EventArgs e)
=> Dispatcher.InvokeAsync(UpdateServerDots);
private void UpdateServerDots()
{
var server = ServerStatusService.Instance;
if (OllamaStatusDot != null)
OllamaStatusDot.Fill = server.OllamaOnline ? DotOnline : DotOffline;
if (LlmStatusDot != null)
LlmStatusDot.Fill = server.LlmOnline ? DotOnline : DotOffline;
if (McpStatusDot != null)
McpStatusDot.Fill = server.McpOnline ? DotOnline : DotOffline;
}
private void UpdatePomoWidgetStyle()
{
if (WgtPomo == null)
return;
var running = PomodoroService.Instance.IsRunning;
WgtPomo.Background = running
? new SolidColorBrush(Color.FromArgb(0x1E, 0xF5, 0x9E, 0x0B))
: new SolidColorBrush(Color.FromArgb(0x0D, 0xF5, 0x9E, 0x0B));
}
private void WgtPerf_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "info ";
InputBox?.Focus();
}
private void WgtPomo_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "pomo ";
InputBox?.Focus();
}
private void WgtNote_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "note ";
InputBox?.Focus();
}
private void WgtServer_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var settings = CurrentApp?.SettingsService?.Settings;
ServerStatusService.Instance.Refresh(settings);
_vm.InputText = "port";
InputBox?.Focus();
}
private void WgtWeather_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true;
if (!internalMode)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("https://wttr.in") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"날씨 페이지 열기 실패: {ex.Message}");
}
}
else
{
WeatherWidgetService.Invalidate();
_ = RefreshWeatherAsync();
}
}
private void WgtCal_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ms-clock:") { UseShellExecute = true });
}
catch
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("outlookcal:") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"달력 열기 실패: {ex.Message}");
}
}
}
private void WgtBattery_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ms-settings:powersleep") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"전원 설정 열기 실패: {ex.Message}");
}
}
private void UpdateBatteryWidget()
{
try
{
var power = System.Windows.Forms.SystemInformation.PowerStatus;
var pct = power.BatteryLifePercent;
if (pct > 1.0f || pct < 0f)
{
_vm.Widget_BatteryVisible = false;
return;
}
_vm.Widget_BatteryVisible = true;
var pctInt = (int)(pct * 100);
var charging = power.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
_vm.Widget_BatteryText = charging ? $"{pctInt}% 충전" : $"{pctInt}%";
_vm.Widget_BatteryIcon = charging ? "\uE83E"
: pctInt >= 85 ? "\uEBA7"
: pctInt >= 70 ? "\uEBA5"
: pctInt >= 50 ? "\uEBA3"
: pctInt >= 25 ? "\uEBA1"
: "\uEBA0";
}
catch (Exception ex)
{
LogService.Warn($"배터리 위젯 갱신 실패: {ex.Message}");
_vm.Widget_BatteryVisible = false;
}
}
private async Task RefreshWeatherAsync()
{
var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true;
await WeatherWidgetService.RefreshAsync(internalMode);
await Dispatcher.InvokeAsync(() => { _vm.Widget_WeatherText = WeatherWidgetService.CachedText; });
}
}

View File

@@ -18,6 +18,8 @@
WindowStartupLocation="Manual"
Loaded="Window_Loaded"
Deactivated="Window_Deactivated"
LocationChanged="Window_LocationChanged"
IsVisibleChanged="Window_IsVisibleChanged"
PreviewKeyDown="Window_PreviewKeyDown"
KeyDown="Window_KeyDown">
@@ -201,10 +203,16 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ─── 입력 영역 ─── -->
<Grid Grid.Row="0" Margin="20,16,20,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
@@ -413,8 +421,57 @@
Foreground="{DynamicResource HintText}"
Margin="3,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<ItemsControl Grid.Row="1"
Grid.ColumnSpan="3"
ItemsSource="{Binding QuickActionItems}"
Margin="0,10,0,0"
Visibility="{Binding ShowQuickActions, Converter={StaticResource BoolToVisibilityConverter}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,6,6"
Padding="9,5"
CornerRadius="10"
Cursor="Hand"
Background="{Binding Background}"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
MouseLeftButtonUp="QuickActionChip_Click">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource AccentColor}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Symbol}"
FontFamily="Segoe MDL2 Assets"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
Margin="0,0,5,0"/>
<TextBlock Text="{Binding Title}"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="11"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
MaxWidth="100"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<!-- ─── 파일 액션 모드 breadcrumb 바 ─── -->
@@ -691,9 +748,31 @@
</ListView.ItemTemplate>
</ListView>
<Border Grid.Row="5"
x:Name="PreviewPanel"
Visibility="{Binding HasPreview, Converter={StaticResource BoolToVisibilityConverter}}"
Background="{DynamicResource ItemBackground}"
CornerRadius="8"
Margin="10,0,10,8"
Padding="12,8"
MaxHeight="100">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBar}"/>
</ScrollViewer.Resources>
<TextBlock Text="{Binding PreviewText}"
FontFamily="Segoe UI Mono, Consolas, Malgun Gothic"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
TextWrapping="Wrap"
LineHeight="16"/>
</ScrollViewer>
</Border>
<!-- ─── 인덱싱 상태 바 ─── -->
<TextBlock x:Name="IndexStatusText"
Grid.Row="5"
Grid.Row="6"
Visibility="Collapsed"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
@@ -701,9 +780,179 @@
Margin="0,0,0,8"
Opacity="0.7"/>
<Border x:Name="WidgetBar"
Grid.Row="7"
BorderBrush="{DynamicResource SeparatorColor}"
BorderThickness="0,1,0,0"
Padding="10,7,10,9">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="WgtPerf" Grid.Column="0"
CornerRadius="5" Padding="8,5"
Background="#0D60A5FA"
Cursor="Hand"
MouseLeftButtonUp="WgtPerf_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE950;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_PerfText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtPomo" Grid.Column="2"
CornerRadius="5" Padding="8,5"
Background="#0DF59E0B"
Cursor="Hand"
MouseLeftButtonUp="WgtPomo_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE916;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#F59E0B"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock x:Name="WgtPomoText"
Text="{Binding Widget_PomoText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtNote" Grid.Column="4"
CornerRadius="5" Padding="8,5"
Background="#0D8B5CF6"
Cursor="Hand"
MouseLeftButtonUp="WgtNote_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE70B;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#8B5CF6"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_NoteText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtServer" Grid.Column="6"
CornerRadius="5" Padding="8,5"
Background="#0D10B981"
Cursor="Hand"
MouseLeftButtonUp="WgtServer_Click">
<StackPanel Orientation="Horizontal" x:Name="WgtServerContent">
<Ellipse x:Name="OllamaStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock Text="Ollama"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Ellipse x:Name="LlmStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock Text="API"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Ellipse x:Name="McpStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock x:Name="McpNameText"
Text="{Binding Widget_McpName}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="WgtWeather" Grid.Column="0"
CornerRadius="5" Padding="8,5"
Background="#0D3B82F6"
Cursor="Hand"
MouseLeftButtonUp="WgtWeather_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE708;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_WeatherText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="100"/>
</StackPanel>
</Border>
<Border x:Name="WgtCal" Grid.Column="2"
CornerRadius="5" Padding="8,5"
Background="#0DEC4899"
Cursor="Hand"
MouseLeftButtonUp="WgtCal_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8BF;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#EC4899"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_CalText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtBattery" Grid.Column="4"
CornerRadius="5" Padding="8,5"
Background="#0D10B981"
Cursor="Hand"
MouseLeftButtonUp="WgtBattery_Click"
Visibility="{Binding Widget_BatteryVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Widget_BatteryIcon}"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#10B981"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_BatteryText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- ─── 토스트 오버레이 ─── -->
<Border x:Name="ToastOverlay"
Grid.Row="4" Grid.RowSpan="2"
Grid.Row="4" Grid.RowSpan="4"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,12"

View File

@@ -14,6 +14,8 @@ namespace AxCopilot.Views;
public partial class LauncherWindow : Window
{
private static App? CurrentApp => System.Windows.Application.Current as App;
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
@@ -34,6 +36,7 @@ public partial class LauncherWindow : Window
private readonly LauncherViewModel _vm;
private System.Windows.Threading.DispatcherTimer? _indexStatusTimer;
private System.Windows.Threading.DispatcherTimer? _toastTimer;
/// <summary>Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입)</summary>
public Action? OpenSettingsAction { get; set; }
@@ -71,16 +74,9 @@ public partial class LauncherWindow : Window
Dispatcher.BeginInvoke(() =>
{
var svc = app.IndexService;
IndexStatusText.Text = $"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)";
IndexStatusText.Visibility = Visibility.Visible;
// 기존 타이머 정리 후 5초 후 자동 숨기기
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_indexStatusTimer.Tick += (_, _) => { IndexStatusText.Visibility = Visibility.Collapsed; _indexStatusTimer.Stop(); };
_indexStatusTimer.Start();
ShowIndexStatus(
$"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)",
TimeSpan.FromSeconds(5));
});
};
}
@@ -88,7 +84,7 @@ public partial class LauncherWindow : Window
private void Window_Loaded(object sender, RoutedEventArgs e)
{
CenterOnScreen();
ApplyInitialPlacement();
ApplyTheme();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () =>
{
@@ -151,7 +147,7 @@ public partial class LauncherWindow : Window
_vm.OnShown();
_vm.InputText = "";
base.Show();
CenterOnScreen();
ApplyInitialPlacement();
AnimateIn();
// 포그라운드 강제 + 포커스를 3단계로 보장
@@ -695,6 +691,64 @@ public partial class LauncherWindow : Window
};
}
private void ApplyInitialPlacement()
{
if (!TryRestoreRememberedPosition())
CenterOnScreen();
UpdateRememberedPositionCache();
}
private bool TryRestoreRememberedPosition()
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null || !launcher.RememberPosition) return false;
if (launcher.LastLeft < 0 || launcher.LastTop < 0) return false;
var rememberPoint = new Point(launcher.LastLeft, launcher.LastTop);
if (!IsVisibleOnAnyScreen(rememberPoint)) return false;
Left = launcher.LastLeft;
Top = launcher.LastTop;
return true;
}
private static bool IsVisibleOnAnyScreen(Point point)
{
foreach (var screen in FormsScreen.AllScreens)
{
var bounds = screen.WorkingArea;
if (point.X >= bounds.Left && point.X <= bounds.Right - 40 &&
point.Y >= bounds.Top && point.Y <= bounds.Bottom - 40)
{
return true;
}
}
return false;
}
private void UpdateRememberedPositionCache()
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null || !launcher.RememberPosition || !IsLoaded) return;
launcher.LastLeft = Left;
launcher.LastTop = Top;
}
private void SaveRememberedPosition()
{
var app = CurrentApp;
var settingsService = app?.SettingsService;
if (settingsService == null) return;
var launcher = settingsService.Settings.Launcher;
if (launcher == null || !launcher.RememberPosition || !IsLoaded) return;
UpdateRememberedPositionCache();
settingsService.Save();
}
// 지원 테마 이름 목록
private static readonly HashSet<string> KnownThemes =
new(StringComparer.OrdinalIgnoreCase)
@@ -1031,8 +1085,7 @@ public partial class LauncherWindow : Window
{
var app = (App)System.Windows.Application.Current;
_ = app.IndexService?.BuildAsync(CancellationToken.None);
IndexStatusText.Text = "⟳ 인덱스 재구축 중…";
IndexStatusText.Visibility = Visibility.Visible;
ShowIndexStatus("⟳ 인덱스 재구축 중…", TimeSpan.FromSeconds(8));
e.Handled = true;
return;
}
@@ -1256,6 +1309,36 @@ public partial class LauncherWindow : Window
return;
}
// ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ───────────────────────
if (e.Key == Key.F3)
{
ToggleQuickLook();
e.Handled = true;
return;
}
// ─── F4 → 화면 영역 OCR 즉시 실행 ─────────────────────────────────
if (e.Key == Key.F4)
{
Hide();
_ = Task.Run(async () =>
{
try
{
var handler = new Handlers.OcrHandler();
var item = new SDK.LauncherItem(
"화면 영역 텍스트 추출", "", null, "__ocr_region__");
await handler.ExecuteAsync(item, CancellationToken.None);
}
catch (Exception ex)
{
Services.LogService.Error($"F4 OCR 실행 오류: {ex.Message}");
}
});
e.Handled = true;
return;
}
// ─── Ctrl+1~9 → n번째 결과 즉시 실행 ───────────────────────────────
if (mod == ModifierKeys.Control)
{
@@ -1295,6 +1378,8 @@ public partial class LauncherWindow : Window
"[ 기능 ]",
"F1 도움말",
"F2 파일 이름 바꾸기",
"F3 파일 빠른 미리보기",
"F4 화면 OCR",
"F5 인덱스 새로 고침",
"Delete 항목 제거",
"Ctrl+, 설정",
@@ -1331,14 +1416,14 @@ public partial class LauncherWindow : Window
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
fadeIn.Begin(this);
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
_toastTimer?.Stop();
_toastTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
_indexStatusTimer.Tick += (_, _) =>
_toastTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
_toastTimer.Stop();
// 페이드아웃 후 Collapsed
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
@@ -1350,6 +1435,24 @@ public partial class LauncherWindow : Window
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
};
_toastTimer.Start();
}
private void ShowIndexStatus(string message, TimeSpan duration)
{
IndexStatusText.Text = message;
IndexStatusText.Visibility = Visibility.Visible;
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = duration
};
_indexStatusTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
IndexStatusText.Visibility = Visibility.Collapsed;
};
_indexStatusTimer.Start();
}
@@ -1560,6 +1663,28 @@ public partial class LauncherWindow : Window
if (_vm.CloseOnFocusLost) Hide();
}
private void Window_LocationChanged(object sender, EventArgs e)
{
UpdateRememberedPositionCache();
}
private void Window_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is not bool isVisible)
return;
if (isVisible)
{
StartWidgetUpdates();
return;
}
_quickLookWindow?.Close();
_quickLookWindow = null;
StopWidgetUpdates();
SaveRememberedPosition();
}
private void ScrollToSelected()
{
if (_vm.SelectedItem != null)

View File

@@ -0,0 +1,161 @@
<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">
<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"
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"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="8"/>
</ScrollViewer>
<ScrollViewer x:Name="TextScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<TextBlock x:Name="PreviewText"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</ScrollViewer>
<ScrollViewer x:Name="PdfScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<TextBlock x:Name="PdfPreviewText"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</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"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource PrimaryText}"
HorizontalAlignment="Center"
Margin="0,10,0,0"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="320"/>
<TextBlock x:Name="InfoSubText"
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"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<TextBlock x:Name="FooterMeta"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,143 @@
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using UglyToad.PdfPig;
namespace AxCopilot.Views;
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"
};
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))
{
ShowInfo("\uE838", "폴더", path);
return;
}
if (!File.Exists(path))
{
ShowInfo("\uE783", "파일을 찾을 수 없습니다.", path);
return;
}
var info = new FileInfo(path);
var ext = Path.GetExtension(path);
FooterPath.Text = path;
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}";
if (ImageExts.Contains(ext))
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = fs;
image.EndInit();
image.Freeze();
PreviewImage.Source = image;
ImageScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uE91B";
return;
}
if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
{
using var doc = PdfDocument.Open(path);
var page = doc.NumberOfPages > 0 ? doc.GetPage(1).Text : "";
PdfPreviewText.Text = page.Length > 1200 ? page[..1200] + "…" : page;
PdfScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uEA90";
return;
}
if (TextExts.Contains(ext))
{
string text;
try
{
text = File.ReadAllText(path, Encoding.UTF8);
}
catch
{
text = File.ReadAllText(path);
}
PreviewText.Text = text.Length > 4000 ? text[..4000] + "\n…" : text;
TextScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uE8A5";
return;
}
ShowInfo("\uE7C3", $"파일 · {ext.TrimStart('.').ToUpperInvariant()}", path);
}
catch (Exception ex)
{
ShowInfo("\uEA39", "미리보기를 열지 못했습니다.", ex.Message);
}
}
private void ShowInfo(string icon, string title, string detail)
{
InfoTypeIcon.Text = icon;
InfoTypeName.Text = title;
InfoSubText.Text = detail;
InfoPanel.Visibility = Visibility.Visible;
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1048576 => $"{bytes / 1024.0:F1} KB",
< 1073741824 => $"{bytes / 1048576.0:F1} MB",
_ => $"{bytes / 1073741824.0:F2} GB"
};
}