using System.IO; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; namespace AxCopilot.Views; public partial class ChatWindow { private sealed record FileMentionQuery(string Token, int Start, int Length); private sealed record FileMentionCandidate(string RelativePath, string FileName, int Score); private static readonly Regex FileMentionTokenRegex = new( "(?[^\\s\\\"'`()\\[\\]{}<>]{2,})$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly HashSet FileMentionIgnoredDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode", "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", "packages", ".nuget", "TestResults", "coverage", ".next", "target", ".gradle", ".cargo", }; private readonly object _fileMentionIndexLock = new(); private List _fileMentionIndexedPaths = new(); private string? _fileMentionIndexedFolder; private DateTime _fileMentionIndexBuiltAtUtc = DateTime.MinValue; private CancellationTokenSource? _fileMentionIndexCts; private bool _fileMentionIndexBuildPending; private const int FileMentionIndexLimit = 4000; // 키입력마다 정규식+후보 필터링이 도는 것을 막기 위한 디바운스 타이머. private System.Windows.Threading.DispatcherTimer? _fileMentionDebounceTimer; private string _fileMentionPendingText = ""; private void ScheduleFileMentionRefresh(string text) { _fileMentionPendingText = text; if (_fileMentionDebounceTimer == null) { _fileMentionDebounceTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120), }; _fileMentionDebounceTimer.Tick += (_, _) => { _fileMentionDebounceTimer!.Stop(); try { RefreshFileMentionSuggestions(_fileMentionPendingText); } catch { } }; } _fileMentionDebounceTimer.Stop(); _fileMentionDebounceTimer.Start(); } private void RefreshFileMentionSuggestions(string text) { if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(GetCurrentWorkFolder())) { HideFileMentionSuggestions(); return; } if (SlashPopup?.IsOpen == true && text.StartsWith("/") && !text.Contains(' ')) { HideFileMentionSuggestions(); return; } var query = TryExtractFileMentionQuery(text); if (query == null) { HideFileMentionSuggestions(); return; } var workFolder = GetCurrentWorkFolder(); if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)) { HideFileMentionSuggestions(); return; } List indexSnapshot; bool canUseCurrentIndex; lock (_fileMentionIndexLock) { canUseCurrentIndex = string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase) && (DateTime.UtcNow - _fileMentionIndexBuiltAtUtc) < TimeSpan.FromMinutes(3) && _fileMentionIndexedPaths.Count > 0; indexSnapshot = canUseCurrentIndex ? new List(_fileMentionIndexedPaths) : []; } if (!canUseCurrentIndex) { RenderFileMentionSuggestions(query.Token, [], isLoading: true); EnsureFileMentionIndexAsync(workFolder); return; } var candidates = FindFileMentionCandidates(indexSnapshot, query.Token); RenderFileMentionSuggestions(query.Token, candidates, isLoading: false); } private void EnsureFileMentionIndexAsync(string workFolder) { if (_fileMentionIndexBuildPending && string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase)) return; _fileMentionIndexCts?.Cancel(); _fileMentionIndexCts = new CancellationTokenSource(); var localCts = _fileMentionIndexCts; _fileMentionIndexBuildPending = true; _fileMentionIndexedFolder = workFolder; _ = Task.Run(() => { var indexedPaths = BuildFileMentionIndex(workFolder, localCts.Token); if (localCts.IsCancellationRequested) return; lock (_fileMentionIndexLock) { _fileMentionIndexedPaths = indexedPaths; _fileMentionIndexedFolder = workFolder; _fileMentionIndexBuiltAtUtc = DateTime.UtcNow; } Dispatcher?.Invoke(() => { _fileMentionIndexBuildPending = false; RefreshFileMentionSuggestions(InputBox?.Text ?? ""); }); }, localCts.Token).ContinueWith(_ => { Dispatcher?.Invoke(() => { _fileMentionIndexBuildPending = false; }); }, TaskScheduler.Default); } private static List BuildFileMentionIndex(string workFolder, CancellationToken ct) { var results = new List(capacity: 512); var pending = new Stack(); pending.Push(workFolder); while (pending.Count > 0 && results.Count < FileMentionIndexLimit) { ct.ThrowIfCancellationRequested(); var current = pending.Pop(); try { foreach (var dir in Directory.EnumerateDirectories(current)) { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(dir); if (string.IsNullOrWhiteSpace(name) || name.StartsWith('.') || FileMentionIgnoredDirs.Contains(name)) continue; pending.Push(dir); } foreach (var file in Directory.EnumerateFiles(current)) { ct.ThrowIfCancellationRequested(); var fileName = Path.GetFileName(file); if (string.IsNullOrWhiteSpace(fileName) || fileName.StartsWith('.')) continue; results.Add(Path.GetRelativePath(workFolder, file)); if (results.Count >= FileMentionIndexLimit) break; } } catch { } } return results; } private static FileMentionQuery? TryExtractFileMentionQuery(string text) { if (string.IsNullOrWhiteSpace(text)) return null; var trimmedEnd = text.TrimEnd(); if (trimmedEnd.Length < 2) return null; var match = FileMentionTokenRegex.Match(trimmedEnd); if (!match.Success) return null; var token = match.Groups["token"].Value.Trim(); if (token.Length < 2) return null; var looksFileLike = token.Contains('.') || token.Contains('/') || token.Contains('\\') || token.Contains('_') || token.Contains('-'); if (!looksFileLike) { var lower = trimmedEnd.ToLowerInvariant(); var fileContext = lower.Contains("파일") || lower.Contains("문서") || lower.Contains("폴더") || lower.Contains("file") || lower.Contains("document") || lower.Contains("folder") || lower.Contains("read ") || lower.Contains("open "); if (!fileContext) return null; } return new FileMentionQuery( token, match.Groups["token"].Index, match.Groups["token"].Length); } private static List FindFileMentionCandidates( IReadOnlyCollection indexedPaths, string query) { if (indexedPaths.Count == 0 || string.IsNullOrWhiteSpace(query)) return []; var normalizedQuery = query.Replace('\\', '/').Trim().ToLowerInvariant(); var queryFileName = Path.GetFileName(normalizedQuery); var candidates = new List(); foreach (var relativePath in indexedPaths) { var normalizedPath = relativePath.Replace('\\', '/'); var lowerPath = normalizedPath.ToLowerInvariant(); var fileName = Path.GetFileName(normalizedPath); var lowerFileName = fileName.ToLowerInvariant(); if (!lowerPath.Contains(normalizedQuery) && !lowerFileName.Contains(queryFileName)) continue; var score = 0; if (string.Equals(lowerFileName, queryFileName, StringComparison.Ordinal)) score += 600; else if (lowerFileName.StartsWith(queryFileName, StringComparison.Ordinal)) score += 420; else if (lowerFileName.Contains(queryFileName, StringComparison.Ordinal)) score += 260; if (string.Equals(lowerPath, normalizedQuery, StringComparison.Ordinal)) score += 520; else if (lowerPath.EndsWith(normalizedQuery, StringComparison.Ordinal)) score += 300; else if (lowerPath.Contains(normalizedQuery, StringComparison.Ordinal)) score += 140; score += Math.Max(0, 80 - normalizedPath.Length); candidates.Add(new FileMentionCandidate(normalizedPath, fileName, score)); } return candidates .OrderByDescending(c => c.Score) .ThenBy(c => c.RelativePath.Length) .ThenBy(c => c.RelativePath, StringComparer.OrdinalIgnoreCase) .Take(6) .ToList(); } private void RenderFileMentionSuggestions( string query, IReadOnlyList candidates, bool isLoading) { if (FileMentionSuggestionCard == null || FileMentionSuggestionTitle == null || FileMentionSuggestionChipPanel == null) return; FileMentionSuggestionChipPanel.Children.Clear(); if (isLoading) { FileMentionSuggestionTitle.Text = $"파일 후보 찾는 중 · {query}"; FileMentionSuggestionCard.Visibility = Visibility.Visible; return; } if (candidates.Count == 0) { HideFileMentionSuggestions(); return; } FileMentionSuggestionTitle.Text = $"파일 후보 · {query}"; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? itemBg; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray; var accentBrush = TryFindResource("AccentColor") as Brush ?? primaryText; foreach (var candidate in candidates) { var chip = new Border { Background = itemBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(0, 0, 6, 6), Cursor = Cursors.Hand, Tag = candidate.RelativePath, }; var stack = new StackPanel(); stack.Children.Add(new TextBlock { Text = candidate.FileName, FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = primaryText, }); stack.Children.Add(new TextBlock { Text = candidate.RelativePath, Margin = new Thickness(0, 2, 0, 0), FontSize = 10.5, Foreground = secondaryText, TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 240, }); chip.Child = stack; chip.MouseEnter += (_, _) => chip.Background = hoverBg; chip.MouseLeave += (_, _) => chip.Background = itemBg; chip.MouseLeftButtonUp += (_, _) => ApplyFileMentionSuggestion(candidate.RelativePath); FileMentionSuggestionChipPanel.Children.Add(chip); } FileMentionSuggestionCard.Visibility = Visibility.Visible; } private void HideFileMentionSuggestions() { if (FileMentionSuggestionCard == null || FileMentionSuggestionChipPanel == null) return; FileMentionSuggestionChipPanel.Children.Clear(); FileMentionSuggestionCard.Visibility = Visibility.Collapsed; } private void ApplyFileMentionSuggestion(string relativePath) { if (InputBox == null || string.IsNullOrWhiteSpace(relativePath)) return; var text = InputBox.Text ?? ""; var query = TryExtractFileMentionQuery(text); var replacement = relativePath.Replace('\\', '/'); if (query != null) { text = text[..query.Start] + replacement + text[(query.Start + query.Length)..]; } else { text = string.IsNullOrWhiteSpace(text) ? replacement : $"{text.TrimEnd()} {replacement}"; } InputBox.Text = text; InputBox.CaretIndex = text.Length; InputBox.Focus(); HideFileMentionSuggestions(); } private bool TryAcceptTopFileMentionSuggestion() { if (FileMentionSuggestionCard?.Visibility != Visibility.Visible || FileMentionSuggestionChipPanel == null || FileMentionSuggestionChipPanel.Children.Count == 0) return false; if (FileMentionSuggestionChipPanel.Children[0] is not Border firstChip || firstChip.Tag is not string relativePath) return false; ApplyFileMentionSuggestion(relativePath); return true; } }