diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 92872819..562b6e30 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -39,6 +39,14 @@ import pluginToolsAPI from './plugin/tools' import pluginUIAPI from './plugin/ui' import pluginWindowAPI from './plugin/window' import { setupImageAnalysisAPI } from './shared/imageAnalysis' +import { + matchesFilesInput, + matchesOverText, + matchesRegexText, + type FilesCmdLike, + type OverCmdLike, + type RegexCmdLike +} from '@shared/commandContextShared' import zbrowserAPI from './plugin/zbrowser' import pluginFFmpegAPI from './plugin/ffmpeg' @@ -314,35 +322,149 @@ class APIManager { return cmdType === 'text' || cmdType === 'over' || cmdType === 'regex' } + /** + * 获取快捷键上下文中的有效文本输入。 + */ + private getShortcutTextInput(context?: ShortcutLaunchContext): string { + return context?.pastedText ?? context?.searchQuery ?? '' + } + + /** + * 判断当前快捷键上下文是否包含可用于细粒度匹配的输入。 + */ + private hasContextualShortcutInput(context?: ShortcutLaunchContext): boolean { + if (!context) { + return false + } + + if (context.pastedImage) { + return true + } + + if (Array.isArray(context.pastedFiles) && context.pastedFiles.length > 0) { + return true + } + + return this.getShortcutTextInput(context).trim().length > 0 + } + + /** + * 判断文本类匹配指令是否满足当前快捷键上下文。 + */ + private matchesTextCommandContext(cmd: any, text: string): boolean { + if (cmd?.type === 'regex') { + return matchesRegexText(text, cmd as RegexCmdLike, { preserveFlags: true }) + } + + if (cmd?.type === 'over') { + return matchesOverText(text, cmd as OverCmdLike, { + useExclude: true, + preserveExcludeFlags: true, + allowPlainExclude: true + }) + } + + return false + } + + /** + * 判断文件类匹配指令是否满足当前快捷键上下文。 + */ + private matchesFilesCommandContext(cmd: any, files: ShortcutInputFile[]): boolean { + return matchesFilesInput(files, cmd as FilesCmdLike, { + preserveFlags: true, + allowPlainString: true + }) + } + + /** + * 计算候选命令在当前快捷键上下文下的优先级。 + */ + private getCommandContextPriority( + cmd: any, + cmdType: string, + context?: ShortcutLaunchContext + ): number { + if (!this.hasContextualShortcutInput(context)) { + return 0 + } + + if (cmdType === 'files') { + return this.matchesFilesCommandContext(cmd, context?.pastedFiles || []) ? 500 : -1 + } + + if (cmdType === 'img') { + return context?.pastedImage ? 450 : -1 + } + + const textInput = this.getShortcutTextInput(context) + if (cmdType === 'regex') { + return this.matchesTextCommandContext(cmd, textInput) ? 400 : -1 + } + + if (cmdType === 'over') { + return this.matchesTextCommandContext(cmd, textInput) ? 350 : -1 + } + + return 0 + } + /** * 在指定插件中查找匹配的命令 */ private async findCommandInPlugin( plugin: any, - cmdName: string + cmdName: string, + context?: ShortcutLaunchContext ): Promise<{ feature: any; cmdLabel: string; cmdType: string } | null> { const dynamicFeatures = pluginFeatureAPI.loadDynamicFeatures(plugin.name) const allFeatures = [...(plugin.features || []), ...dynamicFeatures] + const matches: Array<{ feature: any; cmdLabel: string; cmdType: string; cmd: any }> = [] for (const feature of allFeatures) { if (feature.cmds && Array.isArray(feature.cmds)) { for (const cmd of feature.cmds) { - // 处理字符串类型的命令 if (typeof cmd === 'string') { if (cmd === cmdName) { - return { feature, cmdLabel: cmd, cmdType: 'text' } + matches.push({ feature, cmdLabel: cmd, cmdType: 'text', cmd }) } + continue } - // 处理 object 类型的命令(regex 和 over 类型) - else if (typeof cmd === 'object' && cmd.label) { - if (cmd.label === cmdName) { - return { feature, cmdLabel: cmd.label, cmdType: cmd.type || 'text' } - } + + if (typeof cmd === 'object' && cmd?.label === cmdName) { + matches.push({ feature, cmdLabel: cmd.label, cmdType: cmd.type || 'text', cmd }) } } } } - return null + + if (matches.length === 0) { + return null + } + + if (!this.hasContextualShortcutInput(context)) { + const [firstMatch] = matches + return { + feature: firstMatch.feature, + cmdLabel: firstMatch.cmdLabel, + cmdType: firstMatch.cmdType + } + } + + const prioritizedMatch = matches + .map((match, index) => ({ + ...match, + index, + priority: this.getCommandContextPriority(match.cmd, match.cmdType, context) + })) + .sort((a, b) => b.priority - a.priority || a.index - b.index)[0] + + const selectedMatch = prioritizedMatch.priority > 0 ? prioritizedMatch : matches[0] + return { + feature: selectedMatch.feature, + cmdLabel: selectedMatch.cmdLabel, + cmdType: selectedMatch.cmdType + } } /** @@ -429,7 +551,7 @@ class APIManager { return } - const result = await this.findCommandInPlugin(plugin, cmdName) + const result = await this.findCommandInPlugin(plugin, cmdName, context) if (!result) { const msg = `[API] 未找到命令: ${pluginDescription}/${cmdName}` console.error(msg) @@ -452,7 +574,7 @@ class APIManager { const pluginMatches: { plugin: any; feature: any; cmdLabel: string; cmdType: string }[] = [] for (const plugin of pluginList) { - const result = await this.findCommandInPlugin(plugin, cmdName) + const result = await this.findCommandInPlugin(plugin, cmdName, context) if (result) { pluginMatches.push({ plugin, diff --git a/src/renderer/src/stores/commandDataStore.ts b/src/renderer/src/stores/commandDataStore.ts index a1e747c4..e1e71b4a 100644 --- a/src/renderer/src/stores/commandDataStore.ts +++ b/src/renderer/src/stores/commandDataStore.ts @@ -15,6 +15,17 @@ import { normalizeCommandAliases, type CommandAliasStore } from '@shared/commandShared' +import { + matchesFilesInput, + matchesOverText, + matchesRegexText, + matchesWindowInput, + parseMatchPattern, + type FilesCmdLike, + type OverCmdLike, + type RegexCmdLike, + type WindowCmdLike +} from '@shared/commandContextShared' import { ENABLED_MAIN_PUSH_PLUGINS_KEY, isMainPushPluginEnabled, @@ -1152,51 +1163,18 @@ export const useCommandDataStore = defineStore('commandData', () => { const regexMatches: SearchResult[] = [] for (const cmd of regexCommands.value) { if (cmd.matchCmd) { - if (cmd.matchCmd.type === 'regex') { - // Regex 类型匹配 - // 检查用户输入长度是否满足最小要求 - if (query.length < cmd.matchCmd.minLength) { - continue - } - - try { - // 提取正则表达式(去掉两边的斜杠和标志) - const regexStr = cmd.matchCmd.match.replace(/^\/|\/[gimuy]*$/g, '') - const regex = new RegExp(regexStr) - - // 测试用户输入是否匹配 - if (regex.test(query)) { - regexMatches.push(cmd) - } - } catch (error) { - console.error(`正则表达式 ${cmd.matchCmd.match} 解析失败:`, error) - } - } else if (cmd.matchCmd.type === 'over') { - // Over 类型匹配 - const minLength = cmd.matchCmd.minLength ?? 1 - const maxLength = cmd.matchCmd.maxLength ?? 10000 - - // 检查长度是否满足要求 - if (query.length < minLength || query.length > maxLength) { - continue - } - - // 检查是否被排除 - if (cmd.matchCmd.exclude) { - try { - const excludeRegexStr = cmd.matchCmd.exclude.replace(/^\/|\/[gimuy]*$/g, '') - const excludeRegex = new RegExp(excludeRegexStr) - - // 如果匹配到排除规则,跳过 - if (excludeRegex.test(query)) { - continue - } - } catch (error) { - console.error(`排除正则表达式 ${cmd.matchCmd.exclude} 解析失败:`, error) - } - } - - // 通过所有检查,添加到匹配结果 + if ( + cmd.matchCmd.type === 'regex' && + matchesRegexText(query, cmd.matchCmd as RegexCmdLike, { preserveFlags: false }) + ) { + regexMatches.push(cmd) + } else if ( + cmd.matchCmd.type === 'over' && + matchesOverText(query, cmd.matchCmd as OverCmdLike, { + useExclude: true, + preserveExcludeFlags: false + }) + ) { regexMatches.push(cmd) } } @@ -1235,40 +1213,14 @@ export const useCommandDataStore = defineStore('commandData', () => { const result = regexCommands.value.filter((cmd) => { // 支持 over 类型 if (cmd.matchCmd?.type === 'over') { - const textLength = pastedText.length - const minLength = cmd.matchCmd.minLength ?? 1 - const maxLength = cmd.matchCmd.maxLength ?? 10000 - - return textLength >= minLength && textLength <= maxLength + return matchesOverText(pastedText, cmd.matchCmd as OverCmdLike) } // 支持 regex 类型 if (cmd.matchCmd?.type === 'regex') { - const textLength = pastedText.length - const minLength = cmd.matchCmd.minLength ?? 1 - - // 检查长度 - if (textLength < minLength) { - return false - } - - // 检查正则匹配 - const regexStr = cmd.matchCmd.match - if (regexStr) { - try { - // 解析正则表达式字符串(格式:/pattern/flags) - const match = regexStr.match(/^\/(.+)\/([gimuy]*)$/) - if (match) { - const pattern = match[1] - const flags = match[2] - const regex = new RegExp(pattern, flags) - return regex.test(pastedText) - } - } catch (error) { - console.error('正则表达式解析失败:', regexStr, error) - return false - } - } + return matchesRegexText(pastedText, cmd.matchCmd as RegexCmdLike, { + preserveFlags: true + }) } return false @@ -1288,70 +1240,12 @@ export const useCommandDataStore = defineStore('commandData', () => { const filesCommandsList = regexCommands.value.filter((c) => c.matchCmd?.type === 'files') - const result = filesCommandsList.filter((cmd) => { - const filesCmd = cmd.matchCmd as FilesCmd - - // 1. 检查文件数量是否满足要求 - const fileCount = pastedFiles.length - const minLength = filesCmd.minLength ?? 1 - const maxLength = filesCmd.maxLength ?? 10000 - - if (fileCount < minLength || fileCount > maxLength) { - return false - } - - // 2. 检查每个文件是否满足条件 - const allFilesMatch = pastedFiles.every((file) => { - // 2.1 检查文件类型(file 或 directory) - if (filesCmd.fileType) { - if (filesCmd.fileType === 'file' && file.isDirectory) { - return false - } - if (filesCmd.fileType === 'directory' && !file.isDirectory) { - return false - } - } - - // 2.2 检查文件扩展名(只对文件有效,不检查文件夹) - if (filesCmd.extensions && !file.isDirectory) { - const ext = file.name.split('.').pop()?.toLowerCase() - const allowedExts = filesCmd.extensions.map((e) => e.toLowerCase()) - if (!ext || !allowedExts.includes(ext)) { - return false - } - } - - // 2.3 检查正则表达式匹配 - if (filesCmd.match) { - try { - // 解析正则表达式字符串(格式:/pattern/flags) - const match = filesCmd.match.match(/^\/(.+)\/([gimuy]*)$/) - if (match) { - const pattern = match[1] - const flags = match[2] - const regex = new RegExp(pattern, flags) - const testResult = regex.test(file.name) - if (!testResult) { - return false - } - } else { - // 如果不是标准格式,直接作为字符串匹配 - const testResult = file.name.includes(filesCmd.match) - if (!testResult) { - return false - } - } - } catch (error) { - console.error(`正则表达式 ${filesCmd.match} 解析失败:`, error) - return false - } - } - - return true + const result = filesCommandsList.filter((cmd) => + matchesFilesInput(pastedFiles, cmd.matchCmd as FilesCmdLike, { + preserveFlags: true, + allowPlainString: true }) - - return allFilesMatch - }) + ) // 应用特殊指令配置,过滤禁用指令 return result.filter((cmd) => !isCommandDisabled(cmd)).map((cmd) => applySpecialConfig(cmd)) @@ -1359,14 +1253,20 @@ export const useCommandDataStore = defineStore('commandData', () => { const windowTitleRegexCache = new Map() + // 缓存窗口标题匹配使用的正则对象。 function getCachedWindowTitleRegex(pattern: string): RegExp | null { if (windowTitleRegexCache.has(pattern)) { return windowTitleRegexCache.get(pattern) || null } try { - const titleRegexStr = pattern.replace(/^\/|\/[gimuy]*$/g, '') - const regex = new RegExp(titleRegexStr) + const regex = parseMatchPattern(pattern, { + preserveFlags: false, + allowPlainString: true + }) + if (!regex) { + throw new Error('invalid regex pattern') + } windowTitleRegexCache.set(pattern, regex) return regex } catch (error) { @@ -1376,6 +1276,7 @@ export const useCommandDataStore = defineStore('commandData', () => { } } + // 判断窗口命令是否匹配当前活动窗口。 function matchesWindowCommand( command: Command, windowInfo?: { app?: string; title?: string; className?: string } | null @@ -1389,30 +1290,14 @@ export const useCommandDataStore = defineStore('commandData', () => { } const windowCmd = command.matchCmd as WindowCmd + const titleRegex = windowCmd.match.title + ? getCachedWindowTitleRegex(windowCmd.match.title) + : null - // 检查 app 匹配 - if (windowCmd.match.app && windowInfo.app) { - const appMatches = windowCmd.match.app.some((appPattern) => { - // 直接字符串匹配 - return windowInfo.app === appPattern - }) - const classNameMatches = windowCmd.match.className?.some( - (classNamePattern) => classNamePattern === (windowInfo.className || '') - ) - if (appMatches && (!windowCmd.match.className || classNameMatches)) { - return true - } - } - - // 检查 title 匹配(正则表达式) - if (windowCmd.match.title && windowInfo.title) { - const titleRegex = getCachedWindowTitleRegex(windowCmd.match.title) - if (titleRegex?.test(windowInfo.title)) { - return true - } - } - - return false + return matchesWindowInput(windowInfo, windowCmd as WindowCmdLike, { + titleRegex, + preserveTitleFlags: false + }) } // 搜索支持窗口的指令(根据当前激活窗口进行匹配) @@ -1634,30 +1519,18 @@ export const useCommandDataStore = defineStore('commandData', () => { break } } else if (cmd.type === 'regex') { - if (query.length >= (cmd.minLength || 0)) { - try { - const regexStr = cmd.match.replace(/^\/|\/[gimuy]*$/g, '') - if (new RegExp(regexStr).test(query)) { - matched = true - matchedCmdType = 'regex' - break - } - } catch { - /* 忽略无效正则 */ - } + if (matchesRegexText(query, cmd as RegexCmdLike, { preserveFlags: false })) { + matched = true + matchedCmdType = 'regex' + break } } else if (cmd.type === 'over') { - const minLen = cmd.minLength ?? 1 - const maxLen = cmd.maxLength ?? 10000 - if (query.length >= minLen && query.length <= maxLen) { - if (cmd.exclude) { - try { - const excludeStr = cmd.exclude.replace(/^\/|\/[gimuy]*$/g, '') - if (new RegExp(excludeStr).test(query)) continue - } catch { - /* 忽略 */ - } - } + if ( + matchesOverText(query, cmd as OverCmdLike, { + useExclude: true, + preserveExcludeFlags: false + }) + ) { matched = true matchedCmdType = 'over' break diff --git a/src/shared/commandContextShared.ts b/src/shared/commandContextShared.ts new file mode 100644 index 00000000..0ad98ded --- /dev/null +++ b/src/shared/commandContextShared.ts @@ -0,0 +1,258 @@ +/** + * 命令上下文匹配所需的最小文件输入结构。 + */ +export interface PastedFileLike { + path?: string + name: string + isDirectory: boolean +} + +/** + * 命令上下文匹配所需的最小窗口信息结构。 + */ +export interface WindowInfoLike { + app?: string + title?: string + className?: string +} + +/** + * Regex 类型命令的最小字段集合。 + */ +export interface RegexCmdLike { + type: 'regex' + label?: string + match?: string + regex?: string + minLength?: number +} + +/** + * Over 类型命令的最小字段集合。 + */ +export interface OverCmdLike { + type: 'over' + label?: string + exclude?: string + minLength?: number + maxLength?: number +} + +/** + * Files 类型命令的最小字段集合。 + */ +export interface FilesCmdLike { + type: 'files' + label?: string + fileType?: 'file' | 'directory' + extensions?: string[] + match?: string + minLength?: number + maxLength?: number +} + +/** + * Window 类型命令的最小字段集合。 + */ +export interface WindowCmdLike { + type: 'window' + label?: string + match: { + app?: string[] + title?: string + className?: string[] + } +} + +/** + * 可参与上下文匹配的命令联合类型。 + */ +export type MatchCmdLike = RegexCmdLike | OverCmdLike | FilesCmdLike | WindowCmdLike + +/** + * 解析命令匹配中使用的 pattern,兼容是否保留 flags 和普通字符串回退。 + */ +export function parseMatchPattern( + pattern?: string, + options: { + preserveFlags?: boolean + allowPlainString?: boolean + } = {} +): RegExp | null { + if (!pattern) { + return null + } + + try { + const slashMatch = pattern.match(/^\/(.+)\/([gimuy]*)$/) + if (slashMatch) { + const flags = options.preserveFlags ? slashMatch[2] : '' + return new RegExp(slashMatch[1], flags) + } + + if (options.allowPlainString) { + return new RegExp(pattern) + } + } catch { + return null + } + + return null +} + +/** + * 判断 regex 类型命令是否匹配指定文本。 + */ +export function matchesRegexText( + text: string, + cmd: RegexCmdLike, + options: { + preserveFlags?: boolean + } = {} +): boolean { + if (!text) { + return false + } + + const minLength = cmd.minLength ?? 1 + if (text.length < minLength) { + return false + } + + const regex = parseMatchPattern(cmd.match ?? cmd.regex, { + preserveFlags: options.preserveFlags + }) + return regex ? regex.test(text) : false +} + +/** + * 判断 over 类型命令是否匹配指定文本。 + */ +export function matchesOverText( + text: string, + cmd: OverCmdLike, + options: { + useExclude?: boolean + preserveExcludeFlags?: boolean + allowPlainExclude?: boolean + } = {} +): boolean { + if (!text) { + return false + } + + const minLength = cmd.minLength ?? 1 + const maxLength = cmd.maxLength ?? 10000 + if (text.length < minLength || text.length > maxLength) { + return false + } + + if (options.useExclude && cmd.exclude) { + const excludeRegex = parseMatchPattern(cmd.exclude, { + preserveFlags: options.preserveExcludeFlags, + allowPlainString: options.allowPlainExclude + }) + if (excludeRegex?.test(text)) { + return false + } + } + + return true +} + +/** + * 判断 files 类型命令是否匹配指定文件输入。 + */ +export function matchesFilesInput( + files: PastedFileLike[], + cmd: FilesCmdLike, + options: { + preserveFlags?: boolean + allowPlainString?: boolean + } = {} +): boolean { + if (files.length === 0) { + return false + } + + const minLength = cmd.minLength ?? 1 + const maxLength = cmd.maxLength ?? 10000 + if (files.length < minLength || files.length > maxLength) { + return false + } + + return files.every((file) => { + if (cmd.fileType === 'file' && file.isDirectory) { + return false + } + + if (cmd.fileType === 'directory' && !file.isDirectory) { + return false + } + + if (Array.isArray(cmd.extensions) && cmd.extensions.length > 0 && !file.isDirectory) { + const ext = file.name.split('.').pop()?.toLowerCase() + const allowedExts = cmd.extensions.map((item) => item.toLowerCase()) + if (!ext || !allowedExts.includes(ext)) { + return false + } + } + + if (cmd.match) { + const regex = parseMatchPattern(cmd.match, { + preserveFlags: options.preserveFlags + }) + if (regex) { + return regex.test(file.name) + } + + if (options.allowPlainString) { + return file.name.includes(cmd.match) + } + + return false + } + + return true + }) +} + +/** + * 判断 window 类型命令是否匹配指定窗口信息。 + */ +export function matchesWindowInput( + windowInfo: WindowInfoLike | null | undefined, + cmd: WindowCmdLike, + options: { + titleRegex?: RegExp | null + preserveTitleFlags?: boolean + } = {} +): boolean { + if (!windowInfo || (!windowInfo.app && !windowInfo.title)) { + return false + } + + if (cmd.match.app && windowInfo.app) { + const appMatches = cmd.match.app.some((appPattern) => windowInfo.app === appPattern) + const classNameMatches = cmd.match.className?.some( + (classNamePattern) => classNamePattern === (windowInfo.className || '') + ) + if (appMatches && (!cmd.match.className || classNameMatches)) { + return true + } + } + + if (cmd.match.title && windowInfo.title) { + const titleRegex = + options.titleRegex ?? + parseMatchPattern(cmd.match.title, { + preserveFlags: options.preserveTitleFlags, + allowPlainString: true + }) + if (titleRegex?.test(windowInfo.title)) { + return true + } + } + + return false +} diff --git a/tests/main/commandContextShared.test.ts b/tests/main/commandContextShared.test.ts new file mode 100644 index 00000000..c0bac1c9 --- /dev/null +++ b/tests/main/commandContextShared.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import { + matchesFilesInput, + matchesOverText, + matchesRegexText, + matchesWindowInput, + parseMatchPattern +} from '../../src/shared/commandContextShared' + +describe('commandContextShared', () => { + describe('parseMatchPattern', () => { + it('保留 flags 解析标准正则字符串', () => { + const regex = parseMatchPattern('/hello/gi', { preserveFlags: true }) + expect(regex?.flags).toBe('gi') + expect(regex?.test('HELLO')).toBe(true) + }) + + it('可按需丢弃 flags', () => { + const regex = parseMatchPattern('/hello/i', { preserveFlags: false }) + expect(regex?.flags).toBe('') + expect(regex?.test('HELLO')).toBe(false) + expect(regex?.test('hello')).toBe(true) + }) + + it('允许普通字符串回退为正则', () => { + const regex = parseMatchPattern('\\.txt$', { allowPlainString: true }) + expect(regex?.test('readme.txt')).toBe(true) + }) + + it('无效正则返回 null', () => { + expect(parseMatchPattern('/[a-/')).toBeNull() + }) + }) + + describe('matchesRegexText', () => { + it('按最小长度和正则匹配文本', () => { + expect( + matchesRegexText('https://claude.ai', { + type: 'regex', + match: '/^https?:\\/\\//', + minLength: 3 + }) + ).toBe(true) + }) + + it('preserveFlags=false 时保持当前 query 搜索语义', () => { + expect( + matchesRegexText('HELLO', { + type: 'regex', + match: '/hello/i', + minLength: 1 + }) + ).toBe(false) + }) + + it('preserveFlags=true 时支持 flags', () => { + expect( + matchesRegexText( + 'HELLO', + { + type: 'regex', + match: '/hello/i', + minLength: 1 + }, + { preserveFlags: true } + ) + ).toBe(true) + }) + }) + + describe('matchesOverText', () => { + it('按长度范围匹配文本', () => { + expect( + matchesOverText('abc', { + type: 'over', + minLength: 1, + maxLength: 5 + }) + ).toBe(true) + }) + + it('启用 exclude 时可排除命中', () => { + expect( + matchesOverText( + 'hello123', + { + type: 'over', + minLength: 1, + maxLength: 20, + exclude: '/\\d+/' + }, + { useExclude: true, preserveExcludeFlags: true, allowPlainExclude: true } + ) + ).toBe(false) + }) + + it('不启用 exclude 时保持 searchTextCommands 现有语义', () => { + expect( + matchesOverText('hello123', { + type: 'over', + minLength: 1, + maxLength: 20, + exclude: '/\\d+/' + }) + ).toBe(true) + }) + }) + + describe('matchesFilesInput', () => { + const files = [ + { path: 'C:/demo/a.txt', name: 'a.txt', isDirectory: false }, + { path: 'C:/demo/b.txt', name: 'b.txt', isDirectory: false } + ] + + it('按数量、类型与扩展名匹配文件', () => { + expect( + matchesFilesInput(files, { + type: 'files', + fileType: 'file', + extensions: ['txt'], + minLength: 2, + maxLength: 2 + }) + ).toBe(true) + }) + + it('支持正则文件名匹配并保留 flags', () => { + expect( + matchesFilesInput( + [{ path: 'C:/demo/README.TXT', name: 'README.TXT', isDirectory: false }], + { + type: 'files', + match: '/\\.txt$/i', + minLength: 1, + maxLength: 1 + }, + { preserveFlags: true } + ) + ).toBe(true) + }) + + it('非正则格式按需回退到 includes', () => { + expect( + matchesFilesInput( + [{ path: 'C:/demo/plugin.zpx', name: 'plugin.zpx', isDirectory: false }], + { + type: 'files', + match: '.zpx', + minLength: 1, + maxLength: 1 + }, + { allowPlainString: true } + ) + ).toBe(true) + }) + }) + + describe('matchesWindowInput', () => { + it('支持 app + className 匹配', () => { + expect( + matchesWindowInput( + { app: 'explorer.exe', className: 'CabinetWClass' }, + { + type: 'window', + match: { + app: ['explorer.exe'], + className: ['CabinetWClass'] + } + } + ) + ).toBe(true) + }) + + it('支持 title 正则匹配', () => { + expect( + matchesWindowInput( + { title: 'Claude Code - Workspace' }, + { + type: 'window', + match: { + title: '/Claude\\s+Code/' + } + }, + { + preserveTitleFlags: false + } + ) + ).toBe(true) + }) + + it('支持普通字符串 title 匹配', () => { + expect( + matchesWindowInput( + { title: 'Project - Claude Code' }, + { + type: 'window', + match: { + title: 'Claude Code' + } + } + ) + ).toBe(true) + }) + + it('app 未命中时可由 title 命中', () => { + expect( + matchesWindowInput( + { app: 'notepad.exe', title: 'Project - Claude Code' }, + { + type: 'window', + match: { + app: ['explorer.exe'], + title: '/Claude\\s+Code/' + } + } + ) + ).toBe(true) + }) + }) +})