Files
AX-Copilot-Codex/src/AxCopilot/Views/BatchRenameWindow.xaml.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00

427 lines
16 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Input;
using AxCopilot.Services;
namespace AxCopilot.Views;
/// <summary>
/// L5-5: 배치 파일 이름변경 창.
/// 여러 파일을 변수 패턴 또는 정규식으로 미리보기 후 일괄 적용합니다.
/// </summary>
public partial class BatchRenameWindow : Window
{
private readonly ObservableCollection<RenameEntry> _entries = new();
private bool _keepExt = true;
private bool _regexMode = false;
private int _startNumber = 1;
// ──────────────────────────────────────────────────────────────────────
public BatchRenameWindow()
{
InitializeComponent();
PreviewGrid.ItemsSource = _entries;
// 항목 변경 → 카운트 배지 + 빈 상태 갱신
_entries.CollectionChanged += (_, _) => RefreshUI();
// 드래그 앤 드롭
Drop += Window_Drop;
DragOver += Window_DragOver;
DragLeave += (_, _) => DropHintOverlay.Visibility = Visibility.Collapsed;
}
// ─── 외부에서 파일 목록 주입 ─────────────────────────────────────────
public void AddFiles(IEnumerable<string> paths)
{
foreach (var p in paths)
{
if (!File.Exists(p)) continue;
if (_entries.Any(e => string.Equals(e.OriginalPath, p, StringComparison.OrdinalIgnoreCase))) continue;
_entries.Add(new RenameEntry
{
OriginalPath = p,
OriginalName = Path.GetFileName(p)
});
}
UpdatePreviews();
}
// ─── 패턴 엔진 ───────────────────────────────────────────────────────
private static string ApplyPattern(
string pattern,
string originalName,
string ext,
int index,
int startNum,
bool keepExt,
bool regexMode)
{
if (regexMode)
{
// /regex/replacement/ 형식
var m = Regex.Match(pattern, @"^/(.+)/([^/]*)/?$");
if (m.Success)
{
var rxPat = m.Groups[1].Value;
var repl = m.Groups[2].Value;
try
{
var result = Regex.Replace(originalName, rxPat, repl);
if (keepExt && !string.IsNullOrEmpty(ext) &&
!result.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
result += ext;
return result;
}
catch { return "⚠ 정규식 오류"; }
}
// 패턴 불완전 → 원본 그대로
return originalName + ext;
}
// 변수 모드
var n = index + startNum;
var newName = pattern;
// {n:자릿수} — 자릿수 패딩
newName = Regex.Replace(newName, @"\{n:(\d+)\}", rm =>
{
if (int.TryParse(rm.Groups[1].Value, out var digits))
return n.ToString($"D{digits}");
return n.ToString();
});
// {date:format}
newName = Regex.Replace(newName, @"\{date:([^}]+)\}", rm =>
{
try { return DateTime.Today.ToString(rm.Groups[1].Value); }
catch { return DateTime.Today.ToString("yyyy-MM-dd"); }
});
newName = newName
.Replace("{n}", n.ToString())
.Replace("{name}", originalName)
.Replace("{orig}", originalName)
.Replace("{date}", DateTime.Today.ToString("yyyy-MM-dd"))
.Replace("{ext}", ext.TrimStart('.'));
// 확장자 유지
if (keepExt && !string.IsNullOrEmpty(ext))
{
if (!Path.HasExtension(newName))
newName += ext;
}
return newName;
}
private void UpdatePreviews()
{
var pattern = PatternBox?.Text ?? "{n}_{name}";
var startNum = _startNumber;
var keepExt = _keepExt;
var regexMode = _regexMode;
// 새 이름 계산
for (int i = 0; i < _entries.Count; i++)
{
var entry = _entries[i];
var ext = Path.GetExtension(entry.OriginalPath);
var nameNoExt = Path.GetFileNameWithoutExtension(entry.OriginalPath);
entry.NewName = ApplyPattern(pattern, nameNoExt, ext, i, startNum, keepExt, regexMode);
}
// 충돌 감지 (같은 폴더 내 같은 새 이름 OR 기존 파일)
var grouped = _entries
.GroupBy(e => Path.Combine(
Path.GetDirectoryName(e.OriginalPath) ?? "",
e.NewName),
StringComparer.OrdinalIgnoreCase);
var conflictPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var g in grouped)
{
if (g.Count() > 1)
foreach (var e in g) conflictPaths.Add(e.OriginalPath);
}
foreach (var entry in _entries)
{
var destPath = Path.Combine(
Path.GetDirectoryName(entry.OriginalPath) ?? "",
entry.NewName);
// 충돌: 동명 중복 OR 목적지 파일이 이미 존재(원본 제외)
var nameConflict = conflictPaths.Contains(entry.OriginalPath);
var destExists = File.Exists(destPath) &&
!string.Equals(destPath, entry.OriginalPath, StringComparison.OrdinalIgnoreCase);
entry.HasConflict = nameConflict || destExists;
}
RefreshUI();
}
private void RefreshUI()
{
var count = _entries.Count;
var conflicts = _entries.Count(e => e.HasConflict);
FileCountBadge.Text = count > 0 ? $"— {count}개 파일" : "";
EmptyState.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
PreviewGrid.Visibility = count == 0 ? Visibility.Collapsed : Visibility.Visible;
ConflictLabel.Text = conflicts > 0 ? $"⚠ 충돌 {conflicts}개" : "";
}
// ─── 타이틀바 ────────────────────────────────────────────────────────
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 PatternBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
=> UpdatePreviews();
private void StartNumberBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
if (int.TryParse(StartNumberBox.Text, out var n) && n >= 0)
_startNumber = n;
UpdatePreviews();
}
// ─── 모드 선택 ───────────────────────────────────────────────────────
private void BtnModeVar_Click(object sender, MouseButtonEventArgs e)
{
_regexMode = false;
SetModeUI();
UpdatePreviews();
}
private void BtnModeRegex_Click(object sender, MouseButtonEventArgs e)
{
_regexMode = true;
SetModeUI();
if (string.IsNullOrWhiteSpace(PatternBox.Text) || !PatternBox.Text.StartsWith('/'))
PatternBox.Text = "/(old)/(new)/";
UpdatePreviews();
}
private void SetModeUI()
{
var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush
?? System.Windows.Media.Brushes.DodgerBlue;
var sec = TryFindResource("SecondaryText") as System.Windows.Media.Brush
?? System.Windows.Media.Brushes.Gray;
var dimBg = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
if (_regexMode)
{
BtnModeVar.Background = dimBg;
BtnModeVarText.Foreground = sec;
BtnModeRegex.Background = accent;
BtnModeRegexText.Foreground = System.Windows.Media.Brushes.White;
}
else
{
BtnModeVar.Background = accent;
BtnModeVarText.Foreground = System.Windows.Media.Brushes.White;
BtnModeRegex.Background = dimBg;
BtnModeRegexText.Foreground = sec;
}
}
// ─── 확장자 유지 토글 ────────────────────────────────────────────────
private void ExtToggle_Click(object sender, MouseButtonEventArgs e)
{
_keepExt = !_keepExt;
var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush
?? System.Windows.Media.Brushes.DodgerBlue;
var gray = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF));
ExtToggle.Background = _keepExt ? accent : gray;
ExtThumb.HorizontalAlignment = _keepExt ? HorizontalAlignment.Right : HorizontalAlignment.Left;
ExtThumb.Margin = _keepExt ? new Thickness(0, 0, 1, 0) : new Thickness(1, 0, 0, 0);
UpdatePreviews();
}
// ─── 힌트 팝업 ───────────────────────────────────────────────────────
private void BtnHint_Click(object sender, MouseButtonEventArgs e)
=> HintPopup.IsOpen = !HintPopup.IsOpen;
// ─── 파일/폴더 추가 ──────────────────────────────────────────────────
private void BtnAddFolder_Click(object sender, MouseButtonEventArgs e)
{
using var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "파일이 있는 폴더를 선택하세요",
ShowNewFolderButton = false
};
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
var files = Directory.GetFiles(dlg.SelectedPath);
Array.Sort(files);
AddFiles(files);
}
private void BtnAddFiles_Click(object sender, MouseButtonEventArgs e)
{
using var dlg = new System.Windows.Forms.OpenFileDialog
{
Title = "이름변경할 파일 선택",
Multiselect = true,
Filter = "모든 파일 (*.*)|*.*"
};
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
var files = dlg.FileNames.OrderBy(f => f).ToArray();
AddFiles(files);
}
private void BtnRemoveSelected_Click(object sender, MouseButtonEventArgs e)
{
var selected = PreviewGrid.SelectedItems.OfType<RenameEntry>().ToList();
foreach (var item in selected) _entries.Remove(item);
UpdatePreviews();
}
private void BtnClearAll_Click(object sender, MouseButtonEventArgs e)
{
_entries.Clear();
RefreshUI();
}
// ─── 적용 ────────────────────────────────────────────────────────────
private void BtnApply_Click(object sender, MouseButtonEventArgs e)
{
var toRename = _entries
.Where(en => en.NewName != en.OriginalName && !en.HasConflict)
.ToList();
if (toRename.Count == 0)
{
NotificationService.Notify("배치 이름변경", "변경할 파일이 없습니다. 패턴을 확인하거나 충돌을 해소하세요.");
return;
}
int ok = 0, fail = 0;
foreach (var entry in toRename)
{
try
{
var destPath = Path.Combine(
Path.GetDirectoryName(entry.OriginalPath) ?? "",
entry.NewName);
File.Move(entry.OriginalPath, destPath);
// 성공 후 엔트리 갱신 (새 경로로 업데이트)
entry.OriginalPath = destPath;
entry.OriginalName = entry.NewName;
ok++;
}
catch (Exception ex)
{
LogService.Warn($"배치 이름변경 실패: {entry.OriginalPath} → {entry.NewName} — {ex.Message}");
fail++;
}
}
// 미리보기 갱신 (적용 후 남은 항목)
UpdatePreviews();
var msg = fail > 0
? $"{ok}개 이름변경 완료, {fail}개 실패"
: $"{ok}개 파일 이름변경 완료";
NotificationService.Notify("AX Copilot", msg);
LogService.Info($"배치 이름변경: {msg}");
}
// ─── 드래그 앤 드롭 ──────────────────────────────────────────────────
private void Window_DragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
DropHintOverlay.Visibility = Visibility.Visible;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private void Window_Drop(object sender, DragEventArgs e)
{
DropHintOverlay.Visibility = Visibility.Collapsed;
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
var dropped = (string[])e.Data.GetData(DataFormats.FileDrop);
var files = new List<string>();
foreach (var p in dropped)
{
if (File.Exists(p))
{
files.Add(p);
}
else if (Directory.Exists(p))
{
files.AddRange(Directory.GetFiles(p).OrderBy(f => f));
}
}
AddFiles(files);
}
}
// ─── RenameEntry 모델 ────────────────────────────────────────────────────────
public class RenameEntry : INotifyPropertyChanged
{
public string OriginalPath { get; set; } = "";
private string _originalName = "";
public string OriginalName
{
get => _originalName;
set { _originalName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); }
}
private string _newName = "";
public string NewName
{
get => _newName;
set { _newName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); }
}
private bool _hasConflict;
public bool HasConflict
{
get => _hasConflict;
set { _hasConflict = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); }
}
public string StatusText =>
HasConflict
? "⚠ 충돌"
: OriginalName == NewName
? "─"
: "✓";
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}