diff --git a/CHANGELOG.md b/CHANGELOG.md index 8909c9a..3fc7c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ 本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 +## 0.0.6 + +### 极速搜索 + +- 搜索结果改用后端分页与游标,滚动接近底部时继续加载;筛选、排序和动态变化都针对完整结果集执行。 +- 名称搜索支持包含匹配、完整词匹配、模糊匹配和正则匹配,正则模式继续提供模板和规则辅助。 +- 搜索结果表格支持拖动调整列宽,并为可迁移目录增加快捷迁移入口。 +- 重新整理文件类型、搜索位置、匹配方式、排序与更多条件的界面层级,在独立窗口较窄时优先保持排序区单行。 + +### 新增主题 + +- 新增“熔橙 · 熔芯蜂巢”:橙黑工业 HUD、蜂巢背景、切面控件和能量进度效果。 +- 新增“暖瓷 · 暖瓷晨光”:米白纸感、陶土色强调、柔和卡片和低干扰动态背景。 +- 五套主题统一覆盖主窗口、独立极速搜索、托盘原生菜单、上下文菜单、悬浮提示、属性窗口和迁移/更新进度。 +- 独立极速搜索标题栏增加紧凑主题切换器,设置与主窗口共享并持久保存。 + +### 设置与交互 + +- 设置页按更新诊断、界面外观、索引快捷操作和 AI 服务重新分组,减少过长页面与无效空白。 +- 主题切换不再触发全局快捷键重新注册错误;冲突状态留在对应设置项中等待用户处理。 +- 保留搜索状态、书签、筛选、列宽、窗口位置以及即时生效的设置行为。 + ## 0.0.5 ### 实时搜索与后台资源 diff --git a/README.md b/README.md index fedacd1..a121445 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: 当前版本完全使用自研索引管线,不调用 Everything。 -当前正式版本:`0.0.5`。安装包与便携包见 +当前正式版本:`0.0.6`。安装包与便携包见 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 ## 已实现能力 diff --git a/README.zh-CN.md b/README.zh-CN.md index 391bfec..cbb5726 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属分析与跨盘安全迁移工具。名称索引、内容索引和迁移记录都由项目自身维护,不依赖 Everything。 -当前版本:`0.0.5` +当前版本:`0.0.6` ## 下载与运行 diff --git a/docs/RELEASE-0.0.6.md b/docs/RELEASE-0.0.6.md new file mode 100644 index 0000000..84cb349 --- /dev/null +++ b/docs/RELEASE-0.0.6.md @@ -0,0 +1,31 @@ +# CDriveShiftAI 0.0.6 发布说明 + +本版重点升级极速搜索工作区,并新增两套完整视觉主题。搜索结果可以持续加载、动态更新、按完整结果集排序,同时保持托盘静默时的低资源占用策略。 + +## 极速搜索 + +- 后端分页与游标支持连续懒加载,不再受首批 800 条名称结果或 160 条内容结果的界面上限限制。 +- 筛选与排序在后端面向完整结果集执行;新增、删除、改名和移动继续按照当前规则实时更新。 +- 支持包含匹配、完整词匹配、模糊匹配和正则匹配。 +- 结果表头可以拖动调整列宽,并保存用户设置。 +- 文件夹结果提供“分析”和“迁移”快捷操作,文件继续支持默认打开、选择应用、属性、重命名和删除等操作。 + +## 两套新增主题 + +- **熔橙 · 熔芯蜂巢**:黑棕底色、橙色能量线、蜂巢网格、工业切面卡片和高对比进度效果。 +- **暖瓷 · 暖瓷晨光**:暖米白背景、陶土色强调、柔和阴影和编辑式排版。 +- 两套主题均覆盖主窗口、独立极速搜索、托盘菜单、上下文菜单、悬浮提示、属性窗口、对话框以及迁移和更新进度。 +- 独立极速搜索窗口右上角可以直接切换全部五套主题,选择会同步到主窗口并持久保存。 + +## 设置与交互 + +- 设置页重新按功能拆分模块,减少纵向滚动和卡片中的无效空白。 +- 主题切换不会重新注册全局快捷键;快捷键冲突只在对应设置项显示红色状态提醒。 +- 搜索筛选区在普通与独立窗口中采用统一结构,并优先保持排序与属性区域单行。 + +## 验证 + +- TypeScript 与 Electron 类型检查通过。 +- 37 项单元测试通过。 +- 安装包与便携包完成实际构建。 +- 便携包单文件启动及项目旁数据目录测试通过。 diff --git a/electron/main.ts b/electron/main.ts index 50c1cef..85c4011 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -144,7 +144,9 @@ const effectColors: Record< > = { aurora: { background: "#183841", symbols: "#f2e7c6", nativeTheme: "dark" }, matrix: { background: "#050815", symbols: "#a8eefa", nativeTheme: "dark" }, - calm: { background: "#edf4f5", symbols: "#526f7e", nativeTheme: "light" } + calm: { background: "#edf4f5", symbols: "#526f7e", nativeTheme: "light" }, + ember: { background: "#0b0705", symbols: "#ffb078", nativeTheme: "dark" }, + ivory: { background: "#f6f2ea", symbols: "#75534b", nativeTheme: "light" } }; function applyNativeEffect( @@ -734,17 +736,20 @@ function createTray(): void { submenu: ([ ["aurora", "方块 · 像素湖境"], ["matrix", "科技 · HUD 数据流"], - ["calm", "晶境 · 玻璃流光"] + ["calm", "晶境 · 玻璃流光"], + ["ember", "熔橙 · 熔芯蜂巢"], + ["ivory", "暖瓷 · 米白陶影"] ] as const).map(([effectMode, label]) => ({ label, type: "radio" as const, checked: settings.effectMode === effectMode, icon: createTrayMenuIcon( - effectMode === "aurora" - ? "theme-aurora" - : effectMode === "matrix" - ? "theme-matrix" - : "theme-calm" + `theme-${effectMode}` as + | "theme-aurora" + | "theme-matrix" + | "theme-calm" + | "theme-ember" + | "theme-ivory" ), click: () => { void store.updateSettings({ effectMode }).then((updated) => { @@ -1077,10 +1082,15 @@ function registerIpc(): void { ...previous, ...(safePatch as Partial>) }; - const shortcutFailures = applyGlobalShortcuts(candidate); - if (shortcutFailures.length > 0) { - applyGlobalShortcuts(previous); - throw new Error(shortcutFailures.join(";")); + const changesKeyboardShortcuts = + Object.prototype.hasOwnProperty.call(safePatch, "globalShortcut") || + Object.prototype.hasOwnProperty.call(safePatch, "quickSearchShortcut"); + if (changesKeyboardShortcuts) { + const shortcutFailures = applyGlobalShortcuts(candidate); + if (shortcutFailures.length > 0) { + applyGlobalShortcuts(previous); + throw new Error(shortcutFailures.join(";")); + } } try { const settings = await store.updateSettings( @@ -1099,7 +1109,7 @@ function registerIpc(): void { createTray(); return settings; } catch (error) { - applyGlobalShortcuts(previous); + if (changesKeyboardShortcuts) applyGlobalShortcuts(previous); throw error; } }); @@ -1519,7 +1529,7 @@ function registerIpc(): void { ipcMain.handle( "search:query", - async (_event, query: unknown, filters: unknown) => { + async (_event, query: unknown, filters: unknown, pageOptions?: unknown) => { const value = assertString(query, "搜索词", 512); const raw = (filters ?? {}) as Partial; const allowedCategories = new Set([ @@ -1542,6 +1552,9 @@ function registerIpc(): void { "type" ]); const allowedSortDirections = new Set(["asc", "desc"]); + const regex = raw.regex === true; + const fuzzy = !regex && raw.fuzzy === true; + const wholeWord = !regex && !fuzzy && raw.wholeWord === true; const safeFilters: SearchFilters = { kind: ["all", "folder", "file"].includes(raw.kind ?? "") ? raw.kind! : "all", scope: typeof raw.scope === "string" && raw.scope ? raw.scope : "*", @@ -1568,9 +1581,10 @@ function registerIpc(): void { modifiedAfter: typeof raw.modifiedAfter === "string" ? raw.modifiedAfter : undefined, modifiedBefore: typeof raw.modifiedBefore === "string" ? raw.modifiedBefore : undefined, caseSensitive: raw.caseSensitive === true, - wholeWord: raw.wholeWord === true, + wholeWord, + fuzzy, matchPath: raw.matchPath === true, - regex: raw.regex === true, + regex, sortBy: raw.sortBy && allowedSortFields.has(raw.sortBy) ? raw.sortBy : "relevance", sortDirection: @@ -1578,6 +1592,19 @@ function registerIpc(): void { ? raw.sortDirection : "desc" }; + if (pageOptions && typeof pageOptions === "object") { + const rawPage = pageOptions as { cursor?: unknown; limit?: unknown }; + return searchService!.searchPage(value, safeFilters, { + cursor: + typeof rawPage.cursor === "string" && rawPage.cursor.length <= 256 + ? rawPage.cursor + : undefined, + limit: + typeof rawPage.limit === "number" + ? Math.min(10_000, Math.max(1, Math.trunc(rawPage.limit))) + : undefined + }); + } return searchService!.search(value, safeFilters); } ); @@ -1646,6 +1673,59 @@ function registerIpc(): void { } ); + ipcMain.handle( + "search:content-query-page", + (_event, query: unknown, scope: unknown, options: unknown, pageOptions: unknown) => { + const raw = + options && typeof options === "object" + ? (options as { + regex?: unknown; + caseSensitive?: unknown; + sortBy?: unknown; + sortDirection?: unknown; + minSize?: unknown; + maxSize?: unknown; + modifiedAfter?: unknown; + modifiedBefore?: unknown; + }) + : {}; + const rawPage = + pageOptions && typeof pageOptions === "object" + ? (pageOptions as { cursor?: unknown; limit?: unknown }) + : {}; + return searchService!.searchContentPage( + assertString(query, "内容搜索词", 2_048), + assertString(scope, "内容搜索目录"), + { + regex: raw.regex === true, + caseSensitive: raw.caseSensitive === true, + sortBy: ["relevance", "name", "path", "size", "modified", "type"].includes( + String(raw.sortBy) + ) + ? (raw.sortBy as SearchSortField) + : "relevance", + sortDirection: ["asc", "desc"].includes(String(raw.sortDirection)) + ? (raw.sortDirection as SearchSortDirection) + : "desc", + minSize: typeof raw.minSize === "number" ? Math.max(0, raw.minSize) : undefined, + maxSize: typeof raw.maxSize === "number" ? Math.max(0, raw.maxSize) : undefined, + modifiedAfter: + typeof raw.modifiedAfter === "string" ? raw.modifiedAfter : undefined, + modifiedBefore: + typeof raw.modifiedBefore === "string" ? raw.modifiedBefore : undefined, + cursor: + typeof rawPage.cursor === "string" && rawPage.cursor.length <= 256 + ? rawPage.cursor + : undefined, + limit: + typeof rawPage.limit === "number" + ? Math.min(2_000, Math.max(1, Math.trunc(rawPage.limit))) + : undefined + } + ); + } + ); + ipcMain.handle("analysis:summarize", (_event, targetPath: unknown) => summarizeDirectory(assertString(targetPath, "目录")) ); @@ -1855,17 +1935,7 @@ if (!singleInstance) { syncSearchBackgroundMode(); triggerVisibleUpdateCheck("application-startup", 2_500); const shortcutFailures = applyGlobalShortcuts(store.getSettings()); - if (shortcutFailures.length > 0 && mainWindow && !mainWindow.isDestroyed()) { - void dialog.showMessageBox(mainWindow, { - type: "warning", - title: "全局快捷键未完全启用", - message: shortcutFailures.join("\n"), - detail: "可在设置中修改快捷键后重新保存。", - buttons: ["知道了"], - noLink: true - }); - } - if (shortcutFailures.length > 0 && startupMinimized) { + if (shortcutFailures.length > 0) { logger.warn("shortcut.registration_incomplete", { startupMinimized, failures: shortcutFailures diff --git a/electron/preload.ts b/electron/preload.ts index fc38bf3..358bcce 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -62,8 +62,42 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { ipcRenderer.invoke("shell:search-context-menu", targetPath, isDirectory), openExternal: (url: string) => ipcRenderer.invoke("shell:external", url), search: (query: string, filters: unknown) => ipcRenderer.invoke("search:query", query, filters), + searchPage: (query: string, filters: unknown, pageOptions?: unknown) => + ipcRenderer.invoke("search:query", query, filters, pageOptions ?? {}), searchContent: (query: string, scope: string, options?: unknown) => ipcRenderer.invoke("search:content-query", query, scope, options), + searchContentPage: ( + query: string, + scope: string, + options?: { + regex?: boolean; + caseSensitive?: boolean; + sortBy?: string; + sortDirection?: string; + minSize?: number; + maxSize?: number; + modifiedAfter?: string; + modifiedBefore?: string; + cursor?: string; + limit?: number; + } + ) => + ipcRenderer.invoke( + "search:content-query-page", + query, + scope, + { + regex: options?.regex, + caseSensitive: options?.caseSensitive, + sortBy: options?.sortBy, + sortDirection: options?.sortDirection, + minSize: options?.minSize, + maxSize: options?.maxSize, + modifiedAfter: options?.modifiedAfter, + modifiedBefore: options?.modifiedBefore + }, + { cursor: options?.cursor, limit: options?.limit } + ), indexContent: (scope: string) => ipcRenderer.invoke("search:content-index", scope), contentIndexStatus: (scope: string) => ipcRenderer.invoke("search:content-status", scope), indexerStatus: () => ipcRenderer.invoke("search:status"), diff --git a/electron/search.ts b/electron/search.ts index e7aff7a..d2b24d8 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -7,6 +7,7 @@ import { uptime } from "node:os"; import path from "node:path"; import type { ContentIndexerStatus, + ContentSearchOptions, ContentSearchResult, DirectorySizeResult, IndexerStatus, @@ -15,6 +16,8 @@ import type { NativeResponse, SearchIndexChangedEvent, SearchFilters, + SearchPage, + SearchPageOptions, SearchResult } from "./types"; import { getLocalDriveRoots } from "./system"; @@ -39,6 +42,54 @@ async function fileExists(candidate: string): Promise { } } +const wordCharacterPattern = /[\p{L}\p{N}_]/u; + +function containsCompleteWord(target: string, needle: string): boolean { + let start = target.indexOf(needle); + while (start >= 0) { + const before = Array.from(target.slice(0, start)).at(-1); + const after = Array.from(target.slice(start + needle.length))[0]; + if ( + (!before || !wordCharacterPattern.test(before)) && + (!after || !wordCharacterPattern.test(after)) + ) { + return true; + } + start = target.indexOf(needle, start + Math.max(1, needle.length)); + } + return false; +} + +function fuzzySubsequenceScore(target: string, needle: string): number | undefined { + const wanted = Array.from(needle); + if (wanted.length === 0) return undefined; + let wantedIndex = 0; + let first = -1; + let last = -1; + let consecutivePairs = 0; + let position = 0; + for (const character of target) { + if (character === wanted[wantedIndex]) { + if (first < 0) first = position; + if (last >= 0 && position === last + 1) consecutivePairs += 1; + last = position; + wantedIndex += 1; + if (wantedIndex === wanted.length) { + const span = last - first + 1; + return ( + 48 + + (wanted.length / Math.max(1, span)) * 22 + + (consecutivePairs / Math.max(1, wanted.length - 1)) * 15 + + (1 / (1 + first * 0.15)) * 9 + + (needle.length / Math.max(needle.length, target.length)) * 6 + ); + } + } + position += 1; + } + return undefined; +} + export class SearchService { private child?: ChildProcessWithoutNullStreams; private requestId = 0; @@ -344,6 +395,15 @@ export class SearchService { scope: string, options: { regex?: boolean; caseSensitive?: boolean } = {} ): Promise { + return (await this.searchContentPage(query, scope, options)).items; + } + + async searchContentPage( + query: string, + scope: string, + options: ContentSearchOptions & SearchPageOptions = {} + ): Promise> { + const limit = Math.min(2_000, Math.max(1, Math.trunc(options.limit ?? 80))); if (!this.child) throw new Error("原生索引核心不可用"); const response = await this.request( { @@ -352,13 +412,24 @@ export class SearchService { scope, regex: options.regex === true, caseSensitive: options.caseSensitive === true, - limit: 160 + sortBy: options.sortBy ?? "relevance", + sortDirection: options.sortDirection ?? "desc", + minSize: options.minSize, + maxSize: options.maxSize, + modifiedAfterMs: options.modifiedAfter + ? Date.parse(options.modifiedAfter) + : undefined, + modifiedBeforeMs: options.modifiedBefore + ? Date.parse(options.modifiedBefore) + : undefined, + cursor: options.cursor, + limit }, options.regex ? 30_000 : 15_000 ); - const results = (response.results ?? []) as ContentSearchResult[]; - return Promise.all( - results.map(async (result) => { + const nativeResults = (response.results ?? []) as ContentSearchResult[]; + const results = await Promise.all( + nativeResults.map(async (result) => { try { const stats = await lstat(result.path); return { @@ -371,13 +442,39 @@ export class SearchService { } }) ); + return { + items: results, + hasMore: response.hasMore === true, + nextCursor: response.nextCursor, + totalMatches: response.totalMatches, + generation: response.generation ?? 0, + cursorReset: response.cursorReset === true + }; } async search(query: string, filters: SearchFilters): Promise { + return (await this.searchPage(query, filters)).items; + } + + async searchPage( + query: string, + filters: SearchFilters, + options: SearchPageOptions = {} + ): Promise> { const trimmed = query.trim(); - if (!trimmed) return []; + if (!trimmed) { + return { items: [], hasMore: false, generation: 0 }; + } + const limit = Math.min(10_000, Math.max(1, Math.trunc(options.limit ?? 240))); let results: SearchResult[]; + let hasMore = false; + let nextCursor: string | undefined; + let totalMatches: number | undefined; + let generation = 0; + let cursorReset = false; + let usingNative = false; if (this.child && this.status.state !== "error") { + usingNative = true; const response = await this.request( { op: "query", @@ -389,15 +486,41 @@ export class SearchService { extensions: filters.extensions ?? [], caseSensitive: filters.caseSensitive ?? false, wholeWord: filters.wholeWord ?? false, + fuzzy: filters.fuzzy ?? false, matchPath: filters.matchPath ?? false, regex: filters.regex ?? false, - limit: 800 + sortBy: filters.sortBy ?? "relevance", + sortDirection: filters.sortDirection ?? "desc", + minSize: filters.minSize, + maxSize: filters.maxSize, + modifiedAfterMs: filters.modifiedAfter + ? Date.parse(filters.modifiedAfter) + : undefined, + modifiedBeforeMs: filters.modifiedBefore + ? Date.parse(filters.modifiedBefore) + : undefined, + cursor: options.cursor, + limit }, - filters.regex || filters.matchPath ? 20_000 : 8_000 + filters.regex || + filters.matchPath || + filters.sortBy === "size" || + filters.sortBy === "modified" || + filters.minSize != null || + filters.maxSize != null || + filters.modifiedAfter != null || + filters.modifiedBefore != null + ? 60_000 + : 20_000 ); results = (response.results ?? []) as SearchResult[]; + hasMore = response.hasMore === true; + nextCursor = response.nextCursor; + totalMatches = response.totalMatches; + generation = response.generation ?? 0; + cursorReset = response.cursorReset === true; } else { - results = await this.liveSearch(trimmed, filters.scope || "*", filters.kind); + results = await this.liveSearch(trimmed, filters.scope || "*", filters); } const enriched: SearchResult[] = []; @@ -424,6 +547,8 @@ export class SearchService { ); } + let pageItems = enriched; + if (!usingNative) { const modifiedThreshold = filters.modifiedAfter ? new Date(filters.modifiedAfter).getTime() : undefined; @@ -480,7 +605,20 @@ export class SearchService { } return comparison * direction || first.name.localeCompare(second.name, "zh-CN"); }); - return filtered.slice(0, 500); + const fallbackOffset = Number.parseInt(options.cursor?.split(":").at(-1) ?? "0", 10) || 0; + totalMatches = filtered.length; + pageItems = filtered.slice(fallbackOffset, fallbackOffset + limit); + hasMore = fallbackOffset + pageItems.length < filtered.length; + nextCursor = hasMore ? `fallback:${fallbackOffset + pageItems.length}` : undefined; + } + return { + items: pageItems, + hasMore, + nextCursor, + totalMatches, + generation, + cursorReset + }; } async directorySizes(inputPaths: string[]): Promise { @@ -596,10 +734,14 @@ export class SearchService { private async liveSearch( query: string, root: string, - kind: SearchFilters["kind"] + filters: SearchFilters ): Promise { const deadline = Date.now() + 2_500; - const normalizedQuery = query.toLocaleLowerCase(); + const normalizedQuery = filters.caseSensitive ? query : query.toLocaleLowerCase(); + const tokens = normalizedQuery.split(/\s+/).filter(Boolean); + const expression = filters.regex + ? new RegExp(query, filters.caseSensitive ? "u" : "iu") + : undefined; const queue = root === "*" ? await getLocalDriveRoots() : [root]; const results: SearchResult[] = []; while (queue.length > 0 && Date.now() < deadline && results.length < 80) { @@ -611,16 +753,39 @@ export class SearchService { const entryPath = path.join(current, entry.name); if (entry.isDirectory()) queue.push(entryPath); const kindMatches = - kind === "all" || - (kind === "folder" && entry.isDirectory()) || - (kind === "file" && entry.isFile()); - if (kindMatches && entry.name.toLocaleLowerCase().includes(normalizedQuery)) { + filters.kind === "all" || + (filters.kind === "folder" && entry.isDirectory()) || + (filters.kind === "file" && entry.isFile()); + const originalTarget = filters.matchPath ? entryPath : entry.name; + const target = filters.caseSensitive + ? originalTarget + : originalTarget.toLocaleLowerCase(); + let score: number | undefined; + if (expression?.test(originalTarget)) { + score = 72; + } else if (!expression && filters.fuzzy) { + const tokenScores = tokens.map((token) => fuzzySubsequenceScore(target, token)); + if (tokenScores.every((value) => value != null)) { + score = tokenScores.reduce((sum, value) => sum + (value ?? 0), 0) / + Math.max(1, tokenScores.length); + } + } else if ( + !expression && + filters.wholeWord && + tokens.every((token) => containsCompleteWord(target, token)) + ) { + score = 82; + } else if (!expression && !filters.fuzzy && !filters.wholeWord && + tokens.every((token) => target.includes(token))) { + score = target === normalizedQuery ? 100 : target.startsWith(normalizedQuery) ? 92 : 70; + } + if (kindMatches && score != null) { results.push({ path: entryPath, name: entry.name, isDirectory: entry.isDirectory(), size: 0, - score: entry.name.toLocaleLowerCase() === normalizedQuery ? 100 : 70, + score, source: "live-scan" }); if (results.length >= 80) break; @@ -730,7 +895,9 @@ export class SearchService { this.executableCatalogCache = undefined; this.onIndexChanged({ changedCount: Math.max(1, response.changedCount ?? 1), - observedAt: new Date().toISOString() + observedAt: new Date().toISOString(), + generation: response.generation, + contentScopes: response.contentScopes }); return; } diff --git a/electron/store.ts b/electron/store.ts index 01ffd86..4a5b96f 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -93,6 +93,9 @@ function sanitizeFilters(value: unknown): SearchFilters { const sortBy = searchSortFields.has(String(input.sortBy)) ? (input.sortBy as SearchFilters["sortBy"]) : "relevance"; + const regex = Boolean(input.regex); + const fuzzy = !regex && Boolean(input.fuzzy); + const wholeWord = !regex && !fuzzy && Boolean(input.wholeWord); return { kind, scope: safeString(input.scope, 32_768) || "*", @@ -106,9 +109,10 @@ function sanitizeFilters(value: unknown): SearchFilters { modifiedBefore: typeof input.modifiedBefore === "string" ? input.modifiedBefore.slice(0, 64) : undefined, caseSensitive: Boolean(input.caseSensitive), - wholeWord: Boolean(input.wholeWord), + wholeWord, + fuzzy, matchPath: Boolean(input.matchPath), - regex: Boolean(input.regex), + regex, sortBy, sortDirection: input.sortDirection === "desc" ? "desc" : "asc" }; @@ -289,6 +293,36 @@ function sanitizeUiLayout(value: unknown): UiLayoutState { if (typeof input.sidebarCollapsed === "boolean") { result.sidebarCollapsed = input.sidebarCollapsed; } + if ( + input.searchResultColumnWidths && + typeof input.searchResultColumnWidths === "object" + ) { + const widths = input.searchResultColumnWidths as Record; + const keys = ["name", "path", "type", "size", "modified", "action"] as const; + if ( + keys.every( + (key) => + typeof widths[key] === "number" && + Number.isFinite(widths[key]) && + Number(widths[key]) >= 4 && + Number(widths[key]) <= 60 + ) + ) { + const total = keys.reduce((sum, key) => sum + Number(widths[key]), 0); + if (total >= 80 && total <= 120) { + const normalized = (key: (typeof keys)[number]) => + (Number(widths[key]) / total) * 100; + result.searchResultColumnWidths = { + name: normalized("name"), + path: normalized("path"), + type: normalized("type"), + size: normalized("size"), + modified: normalized("modified"), + action: normalized("action") + }; + } + } + } if ( position && typeof position.x === "number" && @@ -458,6 +492,11 @@ function mergeSettings(input?: Partial): AppSettings { return { ...defaults.settings, ...input, + effectMode: ["aurora", "matrix", "calm", "ember", "ivory"].includes( + input?.effectMode ?? "" + ) + ? input!.effectMode! + : defaults.settings.effectMode, globalShortcut: typeof input?.globalShortcut === "string" ? input.globalShortcut.trim().slice(0, 128) diff --git a/electron/tray-icons.ts b/electron/tray-icons.ts index aaf974a..f367d72 100644 --- a/electron/tray-icons.ts +++ b/electron/tray-icons.ts @@ -21,6 +21,8 @@ export type TrayIconKind = | "theme-aurora" | "theme-matrix" | "theme-calm" + | "theme-ember" + | "theme-ivory" | "settings" | "exit"; @@ -45,6 +47,8 @@ export const trayIconKinds: TrayIconKind[] = [ "theme-aurora", "theme-matrix", "theme-calm", + "theme-ember", + "theme-ivory", "settings", "exit" ]; @@ -343,6 +347,30 @@ function drawIcon(buffer: Buffer, kind: TrayIconKind, color: Rgb): void { ); line(buffer, { x: 5, y: 7 }, { x: 11, y: 7 }, color); break; + case "theme-ember": + polyline( + buffer, + [ + { x: 8, y: 1 }, + { x: 14, y: 5 }, + { x: 14, y: 11 }, + { x: 8, y: 15 }, + { x: 2, y: 11 }, + { x: 2, y: 5 } + ], + color, + true + ); + polyline(buffer, [{ x: 8, y: 4 }, { x: 11, y: 9 }, { x: 8, y: 13 }, { x: 5, y: 9 }, { x: 8, y: 4 }], color); + break; + case "theme-ivory": + circle(buffer, 8, 8, 4, color); + [1, 3, 8, 13, 15].forEach((value) => { + if (value === 8) return; + line(buffer, { x: 8, y: value }, { x: 8, y: value < 8 ? value + 2 : value - 2 }, color); + line(buffer, { x: value, y: 8 }, { x: value < 8 ? value + 2 : value - 2, y: 8 }, color); + }); + break; case "settings": circle(buffer, 8, 8, 5, color); circle(buffer, 8, 8, 2, color); diff --git a/electron/types.ts b/electron/types.ts index bdbf9a1..48b064e 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -28,6 +28,12 @@ export interface ContentSearchResult { export interface ContentSearchOptions { regex?: boolean; caseSensitive?: boolean; + sortBy?: SearchSortField; + sortDirection?: SearchSortDirection; + minSize?: number; + maxSize?: number; + modifiedAfter?: string; + modifiedBefore?: string; } export interface SearchResult { @@ -40,6 +46,20 @@ export interface SearchResult { source: "native-index" | "live-scan"; } +export interface SearchPageOptions { + cursor?: string; + limit?: number; +} + +export interface SearchPage { + items: T[]; + hasMore: boolean; + nextCursor?: string; + totalMatches?: number; + generation: number; + cursorReset?: boolean; +} + export type SearchCategory = | "folder" | "document" @@ -66,6 +86,7 @@ export interface SearchFilters { modifiedBefore?: string; caseSensitive?: boolean; wholeWord?: boolean; + fuzzy?: boolean; matchPath?: boolean; regex?: boolean; sortBy?: SearchSortField; @@ -113,6 +134,7 @@ export interface SearchBookmarkFolder { export interface UiLayoutState { sidebarCollapsed?: boolean; + searchResultColumnWidths?: SearchResultColumnWidths; searchRenamePosition?: { x: number; y: number; @@ -282,7 +304,7 @@ export interface OwnershipMapResult { } export interface AppSettings { - effectMode: "aurora" | "matrix" | "calm"; + effectMode: "aurora" | "matrix" | "calm" | "ember" | "ivory"; launchAtLogin: boolean; launchMinimized: boolean; minimizeToTray: boolean; @@ -460,9 +482,26 @@ export interface NativeResponse { status?: IndexerStatus | ContentIndexerStatus; results?: SearchResult[] | ContentSearchResult[]; changedCount?: number; + hasMore?: boolean; + nextCursor?: string; + totalMatches?: number; + generation?: number; + cursorReset?: boolean; + contentScopes?: string[]; +} + +export interface SearchResultColumnWidths { + name: number; + path: number; + type: number; + size: number; + modified: number; + action: number; } export interface SearchIndexChangedEvent { changedCount: number; observedAt: string; + generation?: number; + contentScopes?: string[]; } diff --git a/electron/update.ts b/electron/update.ts index 62ad5b9..71b6052 100644 --- a/electron/update.ts +++ b/electron/update.ts @@ -23,6 +23,8 @@ const RELEASE_API = const CACHE_DURATION_MS = 15 * 60_000; const DOWNLOAD_RETRIES = 3; const MANIFEST_NAME = "update-manifest.json"; +const CHECK_REQUEST_TIMEOUT_MS = 20_000; +const CHECK_REQUEST_ATTEMPTS = 2; export type UpdatePhase = | "idle" @@ -54,6 +56,11 @@ export interface AppUpdateProgress { maxRetries: number; } +export interface AppUpdateReleaseSection { + title: string; + items: string[]; +} + export interface AppUpdateInfo { status: "checking" | "current" | "available" | "unavailable"; phase: UpdatePhase; @@ -65,6 +72,8 @@ export interface AppUpdateInfo { releaseName?: string; releaseUrl?: string; publishedAt?: string; + releaseSummary?: string; + releaseSections?: AppUpdateReleaseSection[]; assets: AppUpdateAsset[]; selectedAsset?: AppUpdateAsset; progress?: AppUpdateProgress; @@ -85,6 +94,7 @@ interface GitHubRelease { name?: string; html_url?: string; published_at?: string; + body?: string; draft?: boolean; prerelease?: boolean; assets?: Array<{ @@ -144,6 +154,69 @@ function compareVersions(first: string, second: string): number { return 0; } +function cleanReleaseText(value: string): string { + return value + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/[`*_~]/g, "") + .replace(/<[^>]+>/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function limitReleaseText(value: string, maxLength: number): string { + const text = cleanReleaseText(value); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + +export function parseReleaseNotes(markdown?: string): { + summary?: string; + sections: AppUpdateReleaseSection[]; +} { + if (!markdown?.trim()) return { sections: [] }; + + const sections: AppUpdateReleaseSection[] = []; + const introduction: string[] = []; + let current: AppUpdateReleaseSection | undefined; + + for (const rawLine of markdown.replace(/\r/g, "").split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const heading = /^(#{2,4})\s+(.+)$/.exec(line); + if (heading) { + const title = limitReleaseText(heading[2], 26); + if (!title || sections.length >= 6) { + current = undefined; + continue; + } + current = { title, items: [] }; + sections.push(current); + continue; + } + if (/^#\s+/.test(line)) continue; + + const bullet = /^(?:[-*+]\s+|\d+[.)]\s+)(.+)$/.exec(line); + const text = limitReleaseText(bullet?.[1] ?? line, 112); + if (!text) continue; + if (!current) { + if (introduction.length < 2) introduction.push(text); + continue; + } + if (current.items.length < 6) current.items.push(text); + } + + const populated = sections.filter((section) => section.items.length > 0); + if (!populated.length && introduction.length) { + populated.push({ title: "版本说明", items: introduction.slice(0, 4) }); + } + return { + summary: introduction.length + ? limitReleaseText(introduction.join(" "), 180) + : undefined, + sections: populated + }; +} + function updateDistribution(): UpdateDistribution { if (!app.isPackaged) return "development"; return process.env.PORTABLE_EXECUTABLE_FILE ? "portable" : "installed"; @@ -326,6 +399,53 @@ export class UpdateService { return net.fetch(url, init); } + private async fetchForUpdateCheck( + url: string, + currentVersion: string, + refreshRoute = false + ): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= CHECK_REQUEST_ATTEMPTS; attempt += 1) { + try { + const response = await this.networkFetch( + url, + { + headers: githubHeaders(currentVersion), + signal: AbortSignal.timeout(CHECK_REQUEST_TIMEOUT_MS) + }, + refreshRoute && attempt === 1 + ); + if ( + attempt < CHECK_REQUEST_ATTEMPTS && + (response.status === 408 || response.status === 429 || response.status >= 500) + ) { + await delay(450 * attempt); + continue; + } + return response; + } catch (error) { + lastError = error; + logger.warn("update.check_attempt_failed", { + attempt, + maxAttempts: CHECK_REQUEST_ATTEMPTS, + url: new URL(url).origin, + error: serializeError(error) + }); + if (attempt < CHECK_REQUEST_ATTEMPTS) await delay(450 * attempt); + } + } + const timedOut = + lastError instanceof Error && + (lastError.name === "TimeoutError" || /timeout|timed out/i.test(lastError.message)); + throw new Error( + timedOut + ? `连接 GitHub 超时(已重试 ${CHECK_REQUEST_ATTEMPTS} 次),请检查系统代理后重试` + : `连接 GitHub 失败(已重试 ${CHECK_REQUEST_ATTEMPTS} 次):${ + lastError instanceof Error ? lastError.message : String(lastError) + }` + ); + } + async check(force = false): Promise { const now = Date.now(); if (!force && this.cached && now - this.cached.at < CACHE_DURATION_MS) { @@ -342,10 +462,11 @@ export class UpdateService { errorCode: undefined }); try { - const response = await this.networkFetch(RELEASE_API, { - headers: githubHeaders(currentVersion), - signal: AbortSignal.timeout(10_000) - }, true); + const response = await this.fetchForUpdateCheck( + RELEASE_API, + currentVersion, + true + ); if (!response.ok) { throw new Error(`GitHub Release 返回 HTTP ${response.status}`); } @@ -374,12 +495,18 @@ export class UpdateService { ); let manifest: UpdateManifest | undefined; if (updateAvailable && manifestReleaseAsset) { - const manifestResponse = await this.networkFetch(manifestReleaseAsset.downloadUrl, { - headers: githubHeaders(currentVersion), - signal: AbortSignal.timeout(10_000) - }); - if (manifestResponse.ok) { - manifest = safeManifest(await manifestResponse.json()); + try { + const manifestResponse = await this.fetchForUpdateCheck( + manifestReleaseAsset.downloadUrl, + currentVersion + ); + if (manifestResponse.ok) { + manifest = safeManifest(await manifestResponse.json()); + } + } catch (error) { + logger.warn("update.manifest_fetch_failed", { + error: serializeError(error) + }); } } if (manifest && manifest.version !== latestVersion) manifest = undefined; @@ -397,6 +524,7 @@ export class UpdateService { distribution !== "development" && Boolean(manifest && manifestAsset && selectedAsset); this.manifest = manifest; + const releaseNotes = parseReleaseNotes(release.body); const result: AppUpdateInfo = { status: updateAvailable ? "available" : "current", phase: updateAvailable ? "available" : "current", @@ -408,6 +536,8 @@ export class UpdateService { releaseName: release.name || `CDriveShiftAI ${latestVersion}`, releaseUrl: release.html_url, publishedAt: release.published_at, + releaseSummary: releaseNotes.summary, + releaseSections: releaseNotes.sections, assets, selectedAsset, message: updateAvailable @@ -423,20 +553,25 @@ export class UpdateService { this.onState(this.getState()); return this.getState(); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const knownUpdate = Boolean( + this.state.latestVersion && + compareVersions(this.state.latestVersion, currentVersion) > 0 + ); return this.setState({ status: "unavailable", phase: "unavailable", currentVersion, - updateAvailable: false, - canAutoUpdate: false, - assets: [], - selectedAsset: undefined, + updateAvailable: knownUpdate, + canAutoUpdate: knownUpdate && this.state.canAutoUpdate, + assets: knownUpdate ? this.state.assets : [], + selectedAsset: knownUpdate ? this.state.selectedAsset : undefined, progress: undefined, - message: `暂时无法检查更新:${ - error instanceof Error ? error.message : String(error) - }`, + message: knownUpdate + ? `已知新版本 ${this.state.latestVersion};本次联网复查失败:${errorMessage}` + : `暂时无法检查更新:${errorMessage}`, checkedAt, - errorCode: "CHECK_FAILED" + errorCode: /超时/.test(errorMessage) ? "CHECK_TIMEOUT" : "CHECK_FAILED" }); } } diff --git a/index.html b/index.html index 3c629a1..1d032f4 100644 --- a/index.html +++ b/index.html @@ -7,17 +7,21 @@