Skip to content

검색 코어 및 액션 ​

이 장에서는 Lertaro.PluginSdk에서 검색 데이터 소스, 실시간 연산 결과, 비 ASCII 별칭 엔진, 쿼리 접미사 토큰 핸들러 및 컨텍스트 액션 메뉴를 제공하기 위한 핵심 인터페이스와 데이터 구조를 다룹니다.

1. 기본 컴포넌트 규격 IPluginComponent 및 IPlugin ​

모든 플러그인 컴포넌트는 IPluginComponent를 상속하여 호스트에 메타데이터를 제공합니다.

csharp
namespace Lertaro.PluginSdk.Abstractions.Plugins;

public interface IPluginComponent
{
    string Name => GetType().Name;      // 컴포넌트 표시 이름 (기본값: 클래스명)
    string Description => string.Empty; // 설정 창에서 툴팁으로 표시될 설명 문구
}

public interface IPlugin : IPluginComponent
{
    // 플러그인 어셈블리 메인 진입점. 두 웹사이트 멤버는 모두 선택 사항이고,
    // WebsiteUrl이 null이면 설정 카드에는 링크가 아예 표시되지 않는다.
    string? WebsiteUrl => null;
    string? WebsiteLabel => null;
}

2. 검색 결과 제공 ​

정적 캐시 가능 항목 제공자 ISearchableItemProvider ​

키 입력마다 변경되지 않고 사전에 인덱싱하기에 적합한 데이터 소스(시작 메뉴 바로가기, 북마크, 제어판 항목 등)에 사용합니다.

csharp
public interface ISearchableItemProvider : IPluginComponent
{
    bool EnableAlias => true;           // 병음 등의 별칭 변환 허용 여부
    event Action? ItemsChanged;         // 데이터 변경 시 재인덱싱을 요청하는 이벤트
    IEnumerable<SearchableItem> GetSearchableItems();
}

동적 실시간 연산 제공자 IInstantResultProvider ​

키 입력마다 즉시 실행되며 검색어 자체로부터 결과를 도출하는 기능(계산기, 진법 변환, URL 점프 등)에 적합합니다.

csharp
public interface IInstantResultProvider : IPluginComponent
{
    IEnumerable<InstantResultItem> GetInstantResults(string query);
    IEnumerable<InstantResultItem> GetInstantResults(string query, string? contextDirectory) => GetInstantResults(query);
    bool SupportsInlineSearch => false;
    bool[]? GetHighlightMask(string text, string query) => null; // 커스텀 하이라이트 마스크

    // 이 제공자를 호출하는 단어어들. 호스트 자신의 제거 단계가 이 값을 읽으므로
    // 호스트의 제거와 플러그인의 일치가 서로 어긋날 수 없다. 6장 "트리거 단어" 참조.
    IReadOnlyList<string> QueryTriggerKeywords => [];
}

GetInstantResults(query, contextDirectory)를 재정의하면 이번 검색에서 캡처한 디렉터리를 받습니다. 기본 구현은 기존 오버로드를 호출합니다. 탐색기 인라인 검색에 참여하려면 SupportsInlineSearch를 true로 설정합니다. SearchableItem.Keywords에 추가 검색어를 지정할 수 있으며, 각 쿼리 단어는 제목이나 키워드의 단어 접두사와 일치해야 합니다. 이 속성은 SearchableItem에만 있고 InstantResultItem에는 없습니다.

Lertaro.PluginSdk.Helpers.ShellCommandLauncher.Launch(command, shell = "cmd", runAsAdmin = false, workingDirectory = null)는 cmd, powershell, pwsh를 실행합니다. ResolveWorkingDirectory(configuredDirectory, useCurrentDirectory, contextDirectory)는 설정 경로를 확장하고 캡처한 현재 디렉터리는 문자 그대로 사용하며 사용할 수 없는 디렉터리를 거부합니다. 일반 명령은 선택한 인터프리터가 없으면 실패합니다. 명령 자체가 정확한 셸 이름인 경우에만 CMD로 대체할 수 있습니다.

TIP

GetInstantResults는 타이핑 반응성을 위해 동기식으로 호출됩니다. 비동기 네트워크 요청(번역, 검색 제안 등)이 필요한 경우 플레이스홀더를 즉시 반환하고 Task.Run으로 백그라운드에서 조회 후 SearchRefreshService.RefreshIfMatches를 호출하여 호스트 검색 결과를 갱신하세요.

비 ASCII 별칭 변환 엔진 IAliasProvider ​

중국어 파일명 등 비 ASCII 텍스트에 대한 인덱싱용 별칭을 생성하여 병음 혼합 검색을 지원합니다.

csharp
namespace Lertaro.PluginSdk.Abstractions.Plugins;

public interface IAliasProvider : IPluginComponent   // 이름은 IPluginComponent에서 나온다
{
    bool CanHandle(string text);
    IReadOnlyList<(char Start, char End)> InputRanges { get; }  // 입력 문자 범위 (예: CJK 한자)
    IReadOnlyList<(char Start, char End)> OutputRanges { get; } // 출력 문자 범위 (예: a-z)

    // 다음절 엔진(병음)에서 설정하는 값: 생성된 별칭에서 음절을 잇는 문자다.
    // 기본값 '\0'은 엔진이 구분자를 전혀 내지 않는다는 뜻이다.
    char SyllableSeparator => '\0';

    IEnumerable<string> GetAliases(string text);

    int Version => 1;                                           // 규칙 변경 시 증가시켜 재인덱싱 유도
    IEnumerable<string> GetQueryForms(string term) => Array.Empty<string>(); // 쿼리 측 음절 분할 전개
    int[]? MapAliasToSourceIndices(string text, string alias) => null;       // 하이라이트 역매핑

    // 인덱서 핫 경로를 위한 제로 할당 UTF-8 빌더. 기본 구현은 GetAliases()를 싱크로 넘기면서
    // 실제로 대문자를 포함한 별칭만 소문자로 내리므로, 엔진은 문자열보다 싸게 바이트를
    // 생성할 수 있을 때만 이 메서드를 재정의한다.
    void GetAliasesUtf8(string text, AliasByteSink dest);
}

쿼리 접미사 토큰 핸들러 IQueryTokenProvider ​

검색어 끝에 붙는 토큰(예: report :size, doc :@today, image ::"hello world")을 감지하여 결과 목록에 필터링이나 정렬을 적용합니다.

csharp
public interface IQueryTokenProvider : IPluginComponent
{
    bool CanHandle(string token);
    Task<IReadOnlyList<ISearchResult>> ApplyAsync(string token, IReadOnlyList<ISearchResult> results);

    // 이 토큰이 검색어에서 소비된 뒤 결과 행에서 계속 강조할 텍스트.
    // 기본값 null은 호스트 자신의 강조를 건드리지 않는다.
    string? GetHighlightText(string token) => null;
}

3. 결과 컨텍스트 액션 ​

액션 제공자 컨테이너 IActionProvider ​

csharp
public interface IActionProvider
{
    IEnumerable<ISearchResultAction> GetActions();
    IEnumerable<IDynamicActionProvider> GetDynamicActionProviders();
}

정적 액션 계약 ISearchResultAction ​

Ctrl+O 메뉴나 단축키에 등록되는 독립적인 정적 동작(경로 복사, 관리자 권한 실행 등)을 정의합니다.

csharp
namespace Lertaro.PluginSdk.Abstractions;

public interface ISearchResultAction : Plugins.IPluginComponent
{
    string GroupName { get; }           // 액션 메뉴의 그룹 헤더
    string DisplayName { get; }         // 액션 제목
    // 액션은 표시 이름으로 지목되므로 Name은 직접 작성하는 대신 매핑한다:
    string Plugins.IPluginComponent.Name => DisplayName;

    // nullable이 아니며 기본값이 있다. 빈 문자열은 "단축키 없음"을 뜻하며, 파괴적인 파일
    // 액션들이 탐색기의 조합키를 되찾기 전까지 바인딩되지 않은 채로 있던 방식이 이것이다.
    string Hotkey => string.Empty;
    IReadOnlyList<string> Keywords => Array.Empty<string>();
    IReadOnlyList<string> Parameters => Array.Empty<string>();

    // 액션이 어디에 표시되는가. 기본값: 검색에는 항상 표시되고 메뉴에는 키워드를
    // 주장하지 않을 때만 표시된다(키워드가 있으면 대신 행으로 나타난다).
    bool IsVisibleInSearch(IReadOnlyList<ISearchResult> results, SearchWindowType windowType) => true;
    bool IsVisibleInMenu(IReadOnlyList<ISearchResult> results, SearchWindowType windowType) => Keywords.Count == 0;

    ImageSource? Icon { get; }          // 액션 아이콘; null이면 그룹 기본 아이콘을 그린다
    bool CanExecute(IReadOnlyList<ISearchResult> results);
    void Execute(IReadOnlyList<ISearchResult> results, IPluginSearchWindow view);
}

동적 메뉴 빌더 IDynamicActionProvider ​

런타임에 동적으로 메뉴를 생성합니다(Windows Shell 우클릭 메뉴 통합 등).

csharp
namespace Lertaro.PluginSdk.Abstractions.Plugins;

public interface IDynamicActionProvider : IPluginComponent
{
    string GroupName { get; }
    string IPluginComponent.Name => GroupName;    // ISearchResultAction과 같은 매핑

    int Priority => 0;                            // 메뉴 정렬 가중치, nullable이 아니다
    IReadOnlyList<string> Keywords => Array.Empty<string>();
    IReadOnlyList<string> Parameters => Array.Empty<string>();

    bool IsVisibleInSearch(IReadOnlyList<ISearchResult> results, SearchWindowType windowType) => true;
    bool IsVisibleInMenu(IReadOnlyList<ISearchResult> results, SearchWindowType windowType) => Keywords.Count == 0;

    void Init() { }                               // 최초 메뉴 이후에 호출되는 1회성 웜업
    bool CanProvide(IReadOnlyList<ISearchResult> results);

    // 즉시 결과(창 제목, 프로세스 행) 위에도 메뉴를 제공하겠는지 선택한다. 기본값 false —
    // 대부분 제공자는 그런 행이 갖고 있지 않은 파일 경로 기준으로 동작하기 때문이다.
    bool CanProvideForInstantResults => false;

    IEnumerable<DynamicMenuItem> GetMenuItems(IReadOnlyList<ISearchResult> results, IntPtr hMenu);
    IEnumerable<(string Hotkey, Action Execute)> GetHotkeyActions(IReadOnlyList<ISearchResult> results)
        => Array.Empty<(string, Action)>();
    void ExecuteCommand(IReadOnlyList<ISearchResult> results, uint commandId, IntPtr ownerHwnd);

    // 기본 구현 없음: 구현이 필수다. 메뉴가 해제될 때 호출되므로 네이티브 핸들이나
    // 캐시한 Shell CDS 스트림을 가진 제공자는 여기서 반환할 수 있다.
    void ClearSession();
}

4. 보조 데이터 구조 ​

  • SearchableItem: Title, Description, IconData, IconColor, ActionType("Copy" / "Execute" / "None"), ActionArgument, TabCompletion, HBitmapIcon(호스트 자동 해제), ResultKind(호스트의 필터와 열이 기준으로 삼을 수 있는, 플러그인이 선택한 태그), 실행 콜백 두 개(OnExecute(Action)는 fire-and-forget용, 작업이 성공 여부를 알려야 할 때는 OnExecuteFunc(Func<bool>) — 호스트는 그 답변으로 예를 들어 창을 닫을지 결정함)를 포함합니다. InstantResultItem은 표시 및 콜백 멤버를 똑같이 갖지만 ResultKind와 Keywords는 없습니다. ResultKind는 검색 가능 항목 모델에만 있는 멤버입니다.
  • DynamicMenuItem: Text, CommandId, IsSeparator, HasSubMenu, SubMenuHandle, IsDisabled, OnExecute, IsActionable(기본값 true; false는 하위 메뉴를 여는 일 외에는 동작하지 않는 행을 표시), HBitmapItem(미러링 중인 Shell 메뉴의 네이티브 아이콘 핸들), ShortcutHint(메모닉 키가 일치시키는 문자), IsContinuation(페이지 나누기 커서 — 이번 묶음이 호스트가 아직 채우고 있는 메뉴의 연속이며, 이 값이 설정되어 있는 동안 호스트는 계속 요청함), IsHeader(선택적 액션 버튼이 있는 그룹 헤더로 렌더링)를 포함합니다.
  • SearchWindowType: Main(메인 창), Quick(퀵 검색창), Inline(인라인 파일 대화상자) 열거형.

5. 명명된 검색 범위 ISearchScopeProvider ​

범위는 키워드 접두사와 디렉터리 집합의 조합입니다: tf report를 입력하면 호스트는 report로 보통의 인덱스 검색을 되되, 그 폴더들로 제한해 실행합니다. 기존 인덱스 위에 걸치는 2단계 필터이지, 두 번째 검색 엔진이 아닙니다.

csharp
namespace Lertaro.PluginSdk.Abstractions.Plugins;

public interface ISearchScopeProvider : IPluginComponent
{
    // 키 입력 디스패치마다 참조하므로 캐시한 목록을 반환하고, 설정이 바뀔 때만 다시 만든다.
    // 키워드가 비었거나 폴더가 없는 범위는 호스트가 무시한다.
    IReadOnlyList<SearchScope> GetSearchScopes();
}

public sealed class SearchScope
{
    public string Keyword { get; init; } = string.Empty;            // 대소문자 무관 첫 토큰, 예: "tf"
    public IReadOnlyList<string> Folders { get; init; } = Array.Empty<string>();
    public string FilterPattern { get; init; } = "*";               // FILE 이름에 대한 ';' 구분 Win32 와일드카드
}

ISearchableItemProvider와 달리 범위 제공자는 파일을 열거하거나 실체화하지 않으므로, 설정한 폴더가 아무리 커도 메모리와 키 입력당 비용은 일정하게 유지됩니다. 어느 호스트 인덱스도 커버하지 않는 폴더는 실시간으로 훑지 않고 경고와 함께 건너뛰며 — 이는 인덱서 헬퍼가 따르는 규칙과 같은데, 로컬 드라이브·네트워크·폴더 인덱스로 그 폴더를 먼저 커버해야 한다는 것입니다. 디렉터리는 FilterPattern을 항상 통과합니다.

저장소 안 구현체는 File Filters 플러그인입니다.

6. 트리거 단어 TriggerWord ​

사용자가 앞단 단어를 입력해 호출하는 기능 — 즉시 제공자의 QueryTriggerKeywords, 액션의 Keywords, 범위의 Keyword, 파일 필터의 트리거 — 은 그 단어를 모두 Lertaro.PluginSdk.Services.TriggerWord로 해석합니다. 그래서 호스트의 단어 제거와 플러그인의 일치 판정이 서로 어긋날 수 없습니다.

도우미판정 내용
string Normalize(string? configured)설정해 둔 단어를 모든 비교가 기대하는 형태로 만듭니다: 앞뒤 공백 제거, null이나 공백만 있으면 빈 문자열. 읽을 때 정규화하세요 — 호스트는 제거하는 단어를 trim하므로, trim하지 않은 값과 비교하면 아무것도 인식되지 않는데 호스트는 여전히 파일 검색에서 그 단어를 지웁니다.
bool TryMatch(string query, string? word, out string argument)첫 토큰이 단어와 동일해야 합니다(대소문자 무관). argument는 남은 텍스트를 trim한 값이며 검색어가 단어뿐이면 빈 문자열이 되고, 그때도 일치합니다. 그 단어로 시작하는 더 긴 단어는 일치하지 않습니다(csreport는 cs가 아님).
bool TryMatchInvoked(...)위와 같지만 단어 뒤에 실제로 무언가 입력된 뒤에만 단어가 인정됩니다. 맨 단어만으로도 아무도 요청하지 않은 행이 화면에 뜨는 경우에 쓰세요: cs만 있으면 파일 검색으로 남고, cs 가 제공자입니다.
bool TryMatchAny(string query, IReadOnlyList<string> words, out string matchedWord, out string argument)목록 순서대로 처음 일치하는 단어가 이기며, 어느 단어가 일치했는지도 보고합니다 — 제공자마다 여러 키워드를 두는 웹 검색 엔진은 재파싱에 그 값이 필요합니다.
bool IsTypedPrefixOf(string query, string? word)검색어가 아직 완성되지 않은 단어 입력 중일 때 참입니다(mkdir로 가는 중인 m). 단어 전체가 갖춰지기 전에 트리거를 제시할 수 있는 유일한 분기이며, 구분자가 입력되는 순간 false가 됩니다.

IInstantResultProvider.QueryTriggerKeywords(기본값은 비어 있음)는 호스트가 자신의 제거 단계에서 읽는 값이고, PluginConfigField.IsTriggerWord는 그런 단어를 담는 Text 설정에 표시되어, 다른 기능이 이미 같은 단어로 응답하고 있을 때 설정 페이지가 저장은 막지 않은 채 경고할 수 있게 합니다. 그런 표시가 없다면 한 단어를 두 기능이 쓰는 문제는 조용히 지나갑니다: 파일 검색은 먼저 등록된 쪽을 따르고 다른 쪽의 행은 단순히 사라질 뿐, 어느 쪽 이름을 바꿔야 하는지 알려 주는 것이 없습니다.