[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:
264
src/AxCopilot/Handlers/TagHandler.cs
Normal file
264
src/AxCopilot/Handlers/TagHandler.cs
Normal 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)
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user