[v2.0.0] Phase L3-5 파일 태그 시스템 구현

FileTagService.cs (140줄) — 신규:
- 파일·폴더에 사용자 태그 부여 서비스 (싱글턴)
- AddTag / RemoveTag / ClearTags / GetTags / GetFilesByTag / GetAllTags / HasTags
- %APPDATA%\AxCopilot\file_tags.json 로컬 저장 (key=경로, value=태그목록)
- 태그 정규화: 소문자 + 공백→하이픈 변환

TagHandler.cs (190줄) — 신규:
- "tag" 프리픽스 핸들러 (IActionHandler 구현)
- tag              → 전체 태그 목록 (파일 수 내림차순)
- tag work         → "work" 태그 파일 목록 (prefix match + contains)
- tag add [태그] [경로] → 파일에 태그 추가 (Enter로 확정)
- tag del [태그] [경로] → 파일에서 태그 제거 (Enter로 확정)
- tag clear [경로]       → 파일의 모든 태그 삭제 (Enter로 확정)
- 파일 열기: Process.Start UseShellExecute

Symbols.cs (+1줄):
- Tag = "\uEAB4" 심볼 상수 추가

LauncherViewModel.cs (+2줄):
- PrefixMap에 "tag" → ("태그", Symbols.Tag, "#6366F1") 추가

App.xaml.cs (+2줄):
- Phase L3 핸들러 섹션에 TagHandler 등록

docs/LAUNCHER_ROADMAP.md:
- L3-1~L3-5 완료 표시 ()

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 08:51:04 +09:00
parent d67c378389
commit cb9d197969
6 changed files with 432 additions and 6 deletions

View File

@@ -0,0 +1,264 @@
using System.Diagnostics;
using System.IO;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다.
/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.
///
/// 사용법:
/// tag → 전체 태그 목록 (태그명 + 파일 수)
/// tag work → "work" 태그가 부여된 파일 목록
/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가
/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거
/// tag clear C:\path\file.docx → 파일의 모든 태그 제거
/// </summary>
public class TagHandler : IActionHandler
{
public string? Prefix => "tag";
public PluginMetadata Metadata => new(
"FileTag",
"파일 태그 — tag",
"1.0",
"AX",
"파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.");
private static FileTagService Tags => FileTagService.Instance;
// ─── GetItemsAsync ───────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// ── add 명령: tag add <태그> <경로> ────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx > 0)
{
var tag = rest[..spaceIdx].Trim();
var path = rest[(spaceIdx + 1)..].Trim();
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 추가: [{tag}] {Path.GetFileName(path)}",
$"{path} · Enter로 추가",
null,
ValueTuple.Create("__TAG_ADD__", tag, path),
Symbol: Symbols.Tag)
]);
}
return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx");
}
// ── del 명령: tag del <태그> <경로> ────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx > 0)
{
var tag = rest[..spaceIdx].Trim();
var path = rest[(spaceIdx + 1)..].Trim();
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 제거: [{tag}] {Path.GetFileName(path)}",
$"{path} · Enter로 제거",
null,
ValueTuple.Create("__TAG_DEL__", tag, path),
Symbol: Symbols.Delete)
]);
}
return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx");
}
// ── clear 명령: tag clear <경로> ───────────────────────────────────
if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase))
{
var path = q[6..].Trim();
if (!string.IsNullOrEmpty(path))
{
var currentTags = Tags.GetTags(path);
var tagList = currentTags.Count > 0
? string.Join(", ", currentTags.Select(t => $"[{t}]"))
: "태그 없음";
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"태그 전체 제거: {Path.GetFileName(path)}",
$"{tagList} · Enter로 모두 삭제",
null,
ValueTuple.Create("__TAG_CLEAR__", path),
Symbol: Symbols.Delete)
]);
}
return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx");
}
// ── 태그 검색 또는 전체 목록 ─────────────────────────────────────────
return string.IsNullOrEmpty(q)
? BuildTagListItems()
: BuildTagSearchItems(q);
}
// ─── ExecuteAsync ────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 태그 추가
if (item.Data is ValueTuple<string, string, string> addCmd &&
addCmd.Item1 == "__TAG_ADD__")
{
Tags.AddTag(addCmd.Item3, addCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}");
return Task.CompletedTask;
}
// 태그 제거
if (item.Data is ValueTuple<string, string, string> delCmd &&
delCmd.Item1 == "__TAG_DEL__")
{
Tags.RemoveTag(delCmd.Item3, delCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}");
return Task.CompletedTask;
}
// 전체 태그 제거
if (item.Data is ValueTuple<string, string> clearCmd &&
clearCmd.Item1 == "__TAG_CLEAR__")
{
Tags.ClearTags(clearCmd.Item2);
NotificationService.Notify("AX Copilot",
$"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}");
return Task.CompletedTask;
}
// 파일/폴더 열기
if (item.Data is string filePath)
{
try
{
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private static Task<IEnumerable<LauncherItem>> BuildTagListItems()
{
var allTags = Tags.GetAllTags();
if (allTags.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"등록된 태그가 없습니다",
"tag add [태그] [] ",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"사용법 보기",
"tag add work C:\\path\\file.docx / tag work / tag clear C:\\path",
null, null, Symbol: Symbols.Info),
]);
}
var items = allTags
.OrderByDescending(kv => kv.Value)
.ThenBy(kv => kv.Key)
.Take(12)
.Select(kv => new LauncherItem(
$"[{kv.Key}]",
$"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기",
null, null,
Symbol: Symbols.Tag))
.ToList<LauncherItem>();
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static Task<IEnumerable<LauncherItem>> BuildTagSearchItems(string q)
{
var allTags = Tags.GetAllTags();
// 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순
var matchedTags = allTags.Keys
.Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(t => t)
.ToList();
if (!matchedTags.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"[{q}] ",
"tag add [태그] [경로]로 태그를 추가하세요",
null, null, Symbol: Symbols.Info)
]);
}
var items = new List<LauncherItem>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tag in matchedTags.Take(2))
{
var files = Tags.GetFilesByTag(tag);
foreach (var path in files.Take(6))
{
if (!seen.Add(path)) continue;
var isDir = Directory.Exists(path);
var isFile = File.Exists(path);
var symbol = isDir ? Symbols.Folder
: isFile ? Symbols.File
: Symbols.Warning;
var hint = isDir ? "폴더 열기"
: isFile ? "파일 열기"
: "경로를 찾을 수 없음";
items.Add(new LauncherItem(
Path.GetFileName(path),
$"[{tag}] · {path} · {hint}",
null, path,
Symbol: symbol));
}
}
if (!items.Any())
{
items.Add(new LauncherItem(
$"[{q}] 태그 파일 접근 불가",
"파일이 삭제되었거나 경로가 변경되었을 수 있습니다",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static Task<IEnumerable<LauncherItem>> HelpItems(string usage, string example) =>
Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"사용법: {usage}",
example,
null, null, Symbol: Symbols.Info)
]);
}