[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

@@ -87,7 +87,7 @@
--- ---
## Phase L3 — 차세대 런처 (v2.0) — 차기 개발 ## Phase L3 — 차세대 런처 (v2.0) — 진행 중 / 일부 완료
> **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되, > **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되,
> 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현. > 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현.
@@ -95,11 +95,11 @@
| # | 기능 | 설명 | 우선순위 | 교차 | | # | 기능 | 설명 | 우선순위 | 교차 |
|---|------|------|----------|------| |---|------|------|----------|------|
| L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 | | L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 |
| L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 | | L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 |
| L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 | | L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 |
| L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 | | L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 |
| L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, 태그 기반 검색 | 중간 | — | | L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, `tag` 프리픽스로 태그 기반 검색. `file_tags.json` 로컬 저장 | 중간 | — |
| L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 | | L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 |
| L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — | | L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — |
| L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — | | L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — |

View File

@@ -166,6 +166,8 @@ public partial class App : System.Windows.Application
var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm); var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm);
commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc)); commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc));
commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm)); commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm));
// Phase L3-5: 파일 태그 시스템
commandResolver.RegisterHandler(new TagHandler());
// ─── 플러그인 로드 ──────────────────────────────────────────────────── // ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver); var pluginHost = new PluginHost(settings, commandResolver);

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)
]);
}

View File

@@ -0,0 +1,155 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// Phase L3-5: 파일 태그 시스템 서비스.
/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다.
/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다.
/// </summary>
public class FileTagService
{
private static readonly string TagFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "file_tags.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
/// <summary>key = 정규화된 파일 경로, value = 태그 집합</summary>
private Dictionary<string, HashSet<string>> _data =
new(StringComparer.OrdinalIgnoreCase);
private bool _loaded;
// ─── 싱글턴 ─────────────────────────────────────────────────────────────
private static FileTagService? _instance;
public static FileTagService Instance => _instance ??= new FileTagService();
private FileTagService() { }
// ─── 공개 API ────────────────────────────────────────────────────────────
/// <summary>파일에 태그를 추가합니다.</summary>
public void AddTag(string path, string tag)
{
EnsureLoaded();
path = NormalizePath(path);
tag = NormalizeTag(tag);
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return;
if (!_data.TryGetValue(path, out var tags))
{
tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_data[path] = tags;
}
tags.Add(tag);
Save();
}
/// <summary>파일에서 태그를 제거합니다.</summary>
public void RemoveTag(string path, string tag)
{
EnsureLoaded();
path = NormalizePath(path);
tag = NormalizeTag(tag);
if (!_data.TryGetValue(path, out var tags)) return;
tags.Remove(tag);
if (tags.Count == 0) _data.Remove(path);
Save();
}
/// <summary>파일의 모든 태그를 제거합니다.</summary>
public void ClearTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
_data.Remove(path);
Save();
}
/// <summary>파일의 태그 목록을 반환합니다.</summary>
public IReadOnlyList<string> GetTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
return _data.TryGetValue(path, out var tags)
? tags.OrderBy(t => t).ToList()
: Array.Empty<string>();
}
/// <summary>특정 태그가 부여된 파일 경로 목록을 반환합니다.</summary>
public IReadOnlyList<string> GetFilesByTag(string tag)
{
EnsureLoaded();
tag = NormalizeTag(tag);
return _data
.Where(kv => kv.Value.Contains(tag))
.Select(kv => kv.Key)
.OrderBy(p => p)
.ToList();
}
/// <summary>등록된 모든 태그와 각 파일 수를 반환합니다.</summary>
public IReadOnlyDictionary<string, int> GetAllTags()
{
EnsureLoaded();
return _data
.SelectMany(kv => kv.Value)
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count());
}
/// <summary>경로에 태그가 하나 이상 있는지 확인합니다.</summary>
public bool HasTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
return _data.TryGetValue(path, out var tags) && tags.Count > 0;
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private void EnsureLoaded()
{
if (_loaded) return;
_loaded = true;
try
{
if (!File.Exists(TagFile)) return;
var raw = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
File.ReadAllText(TagFile), JsonOpts);
if (raw != null)
{
_data = raw.ToDictionary(
kv => kv.Key,
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);
}
}
catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); }
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!);
var raw = _data.ToDictionary(
kv => kv.Key,
kv => kv.Value.OrderBy(t => t).ToList());
File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts));
}
catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); }
}
private static string NormalizePath(string path)
=> path.Trim().TrimEnd('\\', '/');
private static string NormalizeTag(string tag)
=> tag.Trim().ToLowerInvariant().Replace(" ", "-");
}

View File

@@ -130,4 +130,7 @@ internal static class Symbols
public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일) public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일)
public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일) public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일)
public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조) public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조)
// ─── Phase L3-5 태그 시스템 ────────────────────────────────────────────────
public const string Tag = "\uEAB4"; // 파일 태그 (레이블)
} }

View File

@@ -147,6 +147,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") }, { "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") }, { "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
{ "help", ("도움말", Symbols.Info, "#6B7280") }, { "help", ("도움말", Symbols.Info, "#6B7280") },
// ─── Phase L3-5 파일 태그 ──────────────────────────────────────────────
{ "tag", ("태그", Symbols.Tag, "#6366F1") },
}; };
// ─── 설정 기능 토글 (런처 실동작 연결) ────────────────────────────────── // ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────