核心檢索與動作
本章節詳細介紹 Lertaro.PluginSdk 中用於貢獻搜尋資料來源、即時計算答案、非 ASCII 別名轉寫引擎、查詢後綴 Token 處理器以及靜態/動態快顯動作選單的核心介面與資料結構。
1. 基礎元件規範 IPluginComponent 與 IPlugin
所有外掛模組擴充元件均直接或間接繼承自 IPluginComponent,用於向宿主宣告元件的中繼資料:
namespace Lertaro.PluginSdk.Abstractions.Plugins;
public interface IPluginComponent
{
string Name => GetType().Name; // 元件顯示名稱(預設取類別名稱)
string Description => string.Empty; // 功能描述,在設定介面中作為 ToolTip 提示氣泡呈現
}
public interface IPlugin : IPluginComponent
{
// 外掛模組主組件的進入點。兩個網站成員都是選填的:當 WebsiteUrl 為 null 時,
// 設定卡片完全不會顯示連結。
string? WebsiteUrl => null;
string? WebsiteLabel => null;
}2. 貢獻搜尋結果
靜態可快取項目來源 ISearchableItemProvider
適用於內容相對靜態、列舉耗時但不需要隨每次擊鍵即時變化的場景(例如:開始功能表捷徑、瀏覽器書籤、系統控制台項目等)。
public interface ISearchableItemProvider : IPluginComponent
{
bool EnableAlias => true; // 是否允許對此資料來源套用拼音等別名轉寫
event Action? ItemsChanged; // 當資料來源發生變動時觸發,通知宿主重新拉取並更新索引
IEnumerable<SearchableItem> GetSearchableItems();
}動態即時計算來源 IInstantResultProvider
在使用者每次敲擊鍵盤時即時觸發,適合形態由查詢字串本身決定的結果(例如:數學計算機、進位轉換、環境變數展開、網頁即時跳轉等)。
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 文字產生額外的可索引別名,支援混合拼音輸入比對:
namespace Lertaro.PluginSdk.Abstractions.Plugins;
public interface IAliasProvider : IPluginComponent // Name 來自 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() 轉送入
// sink,且只小寫化那些確實含有大寫字母的別名,因此引擎只有在能以比字串更廉價
// 的方式產生位元組時,才會覆寫這個方法。
void GetAliasesUtf8(string text, AliasByteSink dest);
}查詢後綴 Token 處理器 IQueryTokenProvider
用於認領並處理搜尋框尾部的特定 Token 標記(例如 report :size、doc :@today 或 image ::"hello world"),對初步比對的結果清單進行串流二次變換(過濾、重新排序等):
public interface IQueryTokenProvider : IPluginComponent
{
bool CanHandle(string token);
Task<IReadOnlyList<ISearchResult>> ApplyAsync(string token, IReadOnlyList<ISearchResult> results);
// 當此 token 已從查詢中被消耗之後,仍要在結果列中持續反白的文字。
// Null(預設值)表示不去動宿主自己的反白。
string? GetHighlightText(string token) => null;
}3. 結果上的快顯動作
動作提供者容器 IActionProvider
public interface IActionProvider
{
IEnumerable<ISearchResultAction> GetActions();
IEnumerable<IDynamicActionProvider> GetDynamicActionProviders();
}靜態動作契約 ISearchResultAction
表示一個明確的靜態操作(如「複製完整路徑」、「以管理員身分執行」等),呈現在 Ctrl+O 動作選單中或綁定為全域動作快速鍵:
namespace Lertaro.PluginSdk.Abstractions;
public interface ISearchResultAction : Plugins.IPluginComponent
{
string GroupName { get; } // 動作選單中的分組標題
string DisplayName { get; } // 動作顯示文字
// 動作以其顯示名稱來尋址,因此 Name 是對應而來、而非自行撰寫:
string Plugins.IPluginComponent.Name => DisplayName;
// 不可為 null,並帶有預設值。空字串代表「沒有快速鍵」,這正是那些破壞性的檔案動作
// 在重新取得檔案總管的按鍵組合之前,維持未綁定的方式。
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 原生快顯選單插入到 Lertaro 中):
namespace Lertaro.PluginSdk.Abstractions.Plugins;
public interface IDynamicActionProvider : IPluginComponent
{
string GroupName { get; }
string IPluginComponent.Name => GroupName; // 與 ISearchResultAction 相同的對應方式
int Priority => 0; // 選單排序權重,非可空型別
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() { } // 一次性預熱,於第一個選單觸發時呼叫
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(GDI 點陣圖控制代碼,宿主自動接管釋放)、ResultKind(一個由外掛模組選擇、供宿主的篩選器與資料欄據以取用的標籤),以及兩個執行回呼:用於一觸即擲的OnExecute(Action),或當動作需要回報成功時的OnExecuteFunc(Func<bool>)——宿主會依據那個回答,例如決定是否關閉視窗。InstantResultItem帶有相同的顯示與回呼成員,不含ResultKind和Keywords,那個成員只有可搜尋項目的模型才有。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 執行它一般的索引搜尋、但限制在那些資料夾內。它是對既有索引的第二階段篩選,而不是第二個搜尋引擎。
namespace Lertaro.PluginSdk.Abstractions.Plugins;
public interface ISearchScopeProvider : IPluginComponent
{
// 在每次擊鍵派發時都會被諮詢:返回一個快取清單,只在配置變更時才重建。
// 關鍵字為空白或沒有任何資料夾的範圍會被宿主忽略。
IReadOnlyList<SearchScope> GetSearchScopes();
}
public sealed class SearchScope
{
public string Keyword { get; init; } = string.Empty; // 不分大小寫的第一個 token,例如 "tf"
public IReadOnlyList<string> Folders { get; init; } = Array.Empty<string>();
public string FilterPattern { get; init; } = "*"; // 以 ';' 分隔、套用於「檔案」名稱的 Win32 萬用字元
}不同於 ISearchableItemProvider,範圍提供者絕不列舉或具體化檔案,因此不論配置的資料夾有多大,記憶體與每次擊鍵的負載都保持平穩。沒有任何宿主索引涵蓋的資料夾會被跳過並記錄一則警告,而不是即時走訪——這與索引器輔助方法遵循的規則是同一條:先把該資料夾涵蓋在已配置的本機磁碟機、網路或資料夾索引中。目錄一律通過 FilterPattern。
倉庫內的實作是檔案篩選(File Filters)外掛模組。
6. 觸發詞 TriggerWord
任何由使用者輸入一個前導詞來呼叫的功能——即時提供者的 QueryTriggerKeywords、動作的 Keywords、範圍的 Keyword、檔案篩選的觸發詞——都會透過 Lertaro.PluginSdk.Services.TriggerWord 來解析那個詞,因此宿主的移除步驟與外掛模組的比對不會各自跑偏。
| 輔助方法 | 比對 |
|---|---|
string Normalize(string? configured) | 把一個已配置的詞還原成每個比對所期待的形態:去掉前後空白,null 或空白時為空字串。請在讀取時正規化——宿主會裁剪它所移除的那個詞,因此若拿一個未裁剪的值來比對會什麼都認不出,而宿主仍會把那個詞從檔案搜尋中移除。 |
bool TryMatch(string query, string? word, out string argument) | 第一個 token 等於那個詞(不分大小寫)。argument 是剩餘、已裁剪的文字,當查詢除了那個詞之外別無其他時為空——而那仍然算命中。一個只是以它開頭的較長詞並不算命中(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) | 查詢是那個詞一個尚未完成的輸入(m 正通往 mkdir 的路上)。這是唯一允許在整個詞還沒輸入完之前就提供觸發的分支;一旦輸入任何分隔符即為 false。 |
IInstantResultProvider.QueryTriggerKeywords(預設為空)是宿主在自己的移除步驟中所讀取的內容,而 PluginConfigField.IsTriggerWord 標記那個存放此類詞的 Text 設定,讓設定頁能在另一個功能已經以同一個詞回應時提出警告——但不會封鎖儲存。否則,兩個功能共用一個詞是無聲無息的:檔案搜尋會遵循先註冊的那一個,而另一個的列就只是不再出現,也不會有任何東西告訴使用者該改命名哪一個。