From 533f170a5019f47b018780c251320f1d928ac9c8 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Mon, 3 Aug 2026 06:02:41 -0400 Subject: [PATCH 1/8] fix: make double-click feedback nonblocking --- src/components/PathOpenFeedback.tsx | 19 +-- src/styles.css | 201 +++++++--------------------- 2 files changed, 52 insertions(+), 168 deletions(-) diff --git a/src/components/PathOpenFeedback.tsx b/src/components/PathOpenFeedback.tsx index 9e5b939..e8d1d51 100644 --- a/src/components/PathOpenFeedback.tsx +++ b/src/components/PathOpenFeedback.tsx @@ -5,7 +5,6 @@ import { useState, type MouseEvent as ReactMouseEvent } from "react"; -import { Check, ExternalLink, X } from "lucide-react"; import { api } from "../lib/api"; type OpenPhase = "opening" | "opened" | "error"; @@ -96,17 +95,13 @@ export function PathOpenFeedback({ ? "已打开" : "打开失败"; return ( - - - {feedback.phase === "opening" ? ( - - ) : feedback.phase === "opened" ? ( - - ) : ( - - )} - - {label} + + {label} ); } diff --git a/src/styles.css b/src/styles.css index af821f3..4e333b4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2345,174 +2345,71 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba position: relative; isolation: isolate; overflow: hidden; - transform-origin: center; -} - -.path-openable::before { - position: absolute; - z-index: 4; - border-radius: inherit; - content: ""; - inset: 1px; - opacity: 0; - pointer-events: none; } .path-openable:active { - transform: scale(.997); - transition: transform 70ms ease; + background: color-mix(in srgb, var(--mint) 4%, transparent); + transition: background 70ms ease; } .path-open-feedback { position: absolute; z-index: 7; - top: 50%; - left: 50%; - display: inline-flex; - min-width: 106px; - height: 30px; - align-items: center; - justify-content: center; - gap: 7px; - border: 1px solid color-mix(in srgb, var(--mint) 42%, var(--border)); - border-radius: 999px; - color: var(--text); - background: - linear-gradient(110deg, color-mix(in srgb, var(--mint) 18%, transparent), transparent 52%), - color-mix(in srgb, var(--surface-solid) 96%, transparent); - padding: 0 12px; - box-shadow: - 0 12px 35px color-mix(in srgb, #000 28%, transparent), - 0 0 28px color-mix(in srgb, var(--mint) 18%, transparent); - font-size: 8px; + right: 0; + bottom: 0; + left: 0; + height: 2px; + overflow: hidden; + border: 0; + background: linear-gradient(90deg, var(--mint), var(--blue)); + box-shadow: 0 -1px 5px color-mix(in srgb, var(--mint) 22%, transparent); pointer-events: none; - transform: translate(-50%, -50%); - animation: path-feedback-enter 180ms cubic-bezier(.2, .85, .2, 1) both; - backdrop-filter: blur(18px) saturate(1.25); + transform-origin: left center; } -.path-open-feedback i { - position: relative; - display: grid; - width: 20px; - height: 20px; - place-items: center; - border-radius: 50%; - color: var(--bg); - background: var(--mint); +.path-open-feedback.opening { + animation: path-open-line 520ms ease-in-out infinite alternate; } -.path-open-feedback b { font-weight: 650; white-space: nowrap; } -.path-open-feedback.opening i { animation: path-icon-orbit 540ms linear infinite; } -.path-open-feedback.opened i { background: var(--good); } -.path-open-feedback.error { - border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); - box-shadow: 0 12px 35px color-mix(in srgb, var(--danger) 18%, transparent); -} -.path-open-feedback.error i { color: white; background: var(--danger); } - -.path-openable.path-open-opening::before { - opacity: 1; - background: - linear-gradient(100deg, transparent 15%, color-mix(in srgb, var(--mint) 15%, transparent) 42%, color-mix(in srgb, var(--blue) 13%, transparent) 58%, transparent 84%); - background-size: 240% 100%; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mint) 36%, transparent); - animation: path-aurora-sweep 560ms ease-out infinite; -} - -.path-openable.path-open-opened::before { - opacity: 1; - background: radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--good) 15%, transparent), transparent 62%); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--good) 25%, transparent); - animation: path-open-complete 820ms ease-out both; -} - -:root[data-effect="matrix"] .path-openable.path-open-opening { - animation: path-matrix-lock 220ms steps(2, end) both; +.path-open-feedback.opened { + background: var(--good); + animation: path-open-line-complete 480ms ease-out both; } -:root[data-effect="matrix"] .path-openable.path-open-opening::before { - border-radius: 2px; - opacity: 1; - background: - linear-gradient(90deg, transparent, color-mix(in srgb, var(--mint) 24%, transparent), transparent), - repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--blue) 11%, transparent) 6px); - background-size: 190% 100%, 100% 100%; - box-shadow: - inset 3px 0 var(--mint), - inset -3px 0 var(--blue), - inset 0 0 0 1px color-mix(in srgb, var(--mint) 58%, transparent); - animation: path-matrix-scan 380ms steps(8, end) infinite; +.path-open-feedback.error { + background: var(--danger); + animation: path-open-line-complete 620ms ease-out both; } :root[data-effect="matrix"] .path-open-feedback { - border-radius: 4px; - border-color: color-mix(in srgb, var(--mint) 66%, var(--border)); - background: - repeating-linear-gradient(0deg, transparent 0 3px, color-mix(in srgb, var(--blue) 8%, transparent) 4px), - color-mix(in srgb, var(--surface-solid) 97%, transparent); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--blue) 18%, transparent), - 0 0 30px color-mix(in srgb, var(--mint) 24%, transparent); - font-family: "Cascadia Code", monospace; - letter-spacing: .04em; -} - -:root[data-effect="matrix"] .path-open-feedback i { border-radius: 3px; } - -:root[data-effect="calm"] .path-openable.path-open-opening::before { - opacity: 1; - background: radial-gradient(circle at 50% 52%, rgba(255, 255, 255, .88), color-mix(in srgb, var(--mint) 8%, transparent) 42%, transparent 74%); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mint) 18%, transparent); - animation: path-calm-breathe 520ms ease-in-out infinite alternate; + height: 1px; + background: linear-gradient(90deg, var(--mint), #72a9ff, var(--mint)); + box-shadow: 0 -1px 7px color-mix(in srgb, var(--mint) 32%, transparent); } :root[data-effect="calm"] .path-open-feedback { - border-color: rgba(28, 132, 139, .2); - color: var(--text); - background: rgba(252, 255, 255, .91); - box-shadow: - 0 14px 35px rgba(50, 81, 94, .16), - 0 0 0 6px rgba(255, 255, 255, .26); - backdrop-filter: blur(22px); -} - -@keyframes path-feedback-enter { - from { opacity: 0; transform: translate(-50%, -44%) scale(.88); } - to { opacity: 1; transform: translate(-50%, -50%) scale(1); } -} - -@keyframes path-icon-orbit { - 0% { transform: rotate(0deg) scale(.92); } - 50% { transform: rotate(180deg) scale(1.08); } - 100% { transform: rotate(360deg) scale(.92); } -} - -@keyframes path-aurora-sweep { - from { background-position: 120% 0; } - to { background-position: -120% 0; } -} - -@keyframes path-open-complete { - 0% { opacity: .95; transform: scale(.98); } - 100% { opacity: 0; transform: scale(1.035); } + background: linear-gradient(90deg, rgba(38, 149, 151, .42), rgba(95, 143, 201, .38)); + box-shadow: none; } -@keyframes path-matrix-lock { - 0% { transform: translateX(0); } - 35% { transform: translateX(1px); } - 70% { transform: translateX(-1px); } - 100% { transform: translateX(0); } +.path-open-feedback-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; } -@keyframes path-matrix-scan { - from { background-position: 130% 0, 0 0; } - to { background-position: -130% 0, 0 8px; } +@keyframes path-open-line { + from { opacity: .36; transform: scaleX(.18); } + to { opacity: .92; transform: scaleX(.78); } } -@keyframes path-calm-breathe { - from { opacity: .5; transform: scale(.985); } - to { opacity: .92; transform: scale(1); } +@keyframes path-open-line-complete { + from { opacity: .85; transform: scaleX(.82); } + to { opacity: 0; transform: scaleX(1); } } /* Theme-aware hover hints (replaces unstyleable Windows/browser title popups) */ @@ -6331,24 +6228,16 @@ input:checked + .toggle::after { transform: translateX(13px); } } :root[data-effect="aurora"] .path-openable::before { - border-radius: 0; - background: - repeating-linear-gradient(90deg, transparent 0 13px, rgba(239, 196, 100, .82) 13px 26px); - animation-timing-function: steps(6, end); + content: none; } :root[data-effect="aurora"] .path-open-feedback { - border: 2px solid #63888a; - border-radius: 3px; - background: #25464b; - box-shadow: 0 5px 0 #0b252a; - backdrop-filter: none; - animation-timing-function: steps(3, end); -} - -:root[data-effect="aurora"] .path-open-feedback i { - border-radius: 1px; - box-shadow: none; + height: 2px; + border: 0; + border-radius: 0; + background: linear-gradient(90deg, #f0c45e, #79c9c6); + box-shadow: 0 -1px 0 rgba(10, 37, 42, .5); + animation-timing-function: steps(5, end); } :root[data-effect="aurora"] .path-properties-backdrop { From 97958d78308ab1ed9a504f3cab893c71745299f2 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Mon, 3 Aug 2026 06:26:25 -0400 Subject: [PATCH 2/8] fix: isolate shortcut conflicts from settings updates --- electron/main.ts | 27 +++++++++++---------------- src/views/SettingsView.tsx | 13 ++++++++++++- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 50c1cef..2373fba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1077,10 +1077,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 +1104,7 @@ function registerIpc(): void { createTray(); return settings; } catch (error) { - applyGlobalShortcuts(previous); + if (changesKeyboardShortcuts) applyGlobalShortcuts(previous); throw error; } }); @@ -1855,17 +1860,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/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index d02884e..1b79f7c 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -419,6 +419,11 @@ export function SettingsView({ if (value === persistedSettingsRef.current[field]) return; const request = ++shortcutRequests.current[target]; try { + if (value.trim()) { + const availability = await api.checkGlobalShortcut(value, target); + if (request !== shortcutRequests.current[target]) return; + if (!availability.available) return; + } const updated = await api.updateSettings({ [field]: value }); @@ -437,11 +442,17 @@ export function SettingsView({ ); } catch (error) { if (request !== shortcutRequests.current[target]) return; + const message = error instanceof Error ? error.message : String(error); + if (/快捷键.*(?:占用|冲突|格式无效)/u.test(message)) { + // Keep the attempted value visible. ShortcutRecorder continuously + // checks it and renders the conflict as red inline guidance. + return; + } setDraft((current) => ({ ...current, [field]: persistedSettingsRef.current[field] })); - notify("error", error instanceof Error ? error.message : String(error)); + notify("error", message); } }; From 828d7d40112316df662ec8666dee8005af0895da Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Mon, 3 Aug 2026 21:00:52 -0400 Subject: [PATCH 3/8] refine page titles and content search guidance --- src/views/MigrateView.tsx | 2 +- src/views/OverviewView.tsx | 2 +- src/views/SearchView.tsx | 6 +++--- src/views/SettingsView.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/views/MigrateView.tsx b/src/views/MigrateView.tsx index 5be5462..de0d114 100644 --- a/src/views/MigrateView.tsx +++ b/src/views/MigrateView.tsx @@ -116,7 +116,7 @@ export function MigrateView({
diff --git a/src/views/OverviewView.tsx b/src/views/OverviewView.tsx index f97a2b2..f3cd594 100644 --- a/src/views/OverviewView.tsx +++ b/src/views/OverviewView.tsx @@ -48,7 +48,7 @@ export function OverviewView({
onNavigate("search")}> diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 1a7863c..90ab3e4 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -1107,7 +1107,7 @@ export function SearchView({ ) : ( @@ -1705,8 +1705,8 @@ export function SearchView({
- 只读取文本、代码、配置和日志;自动跳过 node_modules、.git、dist、build - 等依赖或生成目录,单文件上限 8 MB。 + 内容搜索只索引文本、代码、配置和日志;依赖或生成目录以及超过 8 MB + 的文件不读取正文。名称搜索不受这些限制。
)} diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 1b79f7c..318198a 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -728,7 +728,7 @@ export function SettingsView({
From 42acb6997ea47dedf6601193773e595f54b297c4 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Mon, 3 Aug 2026 21:57:55 -0400 Subject: [PATCH 4/8] remove content index guidance from search --- src/styles.css | 1 - src/views/SearchView.tsx | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/styles.css b/src/styles.css index 4e333b4..9cb0ace 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1714,7 +1714,6 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba .content-state { display: flex; align-items: center; gap: 7px; min-width: 195px; color: var(--dim); font-size: 8px; } .content-state.ready { color: var(--good); } .content-state.indexing { color: var(--amber); } -.content-privacy-note { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--border); color: var(--dim); padding: 8px 16px; font-size: 8px; } .content-match-options { display: flex; flex: 0 0 auto; diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 90ab3e4..fef3ab3 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -1703,11 +1703,6 @@ export function SearchView({ )}
-
- - 内容搜索只索引文本、代码、配置和日志;依赖或生成目录以及超过 8 MB - 的文件不读取正文。名称搜索不受这些限制。 -
)} From ef540537fbff69b3dadeb5a65f5a3f6350918093 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Mon, 3 Aug 2026 23:18:58 -0400 Subject: [PATCH 5/8] feat: add paged live search results --- electron/main.ts | 68 ++- electron/preload.ts | 34 ++ electron/search.ts | 109 +++- electron/types.ts | 28 + native/indexer/src/main.rs | 745 ++++++++++++++++++++++-- scripts/smoke-indexer.mjs | 80 +++ scripts/smoke-packaged-search-state.mjs | 12 +- src/lib/api.ts | 30 + src/lib/virtual-list.ts | 110 ++++ src/styles.css | 4 + src/types.ts | 32 + src/views/SearchView.tsx | 420 ++++++++++--- tests/search-index-change.test.ts | 13 +- tests/virtual-list.test.ts | 21 + 14 files changed, 1572 insertions(+), 134 deletions(-) create mode 100644 src/lib/virtual-list.ts create mode 100644 tests/virtual-list.test.ts diff --git a/electron/main.ts b/electron/main.ts index 2373fba..69e8c51 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1524,7 +1524,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([ @@ -1583,6 +1583,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); } ); @@ -1651,6 +1664,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, "目录")) ); 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..92dfc96 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"; @@ -344,6 +347,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 +364,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 +394,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", @@ -391,11 +440,36 @@ export class SearchService { wholeWord: filters.wholeWord ?? 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); } @@ -424,6 +498,8 @@ export class SearchService { ); } + let pageItems = enriched; + if (!usingNative) { const modifiedThreshold = filters.modifiedAfter ? new Date(filters.modifiedAfter).getTime() : undefined; @@ -480,7 +556,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 { @@ -730,7 +819,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/types.ts b/electron/types.ts index bdbf9a1..997a0c5 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" @@ -460,9 +480,17 @@ 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 SearchIndexChangedEvent { changedCount: number; observedAt: string; + generation?: number; + contentScopes?: string[]; } diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index 56235ab..9e8a718 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -7,6 +7,7 @@ use regex::RegexBuilder; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::cmp::Ordering as CompareOrdering; use std::collections::{HashMap, HashSet, VecDeque}; use std::env; use std::fs::{self, File, OpenOptions}; @@ -18,7 +19,7 @@ use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const CACHE_MAGIC: &[u8; 8] = b"CSIDX02\0"; -const CONTENT_INDEX_VERSION: u32 = 2; +const CONTENT_INDEX_VERSION: u32 = 3; #[derive(Clone, Debug)] struct Entry { @@ -139,6 +140,9 @@ struct SharedState { backgrounded: Arc, stopping: Arc, delta_lock: Mutex<()>, + generation: AtomicU64, + content_generation: AtomicU64, + content_roots: Mutex>, } #[derive(Clone, Copy, Default)] @@ -172,6 +176,13 @@ struct Request { mouse_button: Option, mouse_hold_ms: Option, limit: Option, + cursor: Option, + sort_by: Option, + sort_direction: Option, + min_size: Option, + max_size: Option, + modified_after_ms: Option, + modified_before_ms: Option, } #[derive(Serialize)] @@ -185,6 +196,20 @@ struct SearchResult { source: &'static str, } +struct SearchPage { + results: Vec, + has_more: bool, + total_matches: usize, +} + +#[derive(Clone, Copy)] +struct SearchCandidate { + index: usize, + score: f64, + size: u64, + modified_ms: u64, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ContentSearchResult { @@ -192,6 +217,14 @@ struct ContentSearchResult { name: String, preview: String, score: f64, + size: u64, + modified_at_ms: u64, +} + +struct ContentSearchPage { + results: Vec, + has_more: bool, + total_matches: Option, } #[derive(Serialize)] @@ -602,8 +635,7 @@ fn trim_process_tree_working_sets(root_pid: u32) { let mut opened = 0usize; let mut trimmed = 0usize; for pid in tree { - let handle = - unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_QUOTA, 0, pid) }; + let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_QUOTA, 0, pid) }; if handle == 0 { continue; } @@ -954,8 +986,15 @@ impl SearchIndex { whole_word: bool, match_path: bool, regex_mode: bool, + sort_by: &str, + sort_direction: &str, + min_size: Option, + max_size: Option, + modified_after_ms: Option, + modified_before_ms: Option, + offset: usize, limit: usize, - ) -> Result, String> { + ) -> Result { let needle = if case_sensitive { text.to_string() } else { @@ -966,7 +1005,11 @@ impl SearchIndex { .filter(|part| !part.is_empty()) .collect(); if tokens.is_empty() { - return Ok(Vec::new()); + return Ok(SearchPage { + results: Vec::new(), + has_more: false, + total_matches: 0, + }); } let scope_values: Vec = scopes .iter() @@ -1000,7 +1043,16 @@ impl SearchIndex { 0 }; - let mut matches = Vec::with_capacity(limit.saturating_mul(2)); + let keep = offset.saturating_add(limit).saturating_add(1).max(1); + let prune_at = keep.saturating_mul(2).max(4_096); + let needs_metadata = sort_by == "size" + || sort_by == "modified" + || min_size.is_some() + || max_size.is_some() + || modified_after_ms.is_some() + || modified_before_ms.is_some(); + let mut matches = Vec::with_capacity(keep.min(8_192)); + let mut total_matches = 0usize; for index in 0..self.entries.len() { if !self.live.get(index).copied().unwrap_or(false) { continue; @@ -1099,26 +1151,113 @@ impl SearchIndex { score = 76.0; } score -= (path_value.len().min(400) as f64) * 0.01; - matches.push(SearchResult { - path: path_value.to_string(), - name: file_name(path_value).to_string(), - is_directory, - size: entry.size, + + let (size, modified_ms) = if needs_metadata { + let Ok(metadata) = fs::metadata(path_value) else { + continue; + }; + let modified_ms = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0); + (if is_directory { 0 } else { metadata.len() }, modified_ms) + } else { + (entry.size, 0) + }; + if min_size.is_some_and(|minimum| size < minimum) + || max_size.is_some_and(|maximum| size > maximum) + || modified_after_ms.is_some_and(|minimum| modified_ms < minimum) + || modified_before_ms.is_some_and(|maximum| modified_ms > maximum) + { + continue; + } + + total_matches = total_matches.saturating_add(1); + matches.push(SearchCandidate { + index, score, - source: "native-index", + size, + modified_ms, }); - if matches.len() >= limit.saturating_mul(40).clamp(2_000, 50_000) { - break; + if matches.len() >= prune_at { + matches.sort_by(|first, second| { + self.compare_search_candidates(first, second, sort_by, sort_direction) + }); + matches.truncate(keep); } } - matches.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.path.len().cmp(&b.path.len())) + matches.sort_by(|first, second| { + self.compare_search_candidates(first, second, sort_by, sort_direction) }); - matches.truncate(limit); - Ok(matches) + matches.truncate(keep); + + let page_end = offset.saturating_add(limit).min(matches.len()); + let results = if offset >= page_end { + Vec::new() + } else { + matches[offset..page_end] + .iter() + .filter_map(|candidate| { + let entry = self.entries.get(candidate.index).copied()?; + let path_value = self.path(candidate.index)?; + Some(SearchResult { + path: path_value.to_string(), + name: file_name(path_value).to_string(), + is_directory: entry.is_directory(), + size: candidate.size, + score: candidate.score, + source: "native-index", + }) + }) + .collect() + }; + Ok(SearchPage { + has_more: total_matches > offset.saturating_add(results.len()), + results, + total_matches, + }) + } + + fn compare_search_candidates( + &self, + first: &SearchCandidate, + second: &SearchCandidate, + sort_by: &str, + sort_direction: &str, + ) -> CompareOrdering { + let first_path = self.path(first.index).unwrap_or_default(); + let second_path = self.path(second.index).unwrap_or_default(); + let first_name = file_name(first_path); + let second_name = file_name(second_path); + let first_directory = self + .entries + .get(first.index) + .is_some_and(|entry| entry.is_directory()); + let second_directory = self + .entries + .get(second.index) + .is_some_and(|entry| entry.is_directory()); + let primary = match sort_by { + "name" => first_name.to_lowercase().cmp(&second_name.to_lowercase()), + "path" => normalized(first_path).cmp(&normalized(second_path)), + "size" => first.size.cmp(&second.size), + "modified" => first.modified_ms.cmp(&second.modified_ms), + "type" => file_category(first_directory, first_path) + .cmp(file_category(second_directory, second_path)) + .then_with(|| extension_name(first_path).cmp(&extension_name(second_path))), + _ => first + .score + .partial_cmp(&second.score) + .unwrap_or(CompareOrdering::Equal), + }; + let directed = if sort_direction == "desc" { + primary.reverse() + } else { + primary + }; + directed.then_with(|| normalized(first_path).cmp(&normalized(second_path))) } fn executable_catalog(&self, limit: usize) -> Vec { @@ -1393,12 +1532,37 @@ fn content_database_path(cache_dir: &Path, root: &str) -> PathBuf { )) } +fn remove_legacy_content_databases(cache_dir: &Path, root: &str, current: &Path) { + let prefix = format!("{:016x}-v", stable_hash(root)); + let Ok(entries) = fs::read_dir(cache_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path == current { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if name.starts_with(&prefix) && name.ends_with(".sqlite") { + let _ = fs::remove_file(path); + } + } +} + fn content_index_status( state: &SharedState, root_input: &str, ) -> Result { let root = fs::canonicalize(root_input).map_err(|error| error.to_string())?; let root_text = root.to_string_lossy().to_string(); + state + .content_roots + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(root_text.clone()); let database_path = content_database_path(&state.content_cache_dir, &root_text); if !database_path.exists() { return Ok(ContentIndexStatus { @@ -1629,6 +1793,11 @@ fn start_content_index( return Err("内容索引范围必须是目录".to_string()); } + state + .content_roots + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(root.to_string_lossy().to_string()); thread::spawn(move || { let root_text = root.to_string_lossy().to_string(); emit_content_status( @@ -1655,14 +1824,21 @@ fn start_content_index( PRAGMA synchronous=OFF; PRAGMA temp_store=MEMORY; CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); - CREATE VIRTUAL TABLE docs USING fts5(path UNINDEXED, content, tokenize='trigram');", + CREATE VIRTUAL TABLE docs USING fts5( + path UNINDEXED, + name UNINDEXED, + content, + size UNINDEXED, + modified_ms UNINDEXED, + tokenize='trigram' + );", ) .map_err(|error| format!("无法创建全文索引:{}", error))?; let queue = Arc::new(Mutex::new(VecDeque::from([root.clone()]))); let active = Arc::new(AtomicUsize::new(0)); let visited = Arc::new(AtomicUsize::new(0)); - let (sender, receiver) = mpsc::sync_channel::<(String, String)>(12); + let (sender, receiver) = mpsc::sync_channel::<(String, String, String, u64, u64)>(12); let workers = thread::available_parallelism() .map(|count| count.get().clamp(2, 8)) .unwrap_or(4); @@ -1720,10 +1896,23 @@ fn start_content_index( visited.fetch_add(1, Ordering::Relaxed); if is_content_candidate(&item_path) { if let Some(content) = read_text_document(&item_path) { + let metadata = item.metadata().ok(); + let size = + metadata.as_ref().map(|value| value.len()).unwrap_or(0); + let modified_ms = metadata + .and_then(|value| value.modified().ok()) + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| { + value.as_millis().min(u64::MAX as u128) as u64 + }) + .unwrap_or(0); if sender .send(( item_path.to_string_lossy().to_string(), + item.file_name().to_string_lossy().to_string(), content, + size, + modified_ms, )) .is_err() { @@ -1745,14 +1934,17 @@ fn start_content_index( let mut indexed = 0usize; { let mut statement = transaction - .prepare("INSERT INTO docs(path, content) VALUES (?1, ?2)") + .prepare( + "INSERT INTO docs(path, name, content, size, modified_ms) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) .map_err(|error| error.to_string())?; - for (document_path, content) in receiver { + for (document_path, name, content, size, modified_ms) in receiver { if wait_while_backgrounded(&state.backgrounded, &state.stopping) { return Err("indexer is stopping".to_string()); } statement - .execute(params![document_path, content]) + .execute(params![document_path, name, content, size, modified_ms]) .map_err(|error| error.to_string())?; indexed += 1; if indexed % 250 == 0 { @@ -1800,9 +1992,13 @@ fn start_content_index( if backup_path.exists() { let _ = fs::remove_file(backup_path); } + remove_legacy_content_databases(&state.content_cache_dir, &root_text, &database_path); Ok(indexed) })(); + if result.is_ok() { + state.content_generation.fetch_add(1, Ordering::AcqRel); + } match result { Ok(indexed) => emit_content_status( &output, @@ -1825,10 +2021,22 @@ fn query_content( query: &str, regex_mode: bool, case_sensitive: bool, + sort_by: &str, + sort_direction: &str, + min_size: Option, + max_size: Option, + modified_after_ms: Option, + modified_before_ms: Option, + offset: usize, limit: usize, -) -> Result, String> { +) -> Result { let root = fs::canonicalize(root_input).map_err(|error| error.to_string())?; let root_text = root.to_string_lossy().to_string(); + state + .content_roots + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(root_text.clone()); let database_path = content_database_path(&state.content_cache_dir, &root_text); if !database_path.exists() { return Err("该目录尚未建立内容索引,请先点击“建立内容索引”".to_string()); @@ -1836,6 +2044,8 @@ fn query_content( let connection = Connection::open_with_flags(database_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|error| error.to_string())?; + let keep = offset.saturating_add(limit).saturating_add(1).max(1); + let prune_at = keep.saturating_mul(2).max(4_096); if regex_mode { let pattern = query.trim(); @@ -1857,53 +2067,324 @@ fn query_content( } let mut statement = connection - .prepare("SELECT path, content FROM docs") + .prepare( + "SELECT path, name, content, CAST(size AS INTEGER), + CAST(modified_ms AS INTEGER) FROM docs", + ) .map_err(|error| error.to_string())?; let mut rows = statement.query([]).map_err(|error| error.to_string())?; let mut results = Vec::new(); + let mut total_matches = 0usize; while let Some(row) = rows.next().map_err(|error| error.to_string())? { let path_value: String = row.get(0).map_err(|error| error.to_string())?; - let content: String = row.get(1).map_err(|error| error.to_string())?; + let name: String = row.get(1).map_err(|error| error.to_string())?; + let content: String = row.get(2).map_err(|error| error.to_string())?; + let size = row.get::<_, i64>(3).unwrap_or(0).max(0) as u64; + let modified_at_ms = row.get::<_, i64>(4).unwrap_or(0).max(0) as u64; let Some(found) = expression.find(&content) else { continue; }; + if min_size.is_some_and(|minimum| size < minimum) + || max_size.is_some_and(|maximum| size > maximum) + || modified_after_ms.is_some_and(|minimum| modified_at_ms < minimum) + || modified_before_ms.is_some_and(|maximum| modified_at_ms > maximum) + { + continue; + } + total_matches = total_matches.saturating_add(1); results.push(ContentSearchResult { - name: file_name(&path_value).to_string(), + name, path: path_value, preview: regex_content_preview(&content, found), score: 1.0 / (1.0 + found.start() as f64), + size, + modified_at_ms, }); - if results.len() >= limit { - break; - } + prune_content_results(&mut results, keep, prune_at, sort_by, sort_direction); } - return Ok(results); + return Ok(paginate_content_results( + results, + sort_by, + sort_direction, + offset, + limit, + total_matches, + )); } let expression = format!("\"{}\"", query.trim().replace('"', "\"\"")); let mut statement = connection .prepare( - "SELECT path, snippet(docs, 1, '〔', '〕', ' … ', 22), bm25(docs) - FROM docs WHERE docs MATCH ?1 ORDER BY bm25(docs) LIMIT ?2", + "SELECT path, name, snippet(docs, 2, '[', ']', ' … ', 22), bm25(docs), + CAST(size AS INTEGER), CAST(modified_ms AS INTEGER) + FROM docs WHERE docs MATCH ?1", ) .map_err(|error| error.to_string())?; let rows = statement - .query_map(params![expression, limit as i64], |row| { + .query_map(params![expression], |row| { let path_value: String = row.get(0)?; - let rank: f64 = row.get(2)?; + let rank: f64 = row.get(3)?; Ok(ContentSearchResult { - name: file_name(&path_value).to_string(), + name: row.get(1)?, path: path_value, - preview: row.get(1)?, + preview: row.get(2)?, score: -rank, + size: row.get::<_, i64>(4)?.max(0) as u64, + modified_at_ms: row.get::<_, i64>(5)?.max(0) as u64, }) }) .map_err(|error| error.to_string())?; let mut results = Vec::new(); + let mut total_matches = 0usize; for row in rows { - results.push(row.map_err(|error| error.to_string())?); + let result = row.map_err(|error| error.to_string())?; + if min_size.is_some_and(|minimum| result.size < minimum) + || max_size.is_some_and(|maximum| result.size > maximum) + || modified_after_ms.is_some_and(|minimum| result.modified_at_ms < minimum) + || modified_before_ms.is_some_and(|maximum| result.modified_at_ms > maximum) + { + continue; + } + total_matches = total_matches.saturating_add(1); + results.push(result); + prune_content_results(&mut results, keep, prune_at, sort_by, sort_direction); + } + Ok(paginate_content_results( + results, + sort_by, + sort_direction, + offset, + limit, + total_matches, + )) +} + +fn prune_content_results( + results: &mut Vec, + keep: usize, + prune_at: usize, + sort_by: &str, + sort_direction: &str, +) { + if results.len() < prune_at { + return; + } + sort_content_results(results, sort_by, sort_direction); + results.truncate(keep); +} + +fn sort_content_results(results: &mut [ContentSearchResult], sort_by: &str, sort_direction: &str) { + results.sort_by(|first, second| { + let primary = match sort_by { + "name" => first.name.to_lowercase().cmp(&second.name.to_lowercase()), + "path" => normalized(&first.path).cmp(&normalized(&second.path)), + "size" => first.size.cmp(&second.size), + "modified" => first.modified_at_ms.cmp(&second.modified_at_ms), + "type" => extension_name(&first.name).cmp(&extension_name(&second.name)), + _ => first + .score + .partial_cmp(&second.score) + .unwrap_or(CompareOrdering::Equal), + }; + let directed = if sort_direction == "desc" { + primary.reverse() + } else { + primary + }; + directed.then_with(|| normalized(&first.path).cmp(&normalized(&second.path))) + }); +} + +fn paginate_content_results( + mut results: Vec, + sort_by: &str, + sort_direction: &str, + offset: usize, + limit: usize, + total_matches: usize, +) -> ContentSearchPage { + sort_content_results(&mut results, sort_by, sort_direction); + results.truncate(offset.saturating_add(limit).saturating_add(1)); + let page_end = offset.saturating_add(limit).min(total_matches); + let page_results = if offset >= page_end { + Vec::new() + } else { + results.drain(offset..page_end).collect() + }; + ContentSearchPage { + has_more: total_matches > offset.saturating_add(page_results.len()), + results: page_results, + total_matches: Some(total_matches), + } +} + +fn load_content_roots(cache_dir: &Path) -> HashSet { + let mut roots = HashSet::new(); + let Ok(entries) = fs::read_dir(cache_dir) else { + return roots; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("sqlite") { + continue; + } + let Ok(connection) = + Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + continue; + }; + if let Ok(root) = + connection.query_row("SELECT value FROM meta WHERE key = 'root'", [], |row| { + row.get::<_, String>(0) + }) + { + roots.insert(root); + } + } + roots +} + +fn path_is_within(root: &str, candidate: &str) -> bool { + let normalize_content_path = |value: &str| { + let normalized_value = normalized(value); + normalized_value + .strip_prefix(r"\\?\") + .unwrap_or(&normalized_value) + .to_string() + }; + let root = normalize_content_path(root); + let candidate = normalize_content_path(candidate); + candidate == root + || candidate + .strip_prefix(&root) + .is_some_and(|suffix| suffix.starts_with('\\')) +} + +fn content_path_is_excluded(root: &str, candidate: &Path) -> bool { + let root_path = Path::new(root); + let relative = candidate.strip_prefix(root_path).unwrap_or(candidate); + relative + .parent() + .into_iter() + .flat_map(Path::components) + .any(|component| is_excluded_content_directory(Path::new(component.as_os_str()))) +} + +fn update_content_indexes(state: &SharedState, deltas: &[IndexDelta]) -> Vec { + let roots: Vec = state + .content_roots + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .cloned() + .collect(); + let mut changed_roots = Vec::new(); + for root in roots { + let relevant: Vec<&IndexDelta> = deltas + .iter() + .filter(|delta| match delta { + IndexDelta::Upsert { path, .. } | IndexDelta::Remove { path, .. } => { + path_is_within(&root, path) + } + }) + .collect(); + if relevant.is_empty() { + continue; + } + let database_path = content_database_path(&state.content_cache_dir, &root); + if !database_path.exists() { + continue; + } + let Ok(mut connection) = Connection::open(database_path) else { + continue; + }; + let Ok(transaction) = connection.transaction() else { + continue; + }; + let mut changed = false; + for delta in relevant { + match delta { + IndexDelta::Remove { path, tree } => { + let result = if *tree { + let prefix = format!("{}\\", path); + transaction.execute( + "DELETE FROM docs WHERE path = ?1 OR substr(path, 1, ?3) = ?2", + params![path, prefix, prefix.chars().count() as i64], + ) + } else { + transaction.execute("DELETE FROM docs WHERE path = ?1", params![path]) + }; + changed |= result.is_ok(); + } + IndexDelta::Upsert { + path, is_directory, .. + } => { + let _ = transaction.execute("DELETE FROM docs WHERE path = ?1", params![path]); + if !*is_directory { + let path_value = Path::new(path); + if is_content_candidate(path_value) + && !content_path_is_excluded(&root, path_value) + { + if let Some(content) = read_text_document(path_value) { + let metadata = fs::metadata(path_value).ok(); + let size = metadata.as_ref().map(|value| value.len()).unwrap_or(0); + let modified_ms = metadata + .and_then(|value| value.modified().ok()) + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0); + let _ = transaction.execute( + "INSERT INTO docs(path, name, content, size, modified_ms) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![path, file_name(path), content, size, modified_ms], + ); + } + } + } + changed = true; + } + } + } + if !changed { + continue; + } + let documents = transaction + .query_row("SELECT count(*) FROM docs", [], |row| row.get::<_, i64>(0)) + .unwrap_or(0) + .max(0); + let _ = transaction.execute( + "INSERT INTO meta(key, value) VALUES ('updatedAt', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![now_iso_like()], + ); + let _ = transaction.execute( + "INSERT INTO meta(key, value) VALUES ('documents', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![documents.to_string()], + ); + if transaction.commit().is_ok() { + changed_roots.push(root); + } + } + if !changed_roots.is_empty() { + state.content_generation.fetch_add(1, Ordering::AcqRel); + } + changed_roots +} + +fn cursor_offset(cursor: Option<&str>, generation: u64) -> (usize, bool) { + let Some(cursor) = cursor else { + return (0, false); + }; + let Some((cursor_generation, cursor_offset)) = cursor.split_once(':') else { + return (0, true); + }; + let parsed_generation = cursor_generation.parse::().ok(); + let parsed_offset = cursor_offset.parse::().ok(); + match (parsed_generation, parsed_offset) { + (Some(value), Some(offset)) if value == generation => (offset, false), + _ => (0, true), } - Ok(results) } fn regex_content_preview(content: &str, found: regex::Match<'_>) -> String { @@ -2571,7 +3052,9 @@ fn start_watchers(state: Arc, output: Output) { drop(index); thread::yield_now(); } + let generation = state.generation.fetch_add(1, Ordering::AcqRel) + 1; drop(delta_guard); + let content_scopes = update_content_indexes(&state, &deltas); if let Some(error) = delta_error { let mut status = state .status @@ -2584,7 +3067,9 @@ fn start_watchers(state: Arc, output: Output) { if !state.backgrounded.load(Ordering::Relaxed) { output.send(&json!({ "event": "indexChanged", - "changedCount": deltas.len() + "changedCount": deltas.len(), + "generation": generation, + "contentScopes": content_scopes })); } } @@ -2641,6 +3126,7 @@ fn start_initial_cache_load(state: Arc, output: Output) { .index .write() .unwrap_or_else(|error| error.into_inner()) = index; + state.generation.fetch_add(1, Ordering::AcqRel); trim_process_working_set(); update_status( &state, @@ -2855,6 +3341,7 @@ fn start_scan(state: Arc, output: Output) { .index .write() .unwrap_or_else(|error| error.into_inner()) = index; + state.generation.fetch_add(1, Ordering::AcqRel); drop(delta_guard); trim_process_working_set(); let cache_message = match (cache_error, delta_result.err()) { @@ -2957,6 +3444,7 @@ fn run_server() -> io::Result<()> { .content_cache_dir .unwrap_or_else(|| "content-indexes".to_string()), ); + let content_roots = load_content_roots(&content_cache_dir); let initial_status = Status { mode: "loading".to_string(), state: "idle".to_string(), @@ -2980,6 +3468,9 @@ fn run_server() -> io::Result<()> { backgrounded: Arc::new(AtomicBool::new(request.background.unwrap_or(false))), stopping: Arc::new(AtomicBool::new(false)), delta_lock: Mutex::new(()), + generation: AtomicU64::new(1), + content_generation: AtomicU64::new(1), + content_roots: Mutex::new(content_roots), }); output.status(&initial_status); @@ -3050,7 +3541,11 @@ fn run_server() -> io::Result<()> { .unwrap_or_else(|| vec![request.scope.unwrap_or_else(|| "*".to_string())]); let categories = request.categories.unwrap_or_default(); let extensions = request.extensions.unwrap_or_default(); - let limit = request.limit.unwrap_or(120).clamp(1, 2_000); + let limit = request.limit.unwrap_or(240).clamp(1, 10_000); + let generation = shared.generation.load(Ordering::Acquire); + let (offset, cursor_reset) = cursor_offset(request.cursor.as_deref(), generation); + let sort_by = request.sort_by.unwrap_or_else(|| "relevance".to_string()); + let sort_direction = request.sort_direction.unwrap_or_else(|| "desc".to_string()); let results = shared .index .read() @@ -3065,11 +3560,32 @@ fn run_server() -> io::Result<()> { request.whole_word.unwrap_or(false), request.match_path.unwrap_or(false), request.regex.unwrap_or(false), + &sort_by, + &sort_direction, + request.min_size, + request.max_size, + request.modified_after_ms, + request.modified_before_ms, + offset, limit, ); match results { - Ok(results) => { - output.send(&json!({ "id": request.id, "ok": true, "results": results })) + Ok(page) => { + let next_cursor = if page.has_more { + Some(format!("{}:{}", generation, offset + page.results.len())) + } else { + None + }; + output.send(&json!({ + "id": request.id, + "ok": true, + "results": page.results, + "hasMore": page.has_more, + "nextCursor": next_cursor, + "totalMatches": page.total_matches, + "generation": generation, + "cursorReset": cursor_reset + })) } Err(error) => { output.send(&json!({ "id": request.id, "ok": false, "error": error })) @@ -3132,16 +3648,53 @@ fn run_server() -> io::Result<()> { }; let query = request.query.unwrap_or_default(); let root = request.scope.unwrap_or_default(); - let limit = request.limit.unwrap_or(120).clamp(1, 500); + let limit = request.limit.unwrap_or(80).clamp(1, 2_000); let regex_mode = request.regex.unwrap_or(false); let case_sensitive = request.case_sensitive.unwrap_or(false); + let sort_by = request.sort_by.unwrap_or_else(|| "relevance".to_string()); + let sort_direction = request.sort_direction.unwrap_or_else(|| "desc".to_string()); + let min_size = request.min_size; + let max_size = request.max_size; + let modified_after_ms = request.modified_after_ms; + let modified_before_ms = request.modified_before_ms; + let generation = shared.content_generation.load(Ordering::Acquire); + let (offset, cursor_reset) = cursor_offset(request.cursor.as_deref(), generation); let shared = Arc::clone(shared); let output = output.clone(); let request_id = request.id; thread::spawn(move || { - match query_content(&shared, &root, &query, regex_mode, case_sensitive, limit) { - Ok(results) => output - .send(&json!({ "id": request_id, "ok": true, "results": results })), + match query_content( + &shared, + &root, + &query, + regex_mode, + case_sensitive, + &sort_by, + &sort_direction, + min_size, + max_size, + modified_after_ms, + modified_before_ms, + offset, + limit, + ) { + Ok(page) => { + let next_cursor = if page.has_more { + Some(format!("{}:{}", generation, offset + page.results.len())) + } else { + None + }; + output.send(&json!({ + "id": request_id, + "ok": true, + "results": page.results, + "hasMore": page.has_more, + "nextCursor": next_cursor, + "totalMatches": page.total_matches, + "generation": generation, + "cursorReset": cursor_reset + })) + } Err(error) => { output.send(&json!({ "id": request_id, "ok": false, "error": error })) } @@ -3220,6 +3773,82 @@ mod tests { ); } + #[test] + fn paged_query_keeps_global_sort_and_reports_more_results() { + let backgrounded = AtomicBool::new(false); + let stopping = AtomicBool::new(false); + let index = SearchIndex::from_entries_controlled( + vec![ + Entry { + path: r"C:\Data\match-c.txt".to_string(), + is_directory: false, + size: 3, + }, + Entry { + path: r"C:\Data\match-a.txt".to_string(), + is_directory: false, + size: 1, + }, + Entry { + path: r"C:\Data\match-b.txt".to_string(), + is_directory: false, + size: 2, + }, + ], + &backgrounded, + &stopping, + ) + .expect("test index"); + + let first = index + .query( + "match-", + "file", + &[], + &[], + &[], + false, + false, + false, + false, + "name", + "asc", + None, + None, + None, + None, + 0, + 1, + ) + .expect("first page"); + let second = index + .query( + "match-", + "file", + &[], + &[], + &[], + false, + false, + false, + false, + "name", + "asc", + None, + None, + None, + None, + 1, + 1, + ) + .expect("second page"); + + assert_eq!(first.total_matches, 3); + assert!(first.has_more); + assert_eq!(first.results[0].name, "match-a.txt"); + assert_eq!(second.results[0].name, "match-b.txt"); + } + #[test] fn directory_tombstone_hides_descendants_without_eager_full_scan() { let backgrounded = AtomicBool::new(false); @@ -3261,9 +3890,17 @@ mod tests { false, false, false, + "relevance", + "desc", + None, + None, + None, + None, + 0, 20, ) .expect("query") + .results .len(), 0 ); @@ -3279,9 +3916,17 @@ mod tests { false, false, false, + "relevance", + "desc", + None, + None, + None, + None, + 0, 20, ) .expect("query") + .results .len(), 1 ); diff --git a/scripts/smoke-indexer.mjs b/scripts/smoke-indexer.mjs index 50c785a..c0bf6cc 100644 --- a/scripts/smoke-indexer.mjs +++ b/scripts/smoke-indexer.mjs @@ -30,6 +30,9 @@ await writeFile( ); await writeFile(path.join(nested, "needle-photo.png"), "not a real image", "utf8"); await writeFile(path.join(nested, "needle-script.ts"), "export const needle = true;", "utf8"); +await writeFile(path.join(nested, "needle-page-a.txt"), "page a", "utf8"); +await writeFile(path.join(nested, "needle-page-b.txt"), "page b", "utf8"); +await writeFile(path.join(nested, "needle-page-c.txt"), "page c", "utf8"); await writeFile(path.join(nested, "portable-tool.exe"), "test executable fixture", "utf8"); await writeFile(path.join(scoped, "shared-inside.log"), "inside", "utf8"); await writeFile(path.join(similarlyNamedScope, "shared-outside.log"), "outside", "utf8"); @@ -52,6 +55,9 @@ lines.on("line", (line) => { if (message.ok === false) reject(new Error(message.error)); else resolve(message); } else if (message.event) { + if (process.env.CSHIFT_SMOKE_TRACE === "1") { + console.error("[indexer-event]", JSON.stringify(message)); + } events.push(message); for (const waiter of [...eventWaiters]) waiter(message); } @@ -88,6 +94,10 @@ function request(payload, timeoutMs = 10_000) { }); } +function normalizeWindowsPath(value) { + return value.replace(/^\\\\\?\\/, "").replaceAll("/", "\\").toLocaleLowerCase(); +} + try { await request({ op: "init", @@ -108,6 +118,36 @@ try { if (!nameResult.results?.some((result) => result.name === "Needle-file.txt")) { throw new Error("Name index smoke test did not find the fixture file"); } + const firstPage = await request({ + op: "query", + query: "needle-page", + kind: "file", + scope: fixture, + sortBy: "name", + sortDirection: "asc", + limit: 1 + }); + const secondPage = await request({ + op: "query", + query: "needle-page", + kind: "file", + scope: fixture, + sortBy: "name", + sortDirection: "asc", + cursor: firstPage.nextCursor, + limit: 1 + }); + if ( + firstPage.hasMore !== true || + firstPage.totalMatches !== 3 || + !firstPage.nextCursor || + firstPage.results?.length !== 1 || + secondPage.results?.length !== 1 || + firstPage.results[0]?.path === secondPage.results[0]?.path || + firstPage.results[0]?.name.localeCompare(secondPage.results[0]?.name) >= 0 + ) { + throw new Error("Paged name search did not preserve the complete sorted result set"); + } const imageFilterResult = await request({ op: "query", query: "needle", @@ -229,6 +269,43 @@ try { if (!contentResult.results?.some((result) => result.name === "Needle-file.txt")) { throw new Error("Content index smoke test did not find the fixture content"); } + const liveContentPath = path.join(nested, "live-content-change.txt"); + events.length = 0; + await writeFile(liveContentPath, "dynamic-content-keyword-added", "utf8"); + await waitForEvent( + (message) => + message.event === "indexChanged" && + message.contentScopes?.some( + (scope) => normalizeWindowsPath(scope) === normalizeWindowsPath(fixture) + ) + ); + const liveContentCreated = await request({ + op: "contentQuery", + query: "dynamic-content-keyword-added", + scope: fixture, + limit: 10 + }); + if (!liveContentCreated.results?.some((result) => result.name === "live-content-change.txt")) { + throw new Error("Live content index did not add the changed text file"); + } + events.length = 0; + await rm(liveContentPath, { force: true }); + await waitForEvent( + (message) => + message.event === "indexChanged" && + message.contentScopes?.some( + (scope) => normalizeWindowsPath(scope) === normalizeWindowsPath(fixture) + ) + ); + const liveContentDeleted = await request({ + op: "contentQuery", + query: "dynamic-content-keyword-added", + scope: fixture, + limit: 10 + }); + if (liveContentDeleted.results?.length !== 0) { + throw new Error("Live content index did not remove the deleted text file"); + } const contentRegexResult = await request({ op: "contentQuery", query: String.raw`cdriveshiftai\s+stable-content-(?:needle|missing)`, @@ -264,6 +341,7 @@ try { JSON.stringify( { nameMatches: nameResult.results.length, + pagedNameTotal: firstPage.totalMatches, combinedFilterMatches: imageFilterResult.results.length, multiCategoryMatches: multiCategoryResult.results.length, scopedMatches: scopedResult.results.length, @@ -272,6 +350,8 @@ try { liveCreateMatches: liveCreated.results.length, liveDeleteMatches: liveDeleted.results.length, contentMatches: contentResult.results.length, + liveContentCreateMatches: liveContentCreated.results.length, + liveContentDeleteMatches: liveContentDeleted.results.length, contentRegexMatches: contentRegexResult.results.length, invalidRegexRejected, persistedContentDocuments: contentStatus.status.filesIndexed, diff --git a/scripts/smoke-packaged-search-state.mjs b/scripts/smoke-packaged-search-state.mjs index 162d96c..98c8fad 100644 --- a/scripts/smoke-packaged-search-state.mjs +++ b/scripts/smoke-packaged-search-state.mjs @@ -20,6 +20,8 @@ const dataDirectory = path.join( "packaged-search-state" ); const query = "CDriveShiftAI-state-smoke"; +const smokeMainShortcut = "CommandOrControl+Alt+Shift+F9"; +const smokeQuickSearchShortcut = "CommandOrControl+Alt+Shift+F10"; const propertiesTarget = path.join(workspace, "package.json"); const renameSource = path.join(dataDirectory, "properties-rename-before.txt"); const renameTarget = path.join(dataDirectory, "properties-rename-after.txt"); @@ -71,6 +73,10 @@ await symlink(rollbackDestination, rollbackSource, "junction"); await writeFile( path.join(dataDirectory, "cdriveshiftai-state.json"), JSON.stringify({ + settings: { + globalShortcut: smokeMainShortcut, + quickSearchShortcut: smokeQuickSearchShortcut + }, migrations: [ { id: "migration-delete-smoke", @@ -239,15 +245,15 @@ async function runApplication(debugPort, verifyRestored) { if (!verifyRestored) { const shortcutChecks = await evaluate(`Promise.all([ window.cDriveShiftAI.checkGlobalShortcut( - "CommandOrControl+Alt+Space", + ${JSON.stringify(smokeMainShortcut)}, "main" ), window.cDriveShiftAI.checkGlobalShortcut( - "CommandOrControl+Alt+F", + ${JSON.stringify(smokeQuickSearchShortcut)}, "quick-search" ), window.cDriveShiftAI.checkGlobalShortcut( - "CommandOrControl+Alt+Space", + ${JSON.stringify(smokeMainShortcut)}, "quick-search" ) ])`); diff --git a/src/lib/api.ts b/src/lib/api.ts index 3aadf20..02e6b25 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -508,6 +508,27 @@ const browserFallback: CDriveShiftApi = { } ] as SearchResult[]; }, + async searchPage(query) { + const items = query.trim() + ? ([ + { + path: `C:\\Users\\You\\AppData\\Local\\${query}`, + name: query, + isDirectory: true, + size: 4.8 * 1024 ** 3, + score: 96, + modifiedAt: new Date().toISOString(), + source: "native-index" + } + ] as SearchResult[]) + : []; + return { + items, + hasMore: false, + totalMatches: items.length, + generation: 1 + }; + }, async searchContent(query, scope, options) { if (!query.trim() || !scope) return []; const preview = options?.regex @@ -522,6 +543,15 @@ const browserFallback: CDriveShiftApi = { } ] as ContentSearchResult[]; }, + async searchContentPage(query, scope, options) { + const items = await browserFallback.searchContent(query, scope, options); + return { + items, + hasMore: false, + totalMatches: items.length, + generation: 1 + }; + }, indexContent: () => Promise.resolve(), contentIndexStatus: async (scope) => ({ state: "ready", diff --git a/src/lib/virtual-list.ts b/src/lib/virtual-list.ts new file mode 100644 index 0000000..ae738e6 --- /dev/null +++ b/src/lib/virtual-list.ts @@ -0,0 +1,110 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type UIEvent +} from "react"; + +interface VirtualListState { + scrollTop: number; + viewportHeight: number; +} + +export interface VirtualListRange { + start: number; + end: number; + paddingTop: number; + paddingBottom: number; +} + +export function calculateVirtualListRange( + itemCount: number, + rowHeight: number, + scrollTop: number, + viewportHeight: number, + overscan = 8 +): VirtualListRange { + if (itemCount === 0) { + return { start: 0, end: 0, paddingTop: 0, paddingBottom: 0 }; + } + const visibleHeight = Math.max(rowHeight, viewportHeight); + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + const end = Math.min( + itemCount, + Math.ceil((scrollTop + visibleHeight) / rowHeight) + overscan + ); + return { + start, + end, + paddingTop: start * rowHeight, + paddingBottom: Math.max(0, (itemCount - end) * rowHeight) + }; +} + +export function useVirtualList( + itemCount: number, + rowHeight: number, + overscan = 8 +) { + const containerRef = useRef(null); + const [state, setState] = useState({ + scrollTop: 0, + viewportHeight: 0 + }); + + const measure = useCallback(() => { + const container = containerRef.current; + if (!container) return; + setState((current) => { + const next = { + scrollTop: container.scrollTop, + viewportHeight: container.clientHeight + }; + return current.scrollTop === next.scrollTop && + current.viewportHeight === next.viewportHeight + ? current + : next; + }); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => observer.disconnect(); + }, [itemCount, measure, rowHeight]); + + const onScroll = useCallback( + (_event: UIEvent) => measure(), + [measure] + ); + + const range = useMemo( + () => + calculateVirtualListRange( + itemCount, + rowHeight, + state.scrollTop, + state.viewportHeight, + overscan + ), + [itemCount, overscan, rowHeight, state.scrollTop, state.viewportHeight] + ); + + const resetScroll = useCallback(() => { + const container = containerRef.current; + if (container) container.scrollTop = 0; + setState((current) => ({ ...current, scrollTop: 0 })); + }, []); + + return { + containerRef, + onScroll, + resetScroll, + ...range + }; +} diff --git a/src/styles.css b/src/styles.css index 9cb0ace..8da29b3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1836,6 +1836,10 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba .results-head { display: flex; height: 40px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); color: var(--muted); padding: 0 16px; font-size: 9px; } .inline-note { display: inline-flex; align-items: center; gap: 5px; color: var(--amber); } .result-list { max-height: calc(100vh - 330px); overflow: auto; } +.virtual-result-list { overflow-anchor: none; contain: layout paint; } +.virtual-result-list .result-grid-row { height: 58px; min-height: 58px; box-sizing: border-box; } +.virtual-result-list .content-row { height: 82px; min-height: 82px; box-sizing: border-box; } +.virtual-list-spacer { width: 1px; min-height: 0; pointer-events: none; } .result-row { display: flex; min-height: 57px; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); padding: 7px 13px; transition: background 120ms; } .result-row:last-child { border-bottom: 0; } .result-row:hover { background: color-mix(in srgb, var(--surface-hover) 50%, transparent); } diff --git a/src/types.ts b/src/types.ts index d5e1a25..d71534e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,9 +143,25 @@ 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 interface SearchIndexChangedEvent { changedCount: number; observedAt: string; + generation?: number; + contentScopes?: string[]; } export interface SearchContextActionResult { @@ -193,6 +209,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 DirectorySummary { @@ -570,11 +592,21 @@ export interface CDriveShiftApi { showSearchContextMenu(path: string, isDirectory: boolean): Promise; openExternal(url: string): Promise; search(query: string, filters: SearchFilters): Promise; + searchPage( + query: string, + filters: SearchFilters, + options?: SearchPageOptions + ): Promise>; searchContent( query: string, scope: string, options?: ContentSearchOptions ): Promise; + searchContentPage( + query: string, + scope: string, + options?: ContentSearchOptions & SearchPageOptions + ): Promise>; indexContent(scope: string): Promise; contentIndexStatus(scope: string): Promise; indexerStatus(): Promise; diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index fef3ab3..11b3162 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -5,7 +5,8 @@ import { useRef, useState, type DragEvent, - type MouseEvent + type MouseEvent, + type UIEvent } from "react"; import { createPortal } from "react-dom"; import { @@ -52,6 +53,7 @@ import { import { Badge, EmptyState, PageTitle } from "../components/ui"; import { api } from "../lib/api"; import { formatBytes, formatDate } from "../lib/format"; +import { useVirtualList } from "../lib/virtual-list"; import { bookmarkConditionCount, bookmarkSignature, @@ -91,6 +93,9 @@ interface SearchViewProps { standalone?: boolean; } +const NAME_PAGE_SIZE = 240; +const CONTENT_PAGE_SIZE = 80; + const categoryDefinitions: Array<{ value: SearchCategory; label: string; @@ -137,6 +142,13 @@ export function SearchView({ const [contentScope, setContentScope] = useState("*"); const [results, setResults] = useState([]); const [contentResults, setContentResults] = useState([]); + const [nameCursor, setNameCursor] = useState(); + const [contentCursor, setContentCursor] = useState(); + const [nameHasMore, setNameHasMore] = useState(false); + const [contentHasMore, setContentHasMore] = useState(false); + const [nameTotal, setNameTotal] = useState(); + const [contentTotal, setContentTotal] = useState(); + const [loadingMore, setLoadingMore] = useState(false); const [directorySizes, setDirectorySizes] = useState>( new Map() ); @@ -176,6 +188,8 @@ export function SearchView({ const [propertyPath, setPropertyPath] = useState(""); const [liveIndexRevision, setLiveIndexRevision] = useState(0); const requestSequence = useRef(0); + const pageRequestSequence = useRef(0); + const loadingMoreRef = useRef(false); const sizeSequence = useRef(0); const skipRestoredSearchRef = useRef(false); const skipRestoredSizesRef = useRef(false); @@ -183,6 +197,8 @@ export function SearchView({ const bookmarkPanelRef = useRef(null); const workspaceSnapshotRef = useRef(undefined); const liveRefreshRef = useRef(false); + const resultsRef = useRef([]); + const contentResultsRef = useRef([]); const { feedback: openFeedback, openPath, @@ -201,6 +217,14 @@ export function SearchView({ ? `${normalizeScopePath(contentStatus.root)}:${contentStatus.filesIndexed}` : ""; + useEffect(() => { + resultsRef.current = results; + }, [results]); + + useEffect(() => { + contentResultsRef.current = contentResults; + }, [contentResults]); + useEffect(() => { if (!bookmarkPanelOpen) return; const close = (event: PointerEvent) => { @@ -225,20 +249,50 @@ export function SearchView({ useEffect(() => api.onContentIndexerStatus(setContentStatus), []); useEffect(() => { - if (mode !== "name" || !query.trim()) return; + if (!query.trim()) return; let timer: number | undefined; - const unsubscribe = api.onSearchIndexChanged(() => { + const unsubscribe = api.onSearchIndexChanged((event) => { + if (mode === "content") { + const currentScope = normalizeScopePath(contentScope); + if ( + contentScope === "*" || + !event.contentScopes?.some( + (scope) => normalizeScopePath(scope) === currentScope + ) + ) { + return; + } + } if (timer != null) window.clearTimeout(timer); timer = window.setTimeout(() => { liveRefreshRef.current = true; setLiveIndexRevision((revision) => revision + 1); - }, 180); + }, 240); }); return () => { if (timer != null) window.clearTimeout(timer); unsubscribe(); }; - }, [mode, query]); + }, [contentScope, mode, query]); + + useEffect(() => { + if (!query.trim()) return; + let lastRefresh = 0; + const refreshWhenVisible = () => { + if (document.visibilityState === "hidden") return; + const now = Date.now(); + if (now - lastRefresh < 1_000) return; + lastRefresh = now; + liveRefreshRef.current = true; + setLiveIndexRevision((revision) => revision + 1); + }; + window.addEventListener("focus", refreshWhenVisible); + document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + window.removeEventListener("focus", refreshWhenVisible); + document.removeEventListener("visibilitychange", refreshWhenVisible); + }; + }, [query]); useEffect(() => { let cancelled = false; @@ -290,6 +344,12 @@ export function SearchView({ setContentScope(workspace.contentScope); setResults(workspace.results); setContentResults(workspace.contentResults); + setNameCursor(undefined); + setContentCursor(undefined); + setNameHasMore(workspace.mode === "name" && workspace.results.length >= NAME_PAGE_SIZE); + setContentHasMore( + workspace.mode === "content" && workspace.contentResults.length >= CONTENT_PAGE_SIZE + ); const restoredSizes = workspace.directorySizes ?? []; setDirectorySizes( new Map( @@ -455,12 +515,23 @@ export function SearchView({ if (!workspaceReady) return; if (skipRestoredSearchRef.current) { skipRestoredSearchRef.current = false; + liveRefreshRef.current = true; + window.setTimeout( + () => setLiveIndexRevision((revision) => revision + 1), + 0 + ); return; } const value = query.trim(); if (!value) { setResults([]); setContentResults([]); + setNameCursor(undefined); + setContentCursor(undefined); + setNameHasMore(false); + setContentHasMore(false); + setNameTotal(undefined); + setContentTotal(undefined); setElapsed(undefined); setError(""); return; @@ -485,41 +556,74 @@ export function SearchView({ } const sequence = ++requestSequence.current; + ++pageRequestSequence.current; + loadingMoreRef.current = false; + setLoadingMore(false); const backgroundRefresh = liveRefreshRef.current; liveRefreshRef.current = false; const timer = window.setTimeout(() => { const started = performance.now(); - if (!backgroundRefresh) setLoading(true); + if (!backgroundRefresh) { + setLoading(true); + setDirectorySizes(new Map()); + } setError(""); + const refreshLimit = + mode === "name" + ? Math.max(NAME_PAGE_SIZE, resultsRef.current.length) + : Math.max(CONTENT_PAGE_SIZE, contentResultsRef.current.length); const request = mode === "name" - ? api.search(value, { - ...filters, - kind: deriveKind(categories), - scope: scopes[0] ?? "*", - scopes - }) - : api.searchContent(value, contentScope, { + ? api.searchPage( + value, + { + ...filters, + kind: deriveKind(categories), + scope: scopes[0] ?? "*", + scopes + }, + { limit: backgroundRefresh ? refreshLimit : NAME_PAGE_SIZE } + ) + : api.searchContentPage(value, contentScope, { regex: filters.regex, - caseSensitive: filters.caseSensitive + caseSensitive: filters.caseSensitive, + sortBy: filters.sortBy, + sortDirection: filters.sortDirection, + minSize: filters.minSize, + maxSize: filters.maxSize, + modifiedAfter: filters.modifiedAfter, + modifiedBefore: filters.modifiedBefore, + limit: backgroundRefresh ? refreshLimit : CONTENT_PAGE_SIZE }); void request - .then((items) => { + .then((page) => { if (sequence !== requestSequence.current) return; if (mode === "name") { - const next = items as SearchResult[]; + const next = page.items as SearchResult[]; setResults((current) => searchResultsEqual(current, next) ? current : next ); + setNameCursor(page.nextCursor); + setNameHasMore(page.hasMore); + setNameTotal(page.totalMatches); setSelectedPath((current) => current && next.some((item) => item.path === current) ? current : next[0]?.path ?? "" ); setContentResults([]); + setContentCursor(undefined); + setContentHasMore(false); + setContentTotal(undefined); } else { - setContentResults(items as ContentSearchResult[]); + setContentResults(page.items as ContentSearchResult[]); + setContentCursor(page.nextCursor); + setContentHasMore(page.hasMore); + setContentTotal(page.totalMatches); setResults([]); + setNameCursor(undefined); + setNameHasMore(false); + setNameTotal(undefined); } setElapsed(performance.now() - started); }) @@ -554,9 +658,14 @@ export function SearchView({ setDirectorySizesLoading(false); return; } - const paths = results.filter((item) => item.isDirectory).map((item) => item.path); + const paths = results + .filter( + (item) => + item.isDirectory && !directorySizes.has(normalizeScopePath(item.path)) + ) + .slice(0, 180) + .map((item) => item.path); const sequence = ++sizeSequence.current; - setDirectorySizes(new Map()); if (paths.length === 0) { setDirectorySizesLoading(false); return; @@ -567,9 +676,13 @@ export function SearchView({ .directorySizes(paths) .then((items) => { if (sequence !== sizeSequence.current) return; - setDirectorySizes( - new Map(items.map((item) => [normalizeScopePath(item.path), item])) - ); + setDirectorySizes((current) => { + const next = new Map(current); + for (const item of items) { + next.set(normalizeScopePath(item.path), item); + } + return next; + }); }) .catch((reason) => { if (sequence === sizeSequence.current) { @@ -587,51 +700,149 @@ export function SearchView({ }, [notify, results, workspaceReady]); const displayedResults = useMemo(() => { - const enriched = results - .map((item) => { - const directorySize = item.isDirectory - ? directorySizes.get(normalizeScopePath(item.path)) - : undefined; - return directorySize ? { ...item, size: directorySize.bytes } : item; - }) - .filter((item) => { - if (!item.isDirectory) return true; - const size = directorySizes.get(normalizeScopePath(item.path)); - if (!size?.complete) return true; - if (filters.minSize != null && size.bytes < filters.minSize) return false; - if (filters.maxSize != null && size.bytes > filters.maxSize) return false; - return true; - }); - const field = filters.sortBy ?? "relevance"; - const direction = filters.sortDirection === "desc" ? -1 : 1; - enriched.sort((first, second) => { - let comparison = 0; - if (field === "name") { - comparison = first.name.localeCompare(second.name, "zh-CN", { - numeric: true, - sensitivity: "base" - }); - } else if (field === "path") { - comparison = first.path.localeCompare(second.path, "zh-CN", { - numeric: true, - sensitivity: "base" - }); - } else if (field === "size") { - comparison = first.size - second.size; - } else if (field === "modified") { - comparison = - (first.modifiedAt ? Date.parse(first.modifiedAt) : 0) - - (second.modifiedAt ? Date.parse(second.modifiedAt) : 0); - } else if (field === "type") { - comparison = categoryOf(first).localeCompare(categoryOf(second)); + return results.map((item) => { + const directorySize = item.isDirectory + ? directorySizes.get(normalizeScopePath(item.path)) + : undefined; + return directorySize ? { ...item, size: directorySize.bytes } : item; + }); + }, [directorySizes, results]); + + const activeResultCount = + mode === "name" ? displayedResults.length : contentResults.length; + const activeHasMore = mode === "name" ? nameHasMore : contentHasMore; + const virtualList = useVirtualList( + activeResultCount, + mode === "name" ? 58 : 82, + 10 + ); + const virtualNameResults = displayedResults.slice( + virtualList.start, + virtualList.end + ); + const virtualContentResults = contentResults.slice( + virtualList.start, + virtualList.end + ); + + useEffect(() => { + virtualList.resetScroll(); + }, [contentScope, filters, mode, query, virtualList.resetScroll]); + + const loadMoreResults = useCallback(async () => { + const value = query.trim(); + const hasMore = mode === "name" ? nameHasMore : contentHasMore; + if (!value || !hasMore || loading || loadingMoreRef.current) return; + const sequence = ++pageRequestSequence.current; + loadingMoreRef.current = true; + setLoadingMore(true); + setError(""); + try { + if (mode === "name") { + const restoredBootstrap = !nameCursor && resultsRef.current.length > 0; + const page = await api.searchPage( + value, + { + ...filters, + kind: deriveKind(categories), + scope: scopes[0] ?? "*", + scopes + }, + { + cursor: nameCursor, + limit: restoredBootstrap + ? Math.min(10_000, resultsRef.current.length + NAME_PAGE_SIZE) + : NAME_PAGE_SIZE + } + ); + if (sequence !== pageRequestSequence.current) return; + setResults((current) => + restoredBootstrap || page.cursorReset + ? page.items + : mergeSearchResults(current, page.items) + ); + setNameCursor(page.nextCursor); + setNameHasMore(page.hasMore); + setNameTotal(page.totalMatches); } else { - comparison = second.score - first.score; - return comparison || first.path.length - second.path.length; + const restoredBootstrap = !contentCursor && contentResultsRef.current.length > 0; + const page = await api.searchContentPage(value, contentScope, { + regex: filters.regex, + caseSensitive: filters.caseSensitive, + sortBy: filters.sortBy, + sortDirection: filters.sortDirection, + minSize: filters.minSize, + maxSize: filters.maxSize, + modifiedAfter: filters.modifiedAfter, + modifiedBefore: filters.modifiedBefore, + cursor: contentCursor, + limit: restoredBootstrap + ? Math.min(2_000, contentResultsRef.current.length + CONTENT_PAGE_SIZE) + : CONTENT_PAGE_SIZE + }); + if (sequence !== pageRequestSequence.current) return; + setContentResults((current) => + restoredBootstrap || page.cursorReset + ? page.items + : mergeContentResults(current, page.items) + ); + setContentCursor(page.nextCursor); + setContentHasMore(page.hasMore); + setContentTotal(page.totalMatches); + } + } catch (reason) { + if (sequence === pageRequestSequence.current) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + } finally { + if (sequence === pageRequestSequence.current) { + loadingMoreRef.current = false; + setLoadingMore(false); + } + } + }, [ + categories, + contentCursor, + contentHasMore, + contentScope, + filters, + loading, + loadingMore, + mode, + nameCursor, + nameHasMore, + query, + scopes + ]); + + const handleResultScroll = useCallback( + (event: UIEvent) => { + virtualList.onScroll(event); + const element = event.currentTarget; + if (element.scrollHeight - element.scrollTop - element.clientHeight < 720) { + void loadMoreResults(); + } + }, + [loadMoreResults, virtualList] + ); + + useEffect(() => { + if (!activeHasMore || loading || loadingMore) return; + const frame = window.requestAnimationFrame(() => { + const element = virtualList.containerRef.current; + if (element && element.scrollHeight <= element.clientHeight + 360) { + void loadMoreResults(); } - return comparison * direction || first.name.localeCompare(second.name, "zh-CN"); }); - return enriched; - }, [directorySizes, filters.maxSize, filters.minSize, filters.sortBy, filters.sortDirection, results]); + return () => window.cancelAnimationFrame(frame); + }, [ + activeResultCount, + activeHasMore, + loadMoreResults, + loading, + loadingMore, + virtualList.containerRef + ]); const chooseScope = async () => { const selected = await api.chooseDirectory( @@ -1087,6 +1298,11 @@ export function SearchView({ ? scopes[0] : `${scopes.length} 个范围`; const count = mode === "name" ? displayedResults.length : contentResults.length; + const totalCount = mode === "name" ? nameTotal : contentTotal; + const resultCountLabel = + totalCount != null + ? `已加载 ${count.toLocaleString()} / 共 ${totalCount.toLocaleString()} 个` + : `${count.toLocaleString()}${activeHasMore ? "+" : ""} 个结果`; return (
@@ -1766,7 +1982,12 @@ export function SearchView({ {query ? (
- {loading ? "正在查询…" : `${count} 个结果`} + {loading ? "正在查询…" : resultCountLabel} + {loadingMore && ( + + 正在加载下一批 + + )} {restoredAt && ( @@ -1841,8 +2062,16 @@ export function SearchView({ {error} ) : mode === "name" && displayedResults.length > 0 ? ( -
- {displayedResults.map((item) => { +
+ {virtualList.paddingTop > 0 && ( +
+ )} + {virtualNameResults.map((item) => { const directorySize = item.isDirectory ? directorySizes.get(normalizeScopePath(item.path)) : undefined; @@ -1909,6 +2138,12 @@ export function SearchView({
); })} + {virtualList.paddingBottom > 0 && ( +
+ )}
) : mode === "content" && contentResults.length > 0 ? ( <> @@ -1917,8 +2152,19 @@ export function SearchView({ 文件 / 路径 / 命中内容 大小 / 修改时间
-
- {contentResults.map((item) => { +
+ {virtualList.paddingTop > 0 && ( +
+ )} + {virtualContentResults.map((item) => { const contextItem: SearchResult = { path: item.path, name: item.name, @@ -1954,6 +2200,12 @@ export function SearchView({
); })} + {virtualList.paddingBottom > 0 && ( +
+ )}
) : !loading ? ( @@ -2100,3 +2352,33 @@ function searchResultsEqual(current: SearchResult[], next: SearchResult[]): bool ); }); } + +function mergeSearchResults( + current: SearchResult[], + next: SearchResult[] +): SearchResult[] { + const paths = new Set(current.map((item) => normalizeScopePath(item.path))); + const merged = [...current]; + for (const item of next) { + const key = normalizeScopePath(item.path); + if (paths.has(key)) continue; + paths.add(key); + merged.push(item); + } + return merged; +} + +function mergeContentResults( + current: ContentSearchResult[], + next: ContentSearchResult[] +): ContentSearchResult[] { + const paths = new Set(current.map((item) => normalizeScopePath(item.path))); + const merged = [...current]; + for (const item of next) { + const key = normalizeScopePath(item.path); + if (paths.has(key)) continue; + paths.add(key); + merged.push(item); + } + return merged; +} diff --git a/tests/search-index-change.test.ts b/tests/search-index-change.test.ts index 06114f5..dd2aeb0 100644 --- a/tests/search-index-change.test.ts +++ b/tests/search-index-change.test.ts @@ -22,12 +22,21 @@ describe("search index change bridge", () => { const internals = service as unknown as { handleLine(line: string): void }; internals.handleLine( - JSON.stringify({ event: "indexChanged", changedCount: 7 }) + JSON.stringify({ + event: "indexChanged", + changedCount: 7, + generation: 12, + contentScopes: ["E:\\docs"] + }) ); expect(onIndexChanged).toHaveBeenCalledOnce(); expect(onIndexChanged).toHaveBeenCalledWith( - expect.objectContaining({ changedCount: 7 }) + expect.objectContaining({ + changedCount: 7, + generation: 12, + contentScopes: ["E:\\docs"] + }) ); }); }); diff --git a/tests/virtual-list.test.ts b/tests/virtual-list.test.ts new file mode 100644 index 0000000..fd81dca --- /dev/null +++ b/tests/virtual-list.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { calculateVirtualListRange } from "../src/lib/virtual-list"; + +describe("virtual list range", () => { + it("renders only the visible window plus overscan", () => { + expect(calculateVirtualListRange(10_000, 50, 5_000, 500, 4)).toEqual({ + start: 96, + end: 114, + paddingTop: 4_800, + paddingBottom: 494_300 + }); + }); + + it("clamps the final window without changing total height", () => { + const range = calculateVirtualListRange(1_000, 40, 39_800, 400, 6); + expect(range.end).toBe(1_000); + expect(range.paddingTop + (range.end - range.start) * 40 + range.paddingBottom).toBe( + 40_000 + ); + }); +}); From 4d08e64faa9823eca55a656806377013e1adfa13 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Tue, 4 Aug 2026 00:33:37 -0400 Subject: [PATCH 6/8] feat: add smart high-performance search modes --- electron/main.ts | 8 +- electron/search.ts | 92 ++++++- electron/store.ts | 38 ++- electron/types.ts | 11 + native/indexer/src/main.rs | 219 ++++++++++++---- package.json | 1 + scripts/smoke-indexer.mjs | 33 +++ scripts/smoke-search-performance.mjs | 131 ++++++++++ scripts/visual-smoke.mjs | 57 ++++- src/lib/search.ts | 2 + src/styles.css | 131 ++++++++++ src/types.ts | 11 + src/views/SearchView.tsx | 360 +++++++++++++++++++++++++-- 13 files changed, 1018 insertions(+), 76 deletions(-) create mode 100644 scripts/smoke-search-performance.mjs diff --git a/electron/main.ts b/electron/main.ts index 69e8c51..ea8ecca 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1547,6 +1547,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 : "*", @@ -1573,9 +1576,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: diff --git a/electron/search.ts b/electron/search.ts index 92dfc96..d2b24d8 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -42,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; @@ -438,6 +486,7 @@ 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, sortBy: filters.sortBy ?? "relevance", @@ -471,7 +520,7 @@ export class SearchService { 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[] = []; @@ -685,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) { @@ -700,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; diff --git a/electron/store.ts b/electron/store.ts index 01ffd86..ddf7817 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" && diff --git a/electron/types.ts b/electron/types.ts index 997a0c5..934733f 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -86,6 +86,7 @@ export interface SearchFilters { modifiedBefore?: string; caseSensitive?: boolean; wholeWord?: boolean; + fuzzy?: boolean; matchPath?: boolean; regex?: boolean; sortBy?: SearchSortField; @@ -133,6 +134,7 @@ export interface SearchBookmarkFolder { export interface UiLayoutState { sidebarCollapsed?: boolean; + searchResultColumnWidths?: SearchResultColumnWidths; searchRenamePosition?: { x: number; y: number; @@ -488,6 +490,15 @@ export interface NativeResponse { contentScopes?: string[]; } +export interface SearchResultColumnWidths { + name: number; + path: number; + type: number; + size: number; + modified: number; + action: number; +} + export interface SearchIndexChangedEvent { changedCount: number; observedAt: string; diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index 9e8a718..70fe9ab 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -167,6 +167,7 @@ struct Request { extensions: Option>, case_sensitive: Option, whole_word: Option, + fuzzy: Option, match_path: Option, regex: Option, background: Option, @@ -734,6 +735,52 @@ fn contains_whole_word(target: &str, needle: &str) -> bool { }) } +/// Matches `needle` as an ordered subsequence of `target` and returns a +/// relevance score. Consecutive, early and compact matches rank above widely +/// scattered matches, while every query uses the same generic algorithm. +fn fuzzy_subsequence_score(target: &str, needle: &str) -> Option { + let mut expected = needle.chars(); + let mut next = expected.next()?; + let needle_length = needle.chars().count().max(1); + let mut first_position = None; + let mut last_position = 0usize; + let mut matched = 0usize; + let mut consecutive_pairs = 0usize; + + for (position, character) in target.chars().enumerate() { + if character != next { + continue; + } + if first_position.is_none() { + first_position = Some(position); + } else if position == last_position + 1 { + consecutive_pairs += 1; + } + last_position = position; + matched += 1; + match expected.next() { + Some(character) => next = character, + None => { + let first = first_position.unwrap_or(0); + let span = last_position.saturating_sub(first) + 1; + let compactness = needle_length as f64 / span.max(1) as f64; + let consecutive = + consecutive_pairs as f64 / needle_length.saturating_sub(1).max(1) as f64; + let prefix = 1.0 / (1.0 + first as f64 * 0.15); + let length_fit = needle.len() as f64 / target.len().max(needle.len()) as f64; + return Some( + 48.0 + compactness * 22.0 + + consecutive * 15.0 + + prefix * 9.0 + + length_fit * 6.0, + ); + } + } + } + debug_assert!(matched < needle_length); + None +} + fn trigram_hash(chars: [char; 3]) -> u32 { let mut hash = 2_166_136_261u32; for character in chars { @@ -984,6 +1031,7 @@ impl SearchIndex { extensions: &[String], case_sensitive: bool, whole_word: bool, + fuzzy_mode: bool, match_path: bool, regex_mode: bool, sort_by: &str, @@ -1036,12 +1084,15 @@ impl SearchIndex { }; let first_token_lower = tokens[0].to_lowercase(); - let query_signature = - if !regex_mode && !match_path && first_token_lower.chars().count() >= 3 { - name_signature(&first_token_lower) - } else { - 0 - }; + let query_signature = if !regex_mode + && !fuzzy_mode + && !match_path + && first_token_lower.chars().count() >= 3 + { + name_signature(&first_token_lower) + } else { + 0 + }; let keep = offset.saturating_add(limit).saturating_add(1).max(1); let prune_at = keep.saturating_mul(2).max(4_096); @@ -1051,36 +1102,29 @@ impl SearchIndex { || max_size.is_some() || modified_after_ms.is_some() || modified_before_ms.is_some(); - let mut matches = Vec::with_capacity(keep.min(8_192)); - let mut total_matches = 0usize; - for index in 0..self.entries.len() { + let scoring_needle = text.to_lowercase(); + let candidate_at = |index: usize| -> Option { if !self.live.get(index).copied().unwrap_or(false) { - continue; + return None; } if query_signature != 0 && self.name_signatures.get(index).map_or(true, |candidate| { candidate & query_signature != query_signature }) { - continue; + return None; } - let entry = match self.entries.get(index).copied() { - Some(value) => value, - None => continue, - }; - let path_value = match self.path(index) { - Some(value) => value, - None => continue, - }; + let entry = self.entries.get(index).copied()?; + let path_value = self.path(index)?; if self.path_is_removed(path_value) { - continue; + return None; } let is_directory = entry.is_directory(); if kind == "folder" && !is_directory { - continue; + return None; } if kind == "file" && is_directory { - continue; + return None; } let entry_path_lower = if !scope_values.is_empty() || match_path { Some(normalized(path_value)) @@ -1097,17 +1141,17 @@ impl SearchIndex { .is_some_and(|suffix| suffix.starts_with(['\\', '/'])) }) { - continue; + return None; } if !category_values.is_empty() && !category_values.contains(file_category(is_directory, path_value)) { - continue; + return None; } if !extension_values.is_empty() && (is_directory || !extension_values.contains(&extension_name(path_value))) { - continue; + return None; } let original_target = if match_path { path_value @@ -1121,8 +1165,19 @@ impl SearchIndex { folded_target = original_target.to_lowercase(); &folded_target }; + let fuzzy_score = if fuzzy_mode { + let mut total = 0.0; + for token in &tokens { + total += fuzzy_subsequence_score(target, token)?; + } + Some(total / tokens.len().max(1) as f64) + } else { + None + }; let matches_query = if let Some(regex) = &expression { regex.is_match(original_target) + } else if fuzzy_mode { + fuzzy_score.is_some() } else if whole_word { tokens .iter() @@ -1131,31 +1186,28 @@ impl SearchIndex { tokens.iter().all(|token| target.contains(token)) }; if !matches_query { - continue; + return None; } - let mut score = 60.0; - let scoring_needle = text.to_lowercase(); + let mut score = fuzzy_score.unwrap_or(60.0); let entry_name_lower = file_name(path_value).to_lowercase(); - if entry_name_lower == scoring_needle { + if entry_name_lower == scoring_needle.as_str() { score = 100.0; - } else if entry_name_lower.starts_with(&scoring_needle) { + } else if entry_name_lower.starts_with(scoring_needle.as_str()) { score = 92.0; - } else if entry_name_lower.contains(&scoring_needle) { + } else if entry_name_lower.contains(scoring_needle.as_str()) { score = 82.0; } else if entry_path_lower .as_deref() - .is_some_and(|path_value| path_value.ends_with(&scoring_needle)) - || (!match_path && normalized(path_value).ends_with(&scoring_needle)) + .is_some_and(|path_value| path_value.ends_with(scoring_needle.as_str())) + || (!match_path && normalized(path_value).ends_with(scoring_needle.as_str())) { score = 76.0; } score -= (path_value.len().min(400) as f64) * 0.01; let (size, modified_ms) = if needs_metadata { - let Ok(metadata) = fs::metadata(path_value) else { - continue; - }; + let metadata = fs::metadata(path_value).ok()?; let modified_ms = metadata .modified() .ok() @@ -1171,23 +1223,71 @@ impl SearchIndex { || modified_after_ms.is_some_and(|minimum| modified_ms < minimum) || modified_before_ms.is_some_and(|maximum| modified_ms > maximum) { - continue; + return None; } - total_matches = total_matches.saturating_add(1); - matches.push(SearchCandidate { + Some(SearchCandidate { index, score, size, modified_ms, - }); - if matches.len() >= prune_at { - matches.sort_by(|first, second| { + }) + }; + let process_range = |range: std::ops::Range| { + let mut partial = Vec::with_capacity(keep.min(4_096)); + let mut count = 0usize; + for index in range { + let Some(candidate) = candidate_at(index) else { + continue; + }; + count = count.saturating_add(1); + partial.push(candidate); + if partial.len() >= prune_at { + partial.sort_by(|first, second| { + self.compare_search_candidates(first, second, sort_by, sort_direction) + }); + partial.truncate(keep); + } + } + (partial, count) + }; + let entry_count = self.entries.len(); + let worker_count = thread::available_parallelism() + .map(|count| count.get().clamp(2, 8)) + .unwrap_or(4) + .min(entry_count.max(1)); + let chunk_size = entry_count.div_ceil(worker_count); + let ranges = (0..entry_count) + .step_by(chunk_size.max(1)) + .map(|start| start..(start + chunk_size).min(entry_count)) + .collect::>(); + let (mut matches, total_matches) = if entry_count >= 250_000 && ranges.len() > 1 { + let partials = thread::scope(|scope| { + let process = &process_range; + let handles = ranges + .into_iter() + .map(|range| scope.spawn(move || process(range))) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join()) + .collect::, _>>() + }) + .map_err(|_| "并行搜索线程异常退出".to_string())?; + let mut merged = Vec::with_capacity(keep.min(4_096)); + let mut merged_count = 0usize; + for (mut partial, count) in partials { + merged_count = merged_count.saturating_add(count); + merged.append(&mut partial); + merged.sort_by(|first, second| { self.compare_search_candidates(first, second, sort_by, sort_direction) }); - matches.truncate(keep); + merged.truncate(keep); } - } + (merged, merged_count) + } else { + process_range(0..entry_count) + }; matches.sort_by(|first, second| { self.compare_search_candidates(first, second, sort_by, sort_direction) }); @@ -3558,6 +3658,7 @@ fn run_server() -> io::Result<()> { &extensions, request.case_sensitive.unwrap_or(false), request.whole_word.unwrap_or(false), + request.fuzzy.unwrap_or(false), request.match_path.unwrap_or(false), request.regex.unwrap_or(false), &sort_by, @@ -3751,7 +3852,8 @@ fn main() { #[cfg(test)] mod tests { use super::{ - lowercase_name_signature, name_signature, normalized_path_hash, Entry, SearchIndex, + contains_whole_word, fuzzy_subsequence_score, lowercase_name_signature, name_signature, + normalized_path_hash, Entry, SearchIndex, }; use std::sync::atomic::AtomicBool; @@ -3773,6 +3875,29 @@ mod tests { ); } + #[test] + fn complete_word_matching_uses_generic_character_boundaries() { + assert!(contains_whole_word("test.jsp", "test")); + assert!(contains_whole_word("report-test-final", "test")); + assert!(contains_whole_word("admin.config", "admin")); + assert!(contains_whole_word("sql-injection.md", "sql")); + assert!(!contains_whole_word("deleteStudy", "test")); + assert!(!contains_whole_word("testing", "test")); + assert!(!contains_whole_word("administrator", "admin")); + assert!(!contains_whole_word("mysql", "sql")); + assert!(!contains_whole_word("test_value", "test")); + } + + #[test] + fn fuzzy_matching_is_generic_and_rewards_compact_matches() { + let compact = fuzzy_subsequence_score("redscope", "rdscp").expect("fuzzy match"); + let scattered = + fuzzy_subsequence_score("red-super-copied-project", "rdscp").expect("fuzzy match"); + assert!(compact > scattered); + assert!(fuzzy_subsequence_score("administrator", "admn").is_some()); + assert!(fuzzy_subsequence_score("redscope", "rdx").is_none()); + } + #[test] fn paged_query_keeps_global_sort_and_reports_more_results() { let backgrounded = AtomicBool::new(false); @@ -3811,6 +3936,7 @@ mod tests { false, false, false, + false, "name", "asc", None, @@ -3832,6 +3958,7 @@ mod tests { false, false, false, + false, "name", "asc", None, @@ -3890,6 +4017,7 @@ mod tests { false, false, false, + false, "relevance", "desc", None, @@ -3916,6 +4044,7 @@ mod tests { false, false, false, + false, "relevance", "desc", None, diff --git a/package.json b/package.json index 7e27d0d..157ffdf 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:index-cache": "node scripts/smoke-index-cache-restart.mjs", "test:packaged-index-cache": "node scripts/smoke-packaged-index-cache.mjs", "test:background-cpu": "node scripts/smoke-background-cpu.mjs", + "test:search-performance": "node scripts/smoke-search-performance.mjs", "test:app-performance": "node scripts/smoke-app-performance.mjs", "test:tray-performance": "node scripts/smoke-tray-idle-performance.mjs", "test:clean-exit": "node scripts/smoke-clean-exit.mjs", diff --git a/scripts/smoke-indexer.mjs b/scripts/smoke-indexer.mjs index c0bf6cc..28aed49 100644 --- a/scripts/smoke-indexer.mjs +++ b/scripts/smoke-indexer.mjs @@ -34,6 +34,8 @@ await writeFile(path.join(nested, "needle-page-a.txt"), "page a", "utf8"); await writeFile(path.join(nested, "needle-page-b.txt"), "page b", "utf8"); await writeFile(path.join(nested, "needle-page-c.txt"), "page c", "utf8"); await writeFile(path.join(nested, "portable-tool.exe"), "test executable fixture", "utf8"); +await writeFile(path.join(nested, "release-test.jsp"), "whole word fixture", "utf8"); +await writeFile(path.join(nested, "deleteStudy.md"), "substring-only fixture", "utf8"); await writeFile(path.join(scoped, "shared-inside.log"), "inside", "utf8"); await writeFile(path.join(similarlyNamedScope, "shared-outside.log"), "outside", "utf8"); @@ -206,6 +208,35 @@ try { ) { throw new Error("Case-sensitive regular expression filter returned incorrect results"); } + const completeWordResult = await request({ + op: "query", + query: "test", + kind: "file", + scope: fixture, + wholeWord: true, + sortBy: "name", + sortDirection: "asc", + limit: 10 + }); + if ( + completeWordResult.results?.length !== 1 || + completeWordResult.results[0]?.name !== "release-test.jsp" + ) { + throw new Error("Generic complete-word matching included a partial identifier match"); + } + const fuzzyResult = await request({ + op: "query", + query: "ndlfle", + kind: "file", + scope: fixture, + fuzzy: true, + sortBy: "relevance", + sortDirection: "desc", + limit: 10 + }); + if (!fuzzyResult.results?.some((result) => result.name === "Needle-file.txt")) { + throw new Error("Generic fuzzy name matching did not return the expected subsequence"); + } const executableCatalog = await request({ op: "executableCatalog", limit: 100 @@ -346,6 +377,8 @@ try { multiCategoryMatches: multiCategoryResult.results.length, scopedMatches: scopedResult.results.length, regexMatches: regexResult.results.length, + completeWordMatches: completeWordResult.results.length, + fuzzyMatches: fuzzyResult.results.length, executableCatalogMatches: executableCatalog.results.length, liveCreateMatches: liveCreated.results.length, liveDeleteMatches: liveDeleted.results.length, diff --git a/scripts/smoke-search-performance.mjs b/scripts/smoke-search-performance.mjs new file mode 100644 index 0000000..3e1f98a --- /dev/null +++ b/scripts/smoke-search-performance.mjs @@ -0,0 +1,131 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; + +if (process.platform !== "win32") { + throw new Error("The persisted search performance test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join( + workspace, + "native", + "indexer", + "target", + "release", + "cshift-indexer.exe" +); +const cachePath = path.join(workspace, ".cdriveshiftai-data", "search-index-v1.bin"); +const contentCacheDir = path.join(workspace, ".cdriveshiftai-data", "content-indexes"); +await access(executable); +await access(cachePath); + +const wait = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); +const child = spawn(executable, ["--serve"], { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"] +}); +const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); +const pending = new Map(); +const statuses = []; +let nextId = 0; +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); +}); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.event === "status") statuses.push(message.status); + const pendingRequest = pending.get(message.id); + if (!pendingRequest) return; + pending.delete(message.id); + clearTimeout(pendingRequest.timer); + if (message.ok === false) pendingRequest.reject(new Error(message.error)); + else pendingRequest.resolve(message); +}); + +const request = (payload, timeoutMs = 60_000) => { + const id = ++nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Native request ${id} timed out`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + child.stdin.write(`${JSON.stringify({ id, ...payload })}\n`); + }); +}; + +const waitUntilReady = async () => { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const ready = statuses.find((status) => status.state === "ready"); + if (ready) return ready; + await wait(50); + } + throw new Error(`Persisted index did not become ready: ${JSON.stringify(statuses)}`); +}; + +const timedQuery = async ({ query, wholeWord = false, fuzzy = false, regex = false }) => { + const started = performance.now(); + const response = await request({ + op: "query", + query, + kind: "all", + scope: "*", + scopes: [], + categories: [], + extensions: [], + wholeWord, + fuzzy, + regex, + sortBy: "name", + sortDirection: "asc", + limit: 240 + }); + return { + elapsedMs: Number((performance.now() - started).toFixed(1)), + returned: response.results?.length ?? 0, + totalMatches: response.totalMatches ?? 0, + hasMore: response.hasMore === true + }; +}; + +try { + await request({ + op: "init", + root: "*", + cachePath, + contentCacheDir, + background: false + }); + const ready = await waitUntilReady(); + const substring = await timedQuery({ query: "test" }); + const completeWord = await timedQuery({ query: "test", wholeWord: true }); + const fuzzy = await timedQuery({ query: "rdscp", fuzzy: true }); + console.log( + JSON.stringify( + { + result: "ok", + indexMode: ready.mode, + indexEntries: ready.entries, + substring, + completeWord, + fuzzy + }, + null, + 2 + ) + ); +} finally { + try { + await request({ op: "quit" }, 3_000); + } catch { + child.kill(); + } + if (/panic|uncaught/i.test(stderr)) { + throw new Error(stderr); + } +} diff --git a/scripts/visual-smoke.mjs b/scripts/visual-smoke.mjs index bc2faa8..57c037f 100644 --- a/scripts/visual-smoke.mjs +++ b/scripts/visual-smoke.mjs @@ -189,7 +189,16 @@ try { await waitFor('document.querySelector(".result-grid-row") !== null'); await clickButton("高级筛选"); await waitFor('document.querySelector(".advanced-filter-panel") !== null'); - await clickButton("正则表达式"); + await clickButton("包含匹配"); + await waitFor('document.querySelector(".match-mode-menu") !== null'); + const regexModeSelected = await evaluate(`(() => { + const button = [...document.querySelectorAll(".match-mode-menu button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "正则匹配"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + if (!regexModeSelected) throw new Error("Regex name match mode was unavailable"); await waitFor('document.querySelector(".regex-assistant") !== null'); await wait(220); await capture("cdriveshiftai-search-regex-assistant.png"); @@ -276,8 +285,52 @@ try { ...document.querySelectorAll(".result-columns button") ].some((item) => item.textContent?.includes("大小"))`); if (!searchSizeColumn) throw new Error("Search result size column was not rendered"); + const columnResize = await evaluate(`(() => { + const header = document.querySelector(".result-columns button"); + const handle = header?.querySelector(".result-column-resizer"); + if (!header || !handle) return null; + const headerBounds = header.getBoundingClientRect(); + const handleBounds = handle.getBoundingClientRect(); + return { + before: headerBounds.width, + x: handleBounds.left + handleBounds.width / 2, + y: handleBounds.top + handleBounds.height / 2 + }; + })()`); + if (!columnResize) throw new Error("Search result column resize handle was not rendered"); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: columnResize.x, + y: columnResize.y, + button: "left", + clickCount: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: columnResize.x + 48, + y: columnResize.y, + button: "left", + buttons: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: columnResize.x + 48, + y: columnResize.y, + button: "left", + clickCount: 1 + }); + const resizedColumnWidth = await evaluate( + 'document.querySelector(".result-columns button")?.getBoundingClientRect().width' + ); + if (!(resizedColumnWidth > columnResize.before + 20)) { + throw new Error(`Search result column did not resize: ${columnResize.before} -> ${resizedColumnWidth}`); + } await capture("cdriveshiftai-search-bookmark-folders.png"); - await evaluate('document.querySelector(".search-bookmark-toggle")?.click()'); + await evaluate(`(() => { + if (!document.querySelector(".search-bookmark-dropdown")) return true; + document.querySelector(".search-bookmark-toggle")?.click(); + return true; + })()`); await waitFor('document.querySelector(".search-bookmark-dropdown") === null'); const compactBookmarkHeight = await evaluate( 'document.querySelector(".search-bookmark-strip")?.getBoundingClientRect().height' diff --git a/src/lib/search.ts b/src/lib/search.ts index 4c4ba2a..5828977 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -70,6 +70,7 @@ export function defaultFilters(): SearchFilters { extensions: [], caseSensitive: false, wholeWord: false, + fuzzy: false, matchPath: false, regex: false, sortBy: "relevance", @@ -143,6 +144,7 @@ export function bookmarkConditionCount(bookmark: SearchBookmark): number { Number(filters.modifiedAfter != null || filters.modifiedBefore != null) + Number(filters.caseSensitive) + Number(filters.wholeWord) + + Number(filters.fuzzy) + Number(filters.matchPath) + Number(filters.regex) + Number(bookmark.mode === "content" && bookmark.contentScope !== "*") diff --git a/src/styles.css b/src/styles.css index 8da29b3..d1fb78d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1908,6 +1908,99 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mint) 3%, transparent); } +.search-filter-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; +} + +.match-mode-picker { position: relative; flex: 0 0 auto; } + +.match-mode-control { + display: inline-flex; + height: 31px; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--muted); + background: color-mix(in srgb, var(--surface-hover) 40%, transparent); + min-width: 112px; + justify-content: space-between; + padding: 0 8px; + font-size: 8px; + cursor: pointer; +} + +.match-mode-control > span { flex: 1; color: var(--text); font-weight: 650; white-space: nowrap; } +.match-mode-control > svg:last-child { transition: transform 150ms ease; } +.match-mode-control > svg:last-child.flip { transform: rotate(180deg); } + +.match-mode-control:hover, +.match-mode-control.whole, +.match-mode-control.fuzzy, +.match-mode-control.regex { + border-color: var(--border-strong); + color: var(--mint); + background: color-mix(in srgb, var(--mint) 8%, var(--surface)); +} + +.match-mode-menu { + position: absolute; + z-index: 60; + display: grid; + width: 224px; + gap: 3px; + border: 1px solid var(--border-strong); + border-radius: 11px; + background: + linear-gradient(145deg, color-mix(in srgb, var(--mint) 5%, transparent), transparent 55%), + color-mix(in srgb, var(--surface-solid) 96%, transparent); + box-shadow: var(--shadow), 0 0 18px color-mix(in srgb, var(--mint) 8%, transparent); + padding: 5px; + inset: calc(100% + 7px) 0 auto auto; + animation: filter-panel-in 130ms ease both; + backdrop-filter: blur(18px); +} + +.match-mode-menu button { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 7px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--muted); + background: transparent; + padding: 7px 8px; + cursor: pointer; + text-align: left; +} + +.match-mode-menu button > span { + grid-row: 1 / 3; + width: 10px; + height: 10px; + align-self: center; + border: 1px solid var(--border-strong); + border-radius: 50%; +} + +.match-mode-menu button strong { color: var(--text); font-size: 8px; } +.match-mode-menu button small { margin-top: 2px; color: var(--dim); font-size: 7px; } +.match-mode-menu button:hover, +.match-mode-menu button.active { + border-color: color-mix(in srgb, var(--mint) 22%, var(--border)); + background: color-mix(in srgb, var(--mint) 8%, transparent); +} + +.match-mode-menu button.active > span { + border: 3px solid color-mix(in srgb, var(--mint) 25%, var(--surface-solid)); + background: var(--mint); + box-shadow: 0 0 7px color-mix(in srgb, var(--mint) 50%, transparent); +} + .advanced-filter-button { display: inline-flex; flex: 0 0 auto; @@ -2225,6 +2318,11 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba letter-spacing: 0.03em; } +.result-columns > * { + position: relative; + min-width: 0; +} + .result-columns button { display: inline-flex; align-items: center; @@ -2238,6 +2336,39 @@ kbd { border: 1px solid var(--border); border-radius: 5px; color: var(--dim); ba } .result-columns button:hover { color: var(--mint); } + +.result-column-resizer { + position: absolute; + z-index: 4; + width: 14px; + cursor: col-resize; + inset: -8px -13px -8px auto; + touch-action: none; +} + +.result-column-resizer::after { + position: absolute; + width: 1px; + background: color-mix(in srgb, var(--mint) 42%, var(--border)); + content: ""; + inset: 7px auto 7px 6px; + opacity: 0; + transition: opacity 100ms ease, box-shadow 100ms ease; +} + +.result-column-resizer:hover::after, +.result-column-resizer:focus-visible::after, +:root.resizing-search-columns .result-column-resizer::after { + opacity: 1; + box-shadow: 0 0 7px color-mix(in srgb, var(--mint) 50%, transparent); +} + +:root.resizing-search-columns, +:root.resizing-search-columns * { + cursor: col-resize !important; + user-select: none !important; +} + .result-grid-row { min-height: 53px; gap: 0; diff --git a/src/types.ts b/src/types.ts index d71534e..404720a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,6 +70,7 @@ export interface SearchFilters { modifiedBefore?: string; caseSensitive?: boolean; wholeWord?: boolean; + fuzzy?: boolean; matchPath?: boolean; regex?: boolean; sortBy?: SearchSortField; @@ -117,6 +118,7 @@ export interface SearchBookmarkFolder { export interface UiLayoutState { sidebarCollapsed?: boolean; + searchResultColumnWidths?: SearchResultColumnWidths; searchRenamePosition?: { x: number; y: number; @@ -143,6 +145,15 @@ export interface SearchResult { source: "native-index" | "live-scan"; } +export interface SearchResultColumnWidths { + name: number; + path: number; + type: number; + size: number; + modified: number; + action: number; +} + export interface SearchPageOptions { cursor?: string; limit?: number; diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 11b3162..2f85d17 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -4,8 +4,11 @@ import { useMemo, useRef, useState, + type CSSProperties, type DragEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, + type PointerEvent as ReactPointerEvent, type UIEvent } from "react"; import { createPortal } from "react-dom"; @@ -79,6 +82,7 @@ import type { SearchBookmarkFolder, SearchFilters, SearchResult, + SearchResultColumnWidths, SearchSortDirection, SearchSortField, SearchWorkspaceState @@ -95,6 +99,30 @@ interface SearchViewProps { const NAME_PAGE_SIZE = 240; const CONTENT_PAGE_SIZE = 80; +const SEARCH_COLUMN_KEYS = [ + "name", + "path", + "type", + "size", + "modified", + "action" +] as const satisfies ReadonlyArray; +const DEFAULT_SEARCH_COLUMN_WIDTHS: SearchResultColumnWidths = { + name: 22, + path: 34, + type: 9, + size: 9, + modified: 16, + action: 10 +}; +const MIN_SEARCH_COLUMN_WIDTHS: SearchResultColumnWidths = { + name: 12, + path: 16, + type: 6, + size: 7, + modified: 10, + action: 7 +}; const categoryDefinitions: Array<{ value: SearchCategory; @@ -125,6 +153,15 @@ const sortLabels: Record = { type: "类型" }; +type NameMatchMode = "contains" | "whole" | "fuzzy" | "regex"; + +const nameMatchModeLabels: Record = { + contains: "包含匹配", + whole: "完整词匹配", + fuzzy: "模糊匹配", + regex: "正则匹配" +}; + export function SearchView({ indexer, drives, @@ -137,6 +174,7 @@ export function SearchView({ const [query, setQuery] = useState(""); const [filters, setFilters] = useState(defaultFilters); const [filterPanelOpen, setFilterPanelOpen] = useState(false); + const [matchModeMenuOpen, setMatchModeMenuOpen] = useState(false); const [extensionInput, setExtensionInput] = useState(""); const [datePreset, setDatePreset] = useState("any"); const [contentScope, setContentScope] = useState("*"); @@ -149,6 +187,9 @@ export function SearchView({ const [nameTotal, setNameTotal] = useState(); const [contentTotal, setContentTotal] = useState(); const [loadingMore, setLoadingMore] = useState(false); + const [columnWidths, setColumnWidths] = useState({ + ...DEFAULT_SEARCH_COLUMN_WIDTHS + }); const [directorySizes, setDirectorySizes] = useState>( new Map() ); @@ -195,10 +236,19 @@ export function SearchView({ const skipRestoredSizesRef = useRef(false); const searchInputRef = useRef(null); const bookmarkPanelRef = useRef(null); + const matchModePickerRef = useRef(null); const workspaceSnapshotRef = useRef(undefined); const liveRefreshRef = useRef(false); const resultsRef = useRef([]); const contentResultsRef = useRef([]); + const columnWidthsRef = useRef(columnWidths); + const columnResizeRef = useRef<{ + boundary: number; + pointerId: number; + startX: number; + tableWidth: number; + widths: SearchResultColumnWidths; + } | undefined>(undefined); const { feedback: openFeedback, openPath, @@ -225,6 +275,32 @@ export function SearchView({ contentResultsRef.current = contentResults; }, [contentResults]); + useEffect(() => { + columnWidthsRef.current = columnWidths; + }, [columnWidths]); + + useEffect(() => { + let cancelled = false; + void api + .getUiLayout() + .then((layout) => { + if (cancelled || !layout.searchResultColumnWidths) return; + columnWidthsRef.current = layout.searchResultColumnWidths; + setColumnWidths(layout.searchResultColumnWidths); + }) + .catch((reason) => + console.warn("Unable to restore search result column widths", reason) + ); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => document.documentElement.classList.remove("resizing-search-columns"), + [] + ); + useEffect(() => { if (!bookmarkPanelOpen) return; const close = (event: PointerEvent) => { @@ -239,6 +315,24 @@ export function SearchView({ window.addEventListener("pointerdown", close, true); return () => window.removeEventListener("pointerdown", close, true); }, [bookmarkPanelOpen]); + + useEffect(() => { + if (!matchModeMenuOpen) return; + const close = (event: PointerEvent) => { + if (!matchModePickerRef.current?.contains(event.target as Node)) { + setMatchModeMenuOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setMatchModeMenuOpen(false); + }; + window.addEventListener("pointerdown", close, true); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("pointerdown", close, true); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [matchModeMenuOpen]); const contentIndexingCurrentScope = mode === "content" && contentStatus.state === "indexing" && contentStatusMatchesScope; const regexValidation = useMemo( @@ -952,12 +1046,147 @@ export function SearchView({ }); }; + const resultGridStyle = useMemo( + () => ({ + gridTemplateColumns: SEARCH_COLUMN_KEYS.map( + (key) => `${columnWidths[key]}fr` + ).join(" ") + }), + [columnWidths] + ); + + const resizeColumnBoundary = useCallback( + ( + boundary: number, + deltaPercentage: number, + base = columnWidthsRef.current + ) => { + const leftKey = SEARCH_COLUMN_KEYS[boundary]; + const rightKey = SEARCH_COLUMN_KEYS[boundary + 1]; + if (!leftKey || !rightKey) return; + const pairWidth = base[leftKey] + base[rightKey]; + const leftWidth = Math.min( + pairWidth - MIN_SEARCH_COLUMN_WIDTHS[rightKey], + Math.max( + MIN_SEARCH_COLUMN_WIDTHS[leftKey], + base[leftKey] + deltaPercentage + ) + ); + const next = { + ...base, + [leftKey]: leftWidth, + [rightKey]: pairWidth - leftWidth + }; + columnWidthsRef.current = next; + setColumnWidths(next); + }, + [] + ); + + const beginColumnResize = useCallback( + (boundary: number, event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const table = event.currentTarget.closest(".result-columns"); + if (!(table instanceof HTMLElement) || table.clientWidth <= 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + columnResizeRef.current = { + boundary, + pointerId: event.pointerId, + startX: event.clientX, + tableWidth: table.clientWidth, + widths: { ...columnWidthsRef.current } + }; + document.documentElement.classList.add("resizing-search-columns"); + }, + [] + ); + + const moveColumnResize = useCallback( + (event: ReactPointerEvent) => { + const resize = columnResizeRef.current; + if (!resize || resize.pointerId !== event.pointerId) return; + event.preventDefault(); + resizeColumnBoundary( + resize.boundary, + ((event.clientX - resize.startX) / resize.tableWidth) * 100, + resize.widths + ); + }, + [resizeColumnBoundary] + ); + + const finishColumnResize = useCallback( + (event: ReactPointerEvent) => { + const resize = columnResizeRef.current; + if (!resize || resize.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + columnResizeRef.current = undefined; + document.documentElement.classList.remove("resizing-search-columns"); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + void api + .updateUiLayout({ searchResultColumnWidths: columnWidthsRef.current }) + .catch((reason) => + console.warn("Unable to save search result column widths", reason) + ); + }, + [] + ); + + const resetColumnWidths = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const next = { ...DEFAULT_SEARCH_COLUMN_WIDTHS }; + columnWidthsRef.current = next; + setColumnWidths(next); + void api + .updateUiLayout({ searchResultColumnWidths: next }) + .catch((reason) => + console.warn("Unable to reset search result column widths", reason) + ); + }, []); + + const adjustColumnWidthByKeyboard = useCallback( + (boundary: number, event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + resizeColumnBoundary(boundary, event.key === "ArrowLeft" ? -1 : 1); + void api + .updateUiLayout({ searchResultColumnWidths: columnWidthsRef.current }) + .catch((reason) => + console.warn("Unable to save search result column widths", reason) + ); + }, + [resizeColumnBoundary] + ); + const resetFilters = () => { setFilters(defaultFilters()); setExtensionInput(""); setDatePreset("any"); }; + const nameMatchMode: NameMatchMode = filters.regex + ? "regex" + : filters.fuzzy + ? "fuzzy" + : filters.wholeWord + ? "whole" + : "contains"; + + const selectNameMatchMode = useCallback((value: NameMatchMode) => { + setFilters((current) => ({ + ...current, + wholeWord: value === "whole", + fuzzy: value === "fuzzy", + regex: value === "regex" + })); + }, []); + const insertRegexToken = (token: string) => { const input = searchInputRef.current; const start = input?.selectionStart ?? query.length; @@ -987,6 +1216,7 @@ export function SearchView({ Number(filters.modifiedAfter != null || filters.modifiedBefore != null) + Number(filters.caseSensitive) + Number(filters.wholeWord) + + Number(filters.fuzzy) + Number(filters.matchPath) + Number(filters.regex); @@ -1303,6 +1533,22 @@ export function SearchView({ totalCount != null ? `已加载 ${count.toLocaleString()} / 共 ${totalCount.toLocaleString()} 个` : `${count.toLocaleString()}${activeHasMore ? "+" : ""} 个结果`; + const columnResizeHandle = (boundary: number, label: string) => ( + event.stopPropagation()} + onDoubleClick={resetColumnWidths} + onPointerDown={(event) => beginColumnResize(boundary, event)} + onPointerMove={moveColumnResize} + onPointerUp={finishColumnResize} + onPointerCancel={finishColumnResize} + onKeyDown={(event) => adjustColumnWidthByKeyboard(boundary, event)} + /> + ); return (
@@ -1531,6 +1777,8 @@ export function SearchView({ mode === "name" ? filters.regex ? "输入正则表达式,例如 ^报告.*\\.pdf$" + : filters.fuzzy + ? "输入模糊关键词,例如 rdscp 可匹配 redscope" : "搜索任意磁盘中的文件、目录或文件夹名字…" : filters.regex ? "输入正文正则,例如 error\\s+[45]\\d{2}" @@ -1581,16 +1829,62 @@ export function SearchView({ ))}
- +
+
+ + + + {matchModeMenuOpen && ( +
+ {(Object.keys(nameMatchModeLabels) as NameMatchMode[]).map((value) => ( + + ))} +
+ )} +
+ +
@@ -1783,9 +2077,7 @@ export function SearchView({
{[ ["matchPath", "匹配完整路径"], - ["caseSensitive", "区分大小写"], - ["wholeWord", "完整单词"], - ["regex", "正则表达式"] + ["caseSensitive", "区分大小写"] ].map(([field, label]) => ( )} + {nameMatchMode !== "contains" && ( + + )} + {filters.caseSensitive && ( + + )} + {filters.matchPath && ( + + )}
)} @@ -1893,7 +2208,12 @@ export function SearchView({ type="button" className={filters.regex ? "active" : ""} onClick={() => - setFilters((current) => ({ ...current, regex: !current.regex })) + setFilters((current) => ({ + ...current, + regex: !current.regex, + wholeWord: false, + fuzzy: false + })) } > @@ -2015,34 +2335,39 @@ export function SearchView({ {contentStatus.message} )} - 单击选中 · 双击打开 · 右键更多操作 + 单击选中 · 双击打开 · 右键操作 · 拖动表头分隔线调列宽
{mode === "name" && !error && displayedResults.length > 0 && ( -
+
操作
@@ -2081,6 +2406,7 @@ export function SearchView({ className={`result-row result-grid-row path-openable ${ selectedPath === item.path ? "selected" : "" } ${openClassNameFor(item.path)}`} + style={resultGridStyle} key={item.path} onClick={() => setSelectedPath(item.path)} onDoubleClick={(event) => openFromDoubleClick(event, item.path)} From f68a7662103ac5dfd0b6f685d45a37ed0ec73f1b Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Tue, 4 Aug 2026 02:56:53 -0400 Subject: [PATCH 7/8] feat: show compact release notes in updater --- electron/update.ts | 171 ++++++++++++++++++++++++++++++---- src/components/Sidebar.tsx | 8 +- src/lib/api.ts | 5 + src/lib/releaseNotes.ts | 34 +++++++ src/styles.css | 154 ++++++++++++++++++++++++++++++ src/types.ts | 7 ++ src/views/SettingsView.tsx | 80 ++++++++++++++++ tests/update-download.test.ts | 70 +++++++++++++- 8 files changed, 508 insertions(+), 21 deletions(-) create mode 100644 src/lib/releaseNotes.ts 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/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f1f2cfa..e947ae7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -42,10 +42,14 @@ export function Sidebar({ updateInfo }: SidebarProps) { const ready = indexer.state === "ready"; - const updateStatus = updateInfo?.status ?? "checking"; + const updateStatus = updateInfo?.updateAvailable + ? "available" + : updateInfo?.status ?? "checking"; const updateTooltip = updateStatus === "available" - ? `发现新版本 v${updateInfo?.latestVersion ?? "未知"};当前为 v${updateInfo?.currentVersion ?? "未知"}。点击进入设置更新。` + ? updateInfo?.status === "unavailable" + ? `已检测到新版本 v${updateInfo?.latestVersion ?? "未知"};本次联网复查失败,但不会清除已确认的更新。点击进入设置查看。` + : `发现新版本 v${updateInfo?.latestVersion ?? "未知"};当前为 v${updateInfo?.currentVersion ?? "未知"}。点击进入设置更新。` : updateStatus === "unavailable" ? `暂时无法检查版本:${updateInfo?.message ?? "请稍后重试"}。点击进入设置查看。` : updateStatus === "current" diff --git a/src/lib/api.ts b/src/lib/api.ts index 02e6b25..44456d8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -16,6 +16,7 @@ import type { SearchWorkspaceState, SystemOverview } from "../types"; +import { bundledReleaseNotes } from "./releaseNotes"; const mockOverview: SystemOverview = { drives: [ @@ -210,6 +211,8 @@ const browserFallback: CDriveShiftApi = { releaseName: "CDriveShiftAI 0.0.5", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", publishedAt: new Date().toISOString(), + releaseSummary: bundledReleaseNotes.summary, + releaseSections: bundledReleaseNotes.sections, assets: [], selectedAsset: { name: "CDriveShiftAI-x64-portable.exe", @@ -238,6 +241,8 @@ const browserFallback: CDriveShiftApi = { canAutoUpdate: false, releaseName: "CDriveShiftAI 0.0.5", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", + releaseSummary: bundledReleaseNotes.summary, + releaseSections: bundledReleaseNotes.sections, assets: [], message: "当前已是最新版本 0.0.5", checkedAt: new Date().toISOString() diff --git a/src/lib/releaseNotes.ts b/src/lib/releaseNotes.ts new file mode 100644 index 0000000..5f3a71d --- /dev/null +++ b/src/lib/releaseNotes.ts @@ -0,0 +1,34 @@ +import type { AppUpdateReleaseSection } from "../types"; + +export const bundledReleaseNotes: { + version: string; + summary: string; + sections: AppUpdateReleaseSection[]; +} = { + version: "0.0.5", + summary: "本版完善版本检测、系统代理、实时搜索结果和开机启动行为。", + sections: [ + { + title: "版本检测与自动更新", + items: [ + "启动及窗口恢复时检查正式版本,托盘静默时不轮询。", + "更新请求遵循 Windows 系统代理与 PAC。", + "安装版与便携版均保留校验、续传和失败回滚。" + ] + }, + { + title: "极速搜索实时结果", + items: [ + "新增、删除、改名和移动会按当前条件动态更新结果。", + "托盘静默且没有有效搜索时不运行动态查询。" + ] + }, + { + title: "启动与安装行为", + items: [ + "新增开机启动后最小化,并降低托盘后台内存占用。", + "覆盖安装会保留用户原有的桌面快捷方式选择。" + ] + } + ] +}; diff --git a/src/styles.css b/src/styles.css index d1fb78d..50ea497 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4275,6 +4275,10 @@ input:checked + .toggle::after { transform: translateX(13px); } color: #ef5f64; background: color-mix(in srgb, #ef5f64 12%, transparent); } +.settings-icon.update-warning { + color: var(--amber); + background: color-mix(in srgb, var(--amber) 12%, transparent); +} .update-status-dot { width: 10px; height: 10px; @@ -4321,6 +4325,152 @@ input:checked + .toggle::after { transform: translateX(13px); } .update-actions button { white-space: nowrap; } +.update-status-dot.update-warning { + background: var(--amber); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--amber) 12%, transparent), + 0 0 14px color-mix(in srgb, var(--amber) 45%, transparent); +} +.update-release-notes { + display: grid; + grid-template-columns: minmax(205px, .7fr) minmax(0, 2.3fr); + gap: 12px; + margin-top: 10px; + border: 1px solid color-mix(in srgb, var(--mint) 20%, var(--border)); + border-radius: 11px; + padding: 10px 12px; + background: + linear-gradient(110deg, color-mix(in srgb, var(--mint) 5%, transparent), transparent 45%), + color-mix(in srgb, var(--surface) 72%, transparent); +} +.update-release-heading { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.update-release-title { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 8px; +} +.update-release-title > svg { + flex: 0 0 auto; + margin-top: 1px; + color: var(--mint-bright); +} +.update-release-title > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.update-release-title strong { font-size: 10px; } +.update-release-title small { + display: -webkit-box; + overflow: hidden; + color: var(--muted); + font-size: 8px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.update-release-link { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + border: 0; + color: var(--mint-bright); + background: transparent; + cursor: pointer; + font-size: 8px; +} +.update-release-link:hover { color: var(--text); } +.update-release-body { + display: grid; + min-width: 0; + grid-template-columns: minmax(115px, .52fr) minmax(0, 2fr); + gap: 10px; +} +.update-release-tabs { + display: flex; + overflow: auto; + min-width: 0; + max-height: 74px; + flex-direction: column; + gap: 3px; + scrollbar-width: thin; +} +.update-release-tabs button { + overflow: hidden; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: 7px; + padding: 5px 7px; + color: var(--muted); + background: transparent; + cursor: pointer; + font-size: 8px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.update-release-tabs button:hover { + color: var(--text); + background: var(--surface-hover); +} +.update-release-tabs button.active { + border-color: color-mix(in srgb, var(--mint) 28%, var(--border)); + color: var(--text); + background: color-mix(in srgb, var(--mint) 9%, var(--surface)); +} +.update-release-items { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 6px 14px; +} +.update-release-items > span { + display: flex; + overflow: hidden; + min-width: 0; + align-items: baseline; + gap: 6px; + color: var(--muted); + font-size: 8px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +} +.update-release-items > span > i { + width: 4px; + height: 4px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 7px color-mix(in srgb, var(--mint) 55%, transparent); +} +.update-release-items .update-release-more { + color: var(--mint-bright); +} +:root[data-effect="aurora"] .update-release-notes { + border-width: 2px; + border-radius: 3px; + box-shadow: 3px 3px 0 color-mix(in srgb, #061316 42%, transparent); +} +:root[data-effect="aurora"] .update-release-tabs button, +:root[data-effect="aurora"] .update-release-items > span > i { + border-radius: 1px; +} +:root[data-effect="matrix"] .update-release-notes { + box-shadow: inset 0 0 30px rgba(52, 195, 255, .035); +} +:root[data-effect="calm"] .update-release-notes { + box-shadow: 0 10px 28px rgba(49, 75, 88, .04); +} .update-assets { display: flex; flex-wrap: wrap; @@ -6473,6 +6623,10 @@ input:checked + .toggle::after { transform: translateX(13px); } .check-button { grid-column: 1 / -1; } .ai-connection-grid { grid-template-columns: 1fr; } .ai-model-discovery { grid-template-columns: minmax(140px, .5fr) minmax(210px, 1fr) auto; } + .update-status-panel { grid-template-columns: minmax(95px, .45fr) minmax(95px, .45fr) minmax(220px, 1.5fr); } + .update-actions { grid-column: 1 / -1; justify-content: flex-end; } + .update-release-notes { grid-template-columns: 1fr; } + .update-release-body { grid-template-columns: minmax(150px, .55fr) minmax(0, 2fr); } .directory-insight-header, .directory-insight-row { grid-template-columns: minmax(150px, .8fr) minmax(210px, 1.3fr) 76px 30px; } } diff --git a/src/types.ts b/src/types.ts index 404720a..2b7f03d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -528,6 +528,11 @@ export interface AppUpdateProgress { maxRetries: number; } +export interface AppUpdateReleaseSection { + title: string; + items: string[]; +} + export interface AppUpdateInfo { status: "checking" | "current" | "available" | "unavailable"; phase: UpdatePhase; @@ -539,6 +544,8 @@ export interface AppUpdateInfo { releaseName?: string; releaseUrl?: string; publishedAt?: string; + releaseSummary?: string; + releaseSections?: AppUpdateReleaseSection[]; assets: AppUpdateAsset[]; selectedAsset?: AppUpdateAsset; progress?: AppUpdateProgress; diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 318198a..e21cd1d 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -30,6 +30,7 @@ import { import { api } from "../lib/api"; import { effectDefinitions } from "../lib/effects"; import { getAiProvider } from "../lib/aiProviders"; +import { bundledReleaseNotes } from "../lib/releaseNotes"; import type { AiModelInfo, AiTestResult, @@ -299,6 +300,7 @@ export function SettingsView({ >("idle"); const [testingShortcut, setTestingShortcut] = useState(); const [testingMouseShortcut, setTestingMouseShortcut] = useState(false); + const [activeReleaseModule, setActiveReleaseModule] = useState(); const [mouseShortcutStatus, setMouseShortcutStatus] = useState(); const [showApiKey, setShowApiKey] = useState(false); @@ -320,6 +322,22 @@ export function SettingsView({ const updateBusy = ["downloading", "verifying", "ready", "installing"].includes( updateInfo?.phase ?? "" ); + const bundledNotesMatch = + !updateInfo || + updateInfo.latestVersion === bundledReleaseNotes.version || + (!updateInfo.updateAvailable && + updateInfo.currentVersion === bundledReleaseNotes.version); + const releaseSections = updateInfo?.releaseSections?.length + ? updateInfo.releaseSections + : bundledNotesMatch + ? bundledReleaseNotes.sections + : []; + const releaseSummary = + updateInfo?.releaseSummary ?? + (bundledNotesMatch ? bundledReleaseNotes.summary : updateInfo?.releaseName); + const activeReleaseSection = + releaseSections.find((section) => section.title === activeReleaseModule) ?? + releaseSections[0]; useEffect(() => { const previous = persistedSettingsRef.current; @@ -738,6 +756,8 @@ export function SettingsView({ className={ updateInfo?.updateAvailable ? "settings-icon update-alert" + : updateInfo?.status === "unavailable" + ? "settings-icon update-warning" : "settings-icon update-current" } > @@ -751,6 +771,8 @@ export function SettingsView({ className={ updateInfo?.updateAvailable ? "update-status-dot update-available" + : updateInfo?.status === "unavailable" + ? "update-status-dot update-warning" : "update-status-dot" } /> @@ -854,6 +876,64 @@ export function SettingsView({ )}
+ {activeReleaseSection && ( +
+
+ + + + + {updateInfo?.updateAvailable ? "本次更新" : "当前版本说明"} + + + {releaseSummary} + + + + {updateInfo?.releaseUrl && ( + + )} +
+
+
+ {releaseSections.map((section) => ( + + ))} +
+
+ {activeReleaseSection.items.slice(0, 4).map((item, index) => ( + + + {item} + + ))} + {activeReleaseSection.items.length > 4 && ( + + 另有 {activeReleaseSection.items.length - 4} 项,完整内容见 Release + + )} +
+
+
+ )} {updateInfo && ["downloading", "verifying", "ready", "installing", "error", "cancelled"].includes( updateInfo.phase diff --git a/tests/update-download.test.ts b/tests/update-download.test.ts index 5f98073..cda63ca 100644 --- a/tests/update-download.test.ts +++ b/tests/update-download.test.ts @@ -15,7 +15,11 @@ vi.mock("electron", () => ({ } })); -import { summarizeProxyRules, UpdateService } from "../electron/update"; +import { + parseReleaseNotes, + summarizeProxyRules, + UpdateService +} from "../electron/update"; interface DownloadAttempt { downloadAttempt( @@ -27,6 +31,14 @@ interface DownloadAttempt { ): Promise; } +interface UpdateCheckFetcher { + fetchForUpdateCheck( + url: string, + currentVersion: string, + refreshRoute?: boolean + ): Promise; +} + const fixtureRoot = path.join( process.cwd(), ".cdriveshiftai-data", @@ -54,6 +66,13 @@ function downloader(): DownloadAttempt { return new UpdateService(() => undefined, async () => undefined) as unknown as DownloadAttempt; } +function updateCheckFetcher(): UpdateCheckFetcher { + return new UpdateService( + () => undefined, + async () => undefined + ) as unknown as UpdateCheckFetcher; +} + beforeEach(async () => { await rm(fixtureRoot, { recursive: true, force: true }); await mkdir(fixtureRoot, { recursive: true }); @@ -68,6 +87,32 @@ afterEach(async () => { }); describe("auto-update downloader", () => { + it("turns GitHub Markdown release notes into compact UI modules", () => { + const parsed = parseReleaseNotes(`# CDriveShiftAI 0.0.5 + +本版完善更新、搜索与后台行为。 + +## 自动更新 +- 遵循 Windows 系统代理。 +- 下载失败自动续传。 + +## 极速搜索 +- 新增文件会实时进入当前排序。 +- 删除文件会从结果中移除。`); + + expect(parsed.summary).toBe("本版完善更新、搜索与后台行为。"); + expect(parsed.sections).toEqual([ + { + title: "自动更新", + items: ["遵循 Windows 系统代理。", "下载失败自动续传。"] + }, + { + title: "极速搜索", + items: ["新增文件会实时进入当前排序。", "删除文件会从结果中移除。"] + } + ]); + }); + it("recognizes Windows system proxy and direct routes", () => { expect(summarizeProxyRules("PROXY 127.0.0.1:7890; DIRECT")).toEqual({ mode: "system-proxy", @@ -78,6 +123,29 @@ describe("auto-update downloader", () => { label: "系统网络 · 直连" }); }); + + it("retries a transient release endpoint failure", async () => { + let requests = 0; + const url = await listen((_request, response) => { + requests += 1; + if (requests === 1) { + response.writeHead(503); + response.end("temporarily unavailable"); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ tag_name: "v0.0.5" })); + }); + + const response = await updateCheckFetcher().fetchForUpdateCheck( + url, + "0.0.4", + true + ); + expect(response.status).toBe(200); + expect(requests).toBe(2); + }); + it("resumes a partially downloaded package with an HTTP Range request", async () => { let requests = 0; let resumedFrom = -1; From 3a4f4244028fb5e45620f462bb8ee5bb305c8e64 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Tue, 4 Aug 2026 06:03:08 -0400 Subject: [PATCH 8/8] feat: expand themes and refine search workspace --- CHANGELOG.md | 22 + README.md | 2 +- README.zh-CN.md | 2 +- docs/RELEASE-0.0.6.md | 31 ++ electron/main.ts | 19 +- electron/store.ts | 5 + electron/tray-icons.ts | 28 ++ electron/types.ts | 2 +- index.html | 10 +- package-lock.json | 4 +- package.json | 2 +- scripts/visual-smoke.mjs | 64 ++- src/App.tsx | 15 +- src/components/BackgroundFX.tsx | 100 +++- src/lib/effects.ts | 24 + src/lib/releaseNotes.ts | 26 +- src/styles.css | 788 ++++++++++++++++++++++++++------ src/types.ts | 4 +- src/views/QuickSearchWindow.tsx | 57 ++- src/views/SearchView.tsx | 381 ++++++++------- src/views/SettingsView.tsx | 130 ++++-- 21 files changed, 1304 insertions(+), 412 deletions(-) create mode 100644 docs/RELEASE-0.0.6.md 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 ea8ecca..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) => { diff --git a/electron/store.ts b/electron/store.ts index ddf7817..4a5b96f 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -492,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 934733f..48b064e 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -304,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; diff --git a/index.html b/index.html index 3c629a1..1d032f4 100644 --- a/index.html +++ b/index.html @@ -7,17 +7,21 @@