밖에서 부르기: 명령줄 인자 수신 · 탐색기 보내기 등록
git config 에 mergetool/difftool 로 등록하는 쪽은 전부터 있었는데 정작 그 인자를 받는 쪽이 없어 다리가 반만 놓여 있었다. 나머지 반쪽을 놓고, 탐색기에서도 부를 수 있게 했다. - Services/StartupArgs: 켤 때 넘어온 인자를 읽는다. 이름표 붙은 것(--merge --base B --local L --remote R --merged M)과 그냥 넘어온 경로 둘 다 받는다. 경로가 폴더면 폴더 비교로, 파일이면 파일 비교로 보낸다. difftool 의 LOCAL/REMOTE 와 셸이 주는 "먼저 고른 것/나중 것" 은 뜻이 달라 따로 담는다 - 이 도구의 왼쪽은 늘 "기준"이라 difftool 에서는 REMOTE 가 왼쪽이 된다. - Services/ShellIntegration: 탐색기 "보내기" 메뉴에 바로가기를 둔다. 처음에는 우클릭 메뉴에 바로 넣으려고 레지스트리에 정적 verb 를 등록했다 (HKCU\Software\Classes\AllFilesystemObjects\shell\...). 키는 제대로 들어갔고 HKEY_CLASSES_ROOT 에도 병합돼 보였는데 탐색기 메뉴에는 뜨지 않았다. 확인해 보니 이 PC 에서 우클릭 메뉴에 나오는 다른 프로그램(Notepad++ · Git Bash · WinMerge)은 전부 HKLM 에 등록돼 있었고, HKCU 에 넣은 것은 - 우리 것이든 확인용으로 넣어 본 메모장 항목이든 - 하나도 그려지지 않았다. HKLM 은 관리자 권한이 필요해 쓸 수 없다. 그래서 보내기(SendTo)로 갔다. %APPDATA%\Microsoft\Windows\SendTo 에 바로가기 파일 하나를 두는 것이 전부라 레지스트리도 권한도 필요 없고 윈도우 판을 타지 않는다. 한 단계 더 들어가는 것이 유일한 단점인데, 대신 여러 개를 골라 한 번에 보낼 수 있어 미뤄 두었던 "파일 두 개 골라 비교" 가 덤으로 따라왔다. 등록/해제할 때 예전 판이 레지스트리에 남긴 흔적도 같이 지운다. 셸 확장 DLL 은 쓰지 않았다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 죽는다. - GitWorkspace.RouteStartupArgs 가 요청을 화면으로 보낸다. 창이 다 만들어진 뒤에 탭을 옮겨야 해서 ApplicationIdle 로 미루고, 요청 자체는 남겨 두어 늦게 만들어진 화면이 스스로 집어 가게 했다(폴더 비교 쪽도 파일 비교와 같은 방식으로 추가). - 곁들여: 파일 비교가 "양쪽 경로가 모두 있을 때만" 열리고 있었다. 셸은 하나만 줄 수도 있으므로 한쪽만 있어도 열도록 고쳤다 - 없는 쪽은 빈 내용이 되어 전부 추가로 보인다. ArgsTest 26건 신설. 전체 356건 통과. 실행 확인: 폴더 경로로 켜면 폴더 비교 탭에 그 폴더가 왼쪽에, 파일 두 개로 켜면 파일 비교 탭에 나란히. 등록 버튼을 누르면 보내기 메뉴에 항목이 생기고 버튼 이름이 "탐색기 보내기 해제" 로 바뀐다(실제 탐색기 메뉴에서 확인). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
49a05a0628
commit
4029336b88
26
doc/진행상황.md
26
doc/진행상황.md
@ -1,4 +1,4 @@
|
|||||||
# MergeHub 진행 상황
|
# MergeHub 진행 상황
|
||||||
|
|
||||||
작업 하나가 끝날 때마다 이 표를 고치고 **로컬 커밋**한다.
|
작업 하나가 끝날 때마다 이 표를 고치고 **로컬 커밋**한다.
|
||||||
푸시는 하지 않는다 — 사내 Gitea 저장소가 만들어지면 그때 한 번에 올린다.
|
푸시는 하지 않는다 — 사내 Gitea 저장소가 만들어지면 그때 한 번에 올린다.
|
||||||
@ -71,14 +71,24 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|
|||||||
| 상태 | 항목 | 내용 | 확인 |
|
| 상태 | 항목 | 내용 | 확인 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. <b>이것이 다리의 나머지 반쪽</b>이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 |
|
| **완료** | 명령줄 인자 수신 | `--merge/--diff --base/--local/--remote/--merged` 와 그냥 넘어온 경로. <b>이것이 다리의 나머지 반쪽</b>이다 - `git config` 에 등록하는 쪽은 전부터 있었는데 받는 쪽이 없었다 | `ArgsTest` 26건 · 실제 실행 2건 |
|
||||||
| **완료** | 탐색기 우클릭 | 파일·폴더·폴더 배경 세 자리. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 레지스트리 3개 키 확인 |
|
| **완료** | 탐색기에서 부르기 | 우클릭 → <b>보내기</b> → `MergeHub 로 비교`. Git 탭 버튼 하나로 등록/해제하고 버튼 이름이 지금 상태를 말한다 | 실제 메뉴에서 확인 |
|
||||||
|
|
||||||
**HKEY_CURRENT_USER 에만 쓴다** - 관리자 권한이 필요 없고 지울 때도 이 사람 것만 지운다.
|
**우클릭 메뉴에 바로 넣으려다 보내기로 돌아섰다.** 처음에는 레지스트리에 정적 verb 를 넣었다
|
||||||
사내 PC 에 권한 없이 깔리는 것이 전제라 이 선택이 중요하다. 셸 확장 DLL 을 만들지 않은 것도
|
(`HKCU\Software\Classes\AllFilesystemObjects\shell\...`). 키는 제대로 들어갔고
|
||||||
같은 이유다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
|
`HKEY_CLASSES_ROOT` 에도 병합돼 보였는데 <b>탐색기 메뉴에는 뜨지 않았다.</b>
|
||||||
|
확인해 보니 이 PC 에서 우클릭 메뉴에 나오는 다른 프로그램(Notepad++ · Git Bash · WinMerge)은
|
||||||
|
전부 HKLM(전체 사용자)에 등록돼 있었고, HKCU 에 넣은 것은 - 우리 것이든 실험용으로 넣은
|
||||||
|
메모장 항목이든 - <b>하나도 그려지지 않았다.</b> HKLM 은 관리자 권한이 필요해 쓸 수 없다.
|
||||||
|
|
||||||
탐색기는 고른 것 <b>하나만</b> 준다. 그때는 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 -
|
그래서 <b>보내기(SendTo)</b> 로 갔다. `%APPDATA%\Microsoft\Windows\SendTo` 에 바로가기
|
||||||
사용자가 그 자리에서 상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다.
|
파일 하나를 두는 것이 전부라 레지스트리도 권한도 필요 없고 윈도우 판을 타지 않는다.
|
||||||
|
한 단계 더 들어가는 것이 유일한 단점인데, 대신 <b>여러 개를 골라 한 번에 보낼 수 있다</b> -
|
||||||
|
미뤄 두었던 "파일 두 개 골라 비교"가 덤으로 따라왔다.
|
||||||
|
|
||||||
|
셸 확장 DLL 은 쓰지 않았다 - DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
|
||||||
|
|
||||||
|
고른 것이 하나뿐이면 왼쪽만 채우고 오른쪽은 비워 둔 채 화면을 연다 - 사용자가 그 자리에서
|
||||||
|
상대를 고르면 되고, 아무것도 안 열리는 것보다 낫다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -88,8 +98,6 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|
|||||||
|
|
||||||
- 파일·폴더 드래그&드롭, 최근 파일 목록
|
- 파일·폴더 드래그&드롭, 최근 파일 목록
|
||||||
- 로그인·권한
|
- 로그인·권한
|
||||||
- 탐색기에서 <b>두 개를 한 번에</b> 골라 비교(지금은 하나씩. 여러 개를 한 번에 받으려면
|
|
||||||
레지스트리 MultiSelectModel 이나 셸 확장이 필요하다)
|
|
||||||
- 비교 결과 보고서 내보내기
|
- 비교 결과 보고서 내보내기
|
||||||
- 대용량 파일 성능 (수만 줄) — 실제 소스 크기를 확인한 뒤 판단
|
- 대용량 파일 성능 (수만 줄) — 실제 소스 크기를 확인한 뒤 판단
|
||||||
|
|
||||||
|
|||||||
@ -5,29 +5,29 @@ using Microsoft.Win32;
|
|||||||
namespace MergeHub.Services
|
namespace MergeHub.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 탐색기 우클릭 메뉴 등록.
|
/// 탐색기에서 이 프로그램을 부를 수 있게 한다 - <b>보내기(SendTo)</b> 메뉴에 바로가기를 둔다.
|
||||||
///
|
///
|
||||||
/// <b>HKEY_CURRENT_USER 에만 쓴다</b> - 관리자 권한이 필요 없고, 지울 때도 이 사람 것만 지운다.
|
/// 처음에는 우클릭 메뉴에 바로 넣으려고 레지스트리에 정적 verb 를 등록했다.
|
||||||
/// 사내 PC 에 관리자 권한 없이 깔리는 것이 전제라 이 선택이 중요하다.
|
/// (<c>HKCU\Software\Classes\AllFilesystemObjects\shell\...</c>)
|
||||||
|
/// <b>이 PC 에서는 뜨지 않았다.</b> 확인해 보니 우클릭 메뉴에 나오는 다른 프로그램
|
||||||
|
/// (Notepad++ · Git Bash · WinMerge)은 전부 HKLM(전체 사용자)에 등록돼 있었고,
|
||||||
|
/// HKCU 에 넣은 항목은 - 우리 것이든 실험용으로 넣은 것이든 - 하나도 그려지지 않았다.
|
||||||
|
/// HKLM 은 관리자 권한이 필요해 쓸 수 없다.
|
||||||
///
|
///
|
||||||
/// 셸 확장 DLL 을 만들지 않은 이유 - DLL 을 등록하면 탐색기 프로세스 안으로 들어가
|
/// 그래서 보내기로 간다. 이쪽은 레지스트리를 아예 쓰지 않고
|
||||||
/// 잘못되면 탐색기가 통째로 죽는다. 레지스트리 항목만 쓰는 이 방식은 최악의 경우에도
|
/// <c>%APPDATA%\Microsoft\Windows\SendTo</c> 에 바로가기 파일 하나를 두는 것이 전부라
|
||||||
/// "메뉴를 눌러도 프로그램이 안 뜬다"에서 끝난다.
|
/// 권한도 필요 없고 윈도우 판을 타지 않는다. 덤이 하나 있는데, <b>여러 개를 골라
|
||||||
|
/// 한 번에 보낼 수 있다</b> - 파일 두 개를 골라 바로 비교하는 것이 그냥 된다.
|
||||||
|
///
|
||||||
|
/// 단점은 한 단계 더 들어간다는 것뿐이다(우클릭 → 보내기 → MergeHub 로 비교).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShellIntegration
|
public static class ShellIntegration
|
||||||
{
|
{
|
||||||
/// <summary>메뉴에 보일 이름.</summary>
|
/// <summary>보내기 메뉴에 보일 이름.</summary>
|
||||||
public const string MenuLabel = "MergeHub 로 비교";
|
public const string MenuLabel = "MergeHub 로 비교";
|
||||||
|
|
||||||
private const string KeyName = "MergeHub.Compare";
|
private static string ShortcutPath =>
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.SendTo), MenuLabel + ".lnk");
|
||||||
/// <summary>파일 우클릭 · 폴더 우클릭 · 폴더 안 빈 곳 우클릭, 세 자리에 넣는다.</summary>
|
|
||||||
private static readonly string[] Targets =
|
|
||||||
{
|
|
||||||
@"Software\Classes\*\shell\" + KeyName,
|
|
||||||
@"Software\Classes\Directory\shell\" + KeyName,
|
|
||||||
@"Software\Classes\Directory\Background\shell\" + KeyName,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static bool IsRegistered
|
public static bool IsRegistered
|
||||||
{
|
{
|
||||||
@ -35,60 +35,82 @@ namespace MergeHub.Services
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var key = Registry.CurrentUser.OpenSubKey(Targets[0]);
|
return File.Exists(ShortcutPath);
|
||||||
return key is not null;
|
|
||||||
}
|
}
|
||||||
catch (System.Security.SecurityException)
|
catch (IOException)
|
||||||
{
|
|
||||||
return false; // 정책으로 막힌 PC 면 "안 걸려 있다"로 본다
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>보내기 메뉴에 바로가기를 만든다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때).</summary>
|
||||||
/// 우클릭 메뉴를 넣는다. 이미 있으면 경로만 새로 쓴다(프로그램을 옮겼을 때를 위해).
|
|
||||||
/// </summary>
|
|
||||||
public static void Register(string executablePath)
|
public static void Register(string executablePath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(executablePath)) throw new ArgumentException("실행 파일 경로가 비었습니다.");
|
if (string.IsNullOrWhiteSpace(executablePath)) throw new ArgumentException("실행 파일 경로가 비었습니다.");
|
||||||
|
|
||||||
var exe = executablePath;
|
var shell = Type.GetTypeFromProgID("WScript.Shell")
|
||||||
var icon = exe + ",0";
|
?? throw new InvalidOperationException("WScript.Shell 을 찾을 수 없습니다.");
|
||||||
|
|
||||||
foreach (var path in Targets)
|
var host = Activator.CreateInstance(shell)
|
||||||
{
|
?? throw new InvalidOperationException("WScript.Shell 을 만들지 못했습니다.");
|
||||||
var background = path.Contains("Background");
|
|
||||||
|
|
||||||
using var key = Registry.CurrentUser.CreateSubKey(path)
|
// 바로가기(.lnk)는 COM 으로만 만들 수 있다. 참조를 더하지 않으려고 늦은 바인딩으로 부른다.
|
||||||
?? throw new InvalidOperationException($"레지스트리 키를 만들지 못했습니다: {path}");
|
var link = shell.InvokeMember("CreateShortcut", System.Reflection.BindingFlags.InvokeMethod,
|
||||||
|
null, host, new object[] { ShortcutPath })
|
||||||
|
?? throw new InvalidOperationException("바로가기를 만들지 못했습니다.");
|
||||||
|
|
||||||
key.SetValue(null, MenuLabel);
|
var type = link.GetType();
|
||||||
key.SetValue("Icon", icon);
|
|
||||||
|
|
||||||
using var command = key.CreateSubKey("command")
|
Set(type, link, "TargetPath", executablePath);
|
||||||
?? throw new InvalidOperationException("command 키를 만들지 못했습니다.");
|
Set(type, link, "WorkingDirectory", Path.GetDirectoryName(executablePath) ?? string.Empty);
|
||||||
|
Set(type, link, "Description", "고른 파일·폴더를 MergeHub 으로 비교합니다");
|
||||||
|
|
||||||
// 폴더 안 빈 곳에서 부르면 %1 이 아니라 %V 가 그 폴더다.
|
type.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, link, null);
|
||||||
var target = background ? "%V" : "%1";
|
|
||||||
command.SetValue(null, $"\"{exe}\" \"{target}\"");
|
// 예전 판이 레지스트리에 써 둔 것이 남아 있으면 같이 치운다.
|
||||||
}
|
RemoveLegacyRegistryEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void Set(Type type, object link, string name, string value) =>
|
||||||
|
type.InvokeMember(name, System.Reflection.BindingFlags.SetProperty, null, link, new object[] { value });
|
||||||
|
|
||||||
public static void Unregister()
|
public static void Unregister()
|
||||||
{
|
{
|
||||||
foreach (var path in Targets)
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(ShortcutPath)) File.Delete(ShortcutPath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 지우지 못하면 그대로 둔다. 다시 누르면 된다.
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveLegacyRegistryEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 레지스트리로 등록하던 시절의 흔적. 이 PC 에서는 뜨지도 않던 것이라 남겨 둘 이유가 없다.
|
||||||
|
/// </summary>
|
||||||
|
private static void RemoveLegacyRegistryEntries()
|
||||||
|
{
|
||||||
|
string[] legacy =
|
||||||
|
{
|
||||||
|
@"Software\Classes\AllFilesystemObjects\shell\MergeHub.Compare",
|
||||||
|
@"Software\Classes\Directory\Background\shell\MergeHub.Compare",
|
||||||
|
@"Software\Classes\*\shell\MergeHub.Compare",
|
||||||
|
@"Software\Classes\Directory\shell\MergeHub.Compare",
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in legacy)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
|
Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (Exception e) when (e is ArgumentException || e is UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
// 이미 없으면 그만이다.
|
// 이미 없거나 손댈 수 없으면 그만이다.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@ -536,14 +536,14 @@ namespace MergeHub.ViewModels
|
|||||||
private bool _isShellRegistered = ShellIntegration.IsRegistered;
|
private bool _isShellRegistered = ShellIntegration.IsRegistered;
|
||||||
|
|
||||||
public string ShellIntegrationLabel =>
|
public string ShellIntegrationLabel =>
|
||||||
IsShellRegistered ? "탐색기 우클릭 해제" : "탐색기 우클릭 등록";
|
IsShellRegistered ? "탐색기 보내기 해제" : "탐색기 보내기 등록";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 탐색기 우클릭 메뉴를 넣고 뺀다.
|
/// 탐색기 <b>보내기</b> 메뉴에 이 프로그램을 넣고 뺀다.
|
||||||
///
|
///
|
||||||
/// HKEY_CURRENT_USER 에만 쓰므로 <b>관리자 권한이 필요 없다</b> - 사내 PC 에 권한 없이
|
/// 우클릭 메뉴에 바로 넣는 레지스트리 방식은 이 PC 에서 뜨지 않았다
|
||||||
/// 깔리는 것이 전제다. 셸 확장 DLL 을 만들지 않은 것도 같은 이유로,
|
/// (자세한 이유는 ShellIntegration 설명 참고). 보내기는 바로가기 파일 하나라
|
||||||
/// DLL 은 탐색기 프로세스 안에서 돌아 잘못되면 탐색기가 통째로 죽는다.
|
/// 권한도 필요 없고, 여러 개를 골라 한 번에 보낼 수 있다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleShellIntegration()
|
private void ToggleShellIntegration()
|
||||||
@ -553,19 +553,19 @@ namespace MergeHub.ViewModels
|
|||||||
if (IsShellRegistered)
|
if (IsShellRegistered)
|
||||||
{
|
{
|
||||||
ShellIntegration.Unregister();
|
ShellIntegration.Unregister();
|
||||||
MessageUtil.ShowToast("탐색기 우클릭 메뉴를 뺐습니다");
|
MessageUtil.ShowToast("탐색기 보내기 메뉴에서 뺐습니다");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ShellIntegration.Register(ShellIntegration.CurrentExecutable());
|
ShellIntegration.Register(ShellIntegration.CurrentExecutable());
|
||||||
MessageUtil.ShowSuccessToast($"파일·폴더를 우클릭하면 \"{ShellIntegration.MenuLabel}\" 가 보입니다");
|
MessageUtil.ShowSuccessToast("우클릭 → 보내기 → " + ShellIntegration.MenuLabel + " 로 엽니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
IsShellRegistered = ShellIntegration.IsRegistered;
|
IsShellRegistered = ShellIntegration.IsRegistered;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MessageUtil.ShowErrorToast($"탐색기 등록 실패: {ex.Message}");
|
MessageUtil.ShowErrorToast("보내기 등록 실패: " + ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -134,7 +134,7 @@
|
|||||||
<!-- HKCU 에만 쓰므로 관리자 권한이 필요 없다. 버튼 이름이 지금 상태를 그대로 말한다. -->
|
<!-- HKCU 에만 쓰므로 관리자 권한이 필요 없다. 버튼 이름이 지금 상태를 그대로 말한다. -->
|
||||||
<Button Content="{Binding ShellIntegrationLabel}" Command="{Binding ToggleShellIntegrationCommand}"
|
<Button Content="{Binding ShellIntegrationLabel}" Command="{Binding ToggleShellIntegrationCommand}"
|
||||||
MinWidth="140"
|
MinWidth="140"
|
||||||
ToolTip="탐색기에서 파일·폴더를 우클릭하면 이 프로그램으로 열 수 있게 합니다 (관리자 권한 필요 없음)"/>
|
ToolTip="탐색기에서 파일·폴더를 고르고 [우클릭 → 보내기] 로 이 프로그램에 넘길 수 있게 합니다. 여러 개를 한 번에 골라도 됩니다 (관리자 권한 필요 없음)"/>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
|
|
||||||
<!-- ===== 작업 영역 ===== -->
|
<!-- ===== 작업 영역 ===== -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user