From 97af9b3e9673b2bb592c13b8dddf1ea2584b7dd8 Mon Sep 17 00:00:00 2001 From: aliyun1121003339 Date: Sat, 11 Jul 2026 23:30:05 +0800 Subject: [PATCH] refactor(ui): separate history query and navigation state --- ...settings-navigation-progress-2026-07-11.md | 3 +- ...gs-navigation-progress-2026-07-11.zh-CN.md | 3 +- src/diagram/history/diagramHistoryQuery.ts | 68 ++++++++++++++ .../history/diagramHistoryRepository.ts | 92 ++----------------- src/tests/diagramHistoryRepository.test.ts | 5 + src/tests/settingCatalog.test.ts | 13 +++ src/ui/NotemdSettingTab.ts | 19 ++-- src/ui/settings/SettingsNavigation.ts | 28 ++++++ 8 files changed, 139 insertions(+), 92 deletions(-) create mode 100644 src/diagram/history/diagramHistoryQuery.ts create mode 100644 src/ui/settings/SettingsNavigation.ts diff --git a/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.md b/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.md index ca360e05..9122e49d 100644 --- a/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.md +++ b/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.md @@ -4,7 +4,7 @@ Language: **English** | [简体中文](./vault-history-settings-navigation-progr ## Architecture Comparison -The previous preview history was a 12-item process-local session list. The current implementation adds a Vault-persisted metadata repository with completion-time ordering, normalized token search, intent/source-format/export filters, pagination, retention, and serialized writes. The in-modal list remains the fast recent-session surface; **Manage Vault history** opens the broader searchable index. +The previous preview history was a 12-item process-local session list. The current implementation adds a Vault-persisted metadata repository with completion-time ordering, normalized token search, intent/source-format/export filters, pagination, retention, and serialized writes. Pure query behavior now lives in `diagramHistoryQuery.ts`, independently of persistence writes. The in-modal list remains the fast recent-session surface; **Manage Vault history** opens the broader searchable index. The previous settings page was one sequential renderer with no discovery layer. The current page adds a sticky search/navigation surface and Vault-persisted favorites while retaining existing Obsidian `Setting` controls. A pure fuzzy-search module provides the matching boundary. Favorites now resolve IDs from canonical translation paths, so changing locale or inserting an unrelated setting no longer shifts every saved favorite; dynamically generated provider rows receive deterministic content-derived fallback IDs. @@ -19,6 +19,7 @@ The previous batch title flow validated the selected path only inside `batchGene - Persisted editable artifacts can now reconstruct the existing direct-preview pipeline from history. Reopening reuses the original history identity instead of creating a misleading new generation record, while subsequent exports continue updating that record. - Settings search header, heading navigation, responsive layout, and per-setting favorites saved in plugin data. - Settings discovery now announces localized visible/total result counts, shows a localized empty state, assigns catalog entries to their nearest heading category, and prunes obsolete or duplicate favorite IDs without disturbing valid saved order. +- `SettingsNavigation.ts` now owns the combined fuzzy-query/favorites visibility calculation and visible-category set, so category buttons disappear when their group has no matching settings rather than presenting dead navigation choices. - Batch-folder preparation domain and integration before title-generation batch execution. - Hosts without an interactive preparation callback now use a read-only Vault inspection fallback. Missing, non-empty, or file-collision targets stop before generation with a recoverable interaction-required status; they do not create folders, emit an error log, or authorize writes. - New defaults: `favoriteSettingIds`, `diagramHistoryRetentionLimit`, `diagramHistoryEntries`, and `autoCreateMissingBatchTargetFolders`. diff --git a/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.zh-CN.md b/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.zh-CN.md index aec1e4a0..70c5460f 100644 --- a/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.zh-CN.md +++ b/docs/maintainer/vault-history-settings-navigation-progress-2026-07-11.zh-CN.md @@ -4,7 +4,7 @@ ## 架构对比 -此前的预览历史只是进程内 12 条会话列表。当前实现增加 Vault 持久化元数据仓库,支持按完成时间排序、规范化 token 搜索、图表类型/源格式/导出筛选、分页、保留上限和串行写入。预览弹窗仍保留快速近期会话入口;**管理 Vault 历史**用于打开更完整的可搜索索引。 +此前的预览历史只是进程内 12 条会话列表。当前实现增加 Vault 持久化元数据仓库,支持按完成时间排序、规范化 token 搜索、图表类型/源格式/导出筛选、分页、保留上限和串行写入。纯查询行为现已独立放入 `diagramHistoryQuery.ts`,与持久化写入解耦。预览弹窗仍保留快速近期会话入口;**管理 Vault 历史**用于打开更完整的可搜索索引。 此前设置页是单个顺序渲染器,没有发现层。当前页面在保留 Obsidian 原生 `Setting` 控件的同时,增加吸顶搜索/导览和 Vault 持久化收藏。纯模糊搜索模块提供匹配边界。收藏 ID 现在由规范翻译路径解析,因此切换语言或插入无关设置不会再整体错位;动态 provider 行使用由内容确定生成的 fallback ID。 @@ -19,6 +19,7 @@ - 持久化的可编辑产物现在可以从历史记录重建既有直接预览链路。重新打开会复用原历史身份,不会制造误导性的“新生成”记录;后续导出仍会更新该记录。 - 设置搜索头部、标题导览、响应式布局,以及写入插件数据的单项收藏。 - 设置发现层现在会以本地化方式播报“可见项/总项”数量、显示本地化空状态、把 catalog 项归入最近的标题分类,并清理失效或重复收藏 ID,同时保持有效收藏的原有顺序。 +- `SettingsNavigation.ts` 现在负责组合模糊查询、收藏筛选、可见项与可见分类集合;分类内没有匹配设置时,对应导览按钮会隐藏,不再形成无效导航选择。 - 批处理文件夹准备领域,并接入从标题批量生成执行前。 - 未提供交互式准备回调的 host 现在会使用只读 Vault 检查 fallback。目标缺失、非空或为文件冲突时,会在生成前以可恢复的“需要交互”状态停止;不会创建目录、写错误日志或默许写入。 - 新增默认设置:`favoriteSettingIds`、`diagramHistoryRetentionLimit`、`diagramHistoryEntries`、`autoCreateMissingBatchTargetFolders`。 diff --git a/src/diagram/history/diagramHistoryQuery.ts b/src/diagram/history/diagramHistoryQuery.ts new file mode 100644 index 00000000..21b29bd3 --- /dev/null +++ b/src/diagram/history/diagramHistoryQuery.ts @@ -0,0 +1,68 @@ +import type { DiagramIntent, RenderTarget } from '../types'; + +export type DiagramHistoryExportKind = 'svg' | 'png' | 'pdf'; + +export interface DiagramHistoryEntry { + id: string; + completedAt: number; + title: string; + sourcePath?: string; + intent: DiagramIntent; + sourceFormat: RenderTarget; + artifactPath?: string; + exportPaths: Partial>; + status: 'completed' | 'failed'; + errorMessage?: string; +} + +export interface DiagramHistoryQuery { + search?: string; + intent?: DiagramIntent; + sourceFormat?: RenderTarget; + exportKind?: DiagramHistoryExportKind; + sourcePath?: string; + completedFrom?: number; + completedTo?: number; + page?: number; + pageSize?: number; +} + +export interface DiagramHistoryPage { + items: DiagramHistoryEntry[]; + page: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + +export function cloneDiagramHistoryEntry(entry: DiagramHistoryEntry): DiagramHistoryEntry { + return { ...entry, exportPaths: { ...entry.exportPaths } }; +} + +function normalizedTokens(value: string): string[] { + return value.toLocaleLowerCase().normalize('NFKC').trim().split(/\s+/).filter(Boolean); +} + +function matchesSearch(entry: DiagramHistoryEntry, search: string): boolean { + const haystack = [entry.title, entry.sourcePath, entry.artifactPath, entry.intent, entry.sourceFormat] + .filter(Boolean).join(' ').toLocaleLowerCase().normalize('NFKC'); + return normalizedTokens(search).every(token => haystack.includes(token)); +} + +export function queryDiagramHistoryEntries(entries: readonly DiagramHistoryEntry[], query: DiagramHistoryQuery): DiagramHistoryPage { + const pageSize = Math.max(1, Math.floor(query.pageSize ?? 20)); + const page = Math.max(1, Math.floor(query.page ?? 1)); + const filtered = entries + .filter(entry => !query.search || matchesSearch(entry, query.search)) + .filter(entry => !query.intent || entry.intent === query.intent) + .filter(entry => !query.sourceFormat || entry.sourceFormat === query.sourceFormat) + .filter(entry => !query.exportKind || Boolean(entry.exportPaths[query.exportKind])) + .filter(entry => !query.sourcePath || entry.sourcePath === query.sourcePath) + .filter(entry => query.completedFrom === undefined || entry.completedAt >= query.completedFrom) + .filter(entry => query.completedTo === undefined || entry.completedAt <= query.completedTo) + .sort((left, right) => right.completedAt - left.completedAt); + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const resolvedPage = Math.min(page, totalPages); + const start = (resolvedPage - 1) * pageSize; + return { items: filtered.slice(start, start + pageSize).map(cloneDiagramHistoryEntry), page: resolvedPage, pageSize, totalItems: filtered.length, totalPages }; +} diff --git a/src/diagram/history/diagramHistoryRepository.ts b/src/diagram/history/diagramHistoryRepository.ts index 8616ec9c..6361bff4 100644 --- a/src/diagram/history/diagramHistoryRepository.ts +++ b/src/diagram/history/diagramHistoryRepository.ts @@ -1,83 +1,9 @@ -import type { DiagramIntent, RenderTarget } from '../types'; - -export type DiagramHistoryExportKind = 'svg' | 'png' | 'pdf'; - -export interface DiagramHistoryEntry { - id: string; - completedAt: number; - title: string; - sourcePath?: string; - intent: DiagramIntent; - sourceFormat: RenderTarget; - artifactPath?: string; - exportPaths: Partial>; - status: 'completed' | 'failed'; - errorMessage?: string; -} - -export interface DiagramHistoryQuery { - search?: string; - intent?: DiagramIntent; - sourceFormat?: RenderTarget; - exportKind?: DiagramHistoryExportKind; - sourcePath?: string; - completedFrom?: number; - completedTo?: number; - page?: number; - pageSize?: number; -} - -export interface DiagramHistoryPage { - items: DiagramHistoryEntry[]; - page: number; - pageSize: number; - totalItems: number; - totalPages: number; -} +import { cloneDiagramHistoryEntry, DiagramHistoryEntry, DiagramHistoryExportKind, DiagramHistoryPage, DiagramHistoryQuery, queryDiagramHistoryEntries } from './diagramHistoryQuery'; +export type { DiagramHistoryEntry, DiagramHistoryExportKind, DiagramHistoryPage, DiagramHistoryQuery } from './diagramHistoryQuery'; type LoadEntries = () => Promise; type SaveEntries = (entries: DiagramHistoryEntry[]) => Promise; -function cloneEntry(entry: DiagramHistoryEntry): DiagramHistoryEntry { - return { ...entry, exportPaths: { ...entry.exportPaths } }; -} - -function normalizedTokens(value: string): string[] { - return value.toLocaleLowerCase().normalize('NFKC').trim().split(/\s+/).filter(Boolean); -} - -function matchesSearch(entry: DiagramHistoryEntry, search: string): boolean { - const haystack = [entry.title, entry.sourcePath, entry.artifactPath, entry.intent, entry.sourceFormat] - .filter(Boolean) - .join(' ') - .toLocaleLowerCase() - .normalize('NFKC'); - return normalizedTokens(search).every(token => haystack.includes(token)); -} - -function queryEntries(entries: DiagramHistoryEntry[], query: DiagramHistoryQuery): DiagramHistoryPage { - const pageSize = Math.max(1, Math.floor(query.pageSize ?? 20)); - const page = Math.max(1, Math.floor(query.page ?? 1)); - const filtered = entries - .filter(entry => !query.search || matchesSearch(entry, query.search)) - .filter(entry => !query.intent || entry.intent === query.intent) - .filter(entry => !query.sourceFormat || entry.sourceFormat === query.sourceFormat) - .filter(entry => !query.exportKind || Boolean(entry.exportPaths[query.exportKind])) - .filter(entry => !query.sourcePath || entry.sourcePath === query.sourcePath) - .filter(entry => query.completedFrom === undefined || entry.completedAt >= query.completedFrom) - .filter(entry => query.completedTo === undefined || entry.completedAt <= query.completedTo) - .sort((left, right) => right.completedAt - left.completedAt); - const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); - const resolvedPage = Math.min(page, totalPages); - const start = (resolvedPage - 1) * pageSize; - return { - items: filtered.slice(start, start + pageSize).map(cloneEntry), - page: resolvedPage, - pageSize, - totalItems: filtered.length, - totalPages - }; -} export function createDiagramHistoryRepository(load: LoadEntries, save: SaveEntries, retentionLimit = 500) { let writeQueue = Promise.resolve(); @@ -90,17 +16,17 @@ export function createDiagramHistoryRepository(load: LoadEntries, save: SaveEntr async recordCompleted(entry: DiagramHistoryEntry): Promise { await write(async () => { const entries = (await load()).filter(existing => existing.id !== entry.id); - entries.push(cloneEntry(entry)); + entries.push(cloneDiagramHistoryEntry(entry)); entries.sort((left, right) => right.completedAt - left.completedAt); - await save(entries.slice(0, Math.max(1, retentionLimit)).map(cloneEntry)); + await save(entries.slice(0, Math.max(1, retentionLimit)).map(cloneDiagramHistoryEntry)); }); }, async query(query: DiagramHistoryQuery = {}): Promise { - return queryEntries(await load(), query); + return queryDiagramHistoryEntries(await load(), query); }, async get(id: string): Promise { const entry = (await load()).find(candidate => candidate.id === id); - return entry ? cloneEntry(entry) : null; + return entry ? cloneDiagramHistoryEntry(entry) : null; }, async recordArtifactPath(id: string, artifactPath: string): Promise { let updated = false; @@ -110,7 +36,7 @@ export function createDiagramHistoryRepository(load: LoadEntries, save: SaveEntr if (!entry) return; entry.artifactPath = artifactPath; updated = true; - await save(entries.map(cloneEntry)); + await save(entries.map(cloneDiagramHistoryEntry)); }); return updated; }, @@ -122,7 +48,7 @@ export function createDiagramHistoryRepository(load: LoadEntries, save: SaveEntr if (!entry) return; entry.exportPaths = { ...entry.exportPaths, [kind]: exportPath }; updated = true; - await save(entries.map(cloneEntry)); + await save(entries.map(cloneDiagramHistoryEntry)); }); return updated; }, @@ -133,7 +59,7 @@ export function createDiagramHistoryRepository(load: LoadEntries, save: SaveEntr const remaining = entries.filter(entry => entry.id !== id); removed = remaining.length !== entries.length; if (removed) { - await save(remaining.map(cloneEntry)); + await save(remaining.map(cloneDiagramHistoryEntry)); } }); return removed; diff --git a/src/tests/diagramHistoryRepository.test.ts b/src/tests/diagramHistoryRepository.test.ts index ef365fb0..fb3ff39e 100644 --- a/src/tests/diagramHistoryRepository.test.ts +++ b/src/tests/diagramHistoryRepository.test.ts @@ -2,6 +2,7 @@ import { createDiagramHistoryRepository, DiagramHistoryEntry } from '../diagram/history/diagramHistoryRepository'; +import { queryDiagramHistoryEntries } from '../diagram/history/diagramHistoryQuery'; function entry(id: string, completedAt: number, overrides: Partial = {}): DiagramHistoryEntry { return { @@ -19,6 +20,10 @@ function entry(id: string, completedAt: number, overrides: Partial { + test('exposes storage-independent history querying', () => { + const page = queryDiagramHistoryEntries([entry('older', 1), entry('newer', 2)], { pageSize: 20 }); + expect(page.items.map(item => item.id)).toEqual(['newer', 'older']); + }); test('queries Vault history newest first with fuzzy text, filters, and pagination', async () => { let stored = [ entry('old', 10, { title: 'Quarterly Revenue Flow' }), diff --git a/src/tests/settingCatalog.test.ts b/src/tests/settingCatalog.test.ts index 00d2197d..b266e243 100644 --- a/src/tests/settingCatalog.test.ts +++ b/src/tests/settingCatalog.test.ts @@ -1,5 +1,6 @@ import { searchSettingCatalog, SettingCatalogEntry } from '../ui/settings/settingSearch'; import { createLocalizedSettingIdResolver, retainKnownSettingIds } from '../ui/settings/settingCatalog'; +import { resolveSettingsNavigation } from '../ui/settings/SettingsNavigation'; const entries: SettingCatalogEntry[] = [ { id: 'diagrams.export-ppi', categoryId: 'diagrams', name: 'Image export PPI', description: 'Controls PNG and PDF clarity.', aliases: ['resolution', 'dpi'] }, @@ -47,3 +48,15 @@ describe('localized setting identity', () => { )).toEqual(['settings.model', 'settings.timeout']); }); }); + +describe('settings navigation state', () => { + test('combines fuzzy search and favorites while reporting visible categories', () => { + const result = resolveSettingsNavigation(entries, { + query: 'model', favoritesOnly: true, favoriteIds: new Set(['providers.active', 'batch.parallelism']) + }); + expect([...result.visibleIds]).toEqual(['providers.active']); + expect([...result.visibleCategoryIds]).toEqual(['providers']); + expect(result.visibleCount).toBe(1); + expect(result.totalCount).toBe(3); + }); +}); diff --git a/src/ui/NotemdSettingTab.ts b/src/ui/NotemdSettingTab.ts index 53d8b8d2..2c16cda7 100644 --- a/src/ui/NotemdSettingTab.ts +++ b/src/ui/NotemdSettingTab.ts @@ -44,7 +44,8 @@ import { UI_LOCALE_AUTO } from '../i18n/languageContext'; import { SUPPORTED_UI_LOCALES } from '../i18n/uiLocales'; import { formatI18n, getI18nStrings } from '../i18n'; import { createLocalizedSettingIdResolver, retainKnownSettingIds } from './settings/settingCatalog'; -import { searchSettingCatalog, SettingCatalogEntry } from './settings/settingSearch'; +import { SettingCatalogEntry } from './settings/settingSearch'; +import { resolveSettingsNavigation } from './settings/SettingsNavigation'; import { runProviderConnectionTestWithHost } from '../operations/providerConnectionTestCommandHostAdapter'; import { getFolderTaskFileSelectionProfiles, getFolderTaskRegexValidationError } from '../folderTaskFileSelector'; import { DiscoveredProviderModel, discoverProviderModelsDetailed } from '../providerModelDiscovery'; @@ -114,6 +115,7 @@ export class NotemdSettingTab extends PluginSettingTab { favoritesButton.type = 'button'; let favoritesOnly = false; const navigation = header.createDiv({ cls: 'notemd-settings-category-navigation' }); + const categoryButtons = new Map(); const resultCount = header.createDiv({ cls: 'notemd-settings-result-count' }); resultCount.setAttribute('aria-live', 'polite'); const emptyState = header.createDiv({ cls: 'notemd-settings-empty-state', text: copy.noResults }); @@ -125,18 +127,21 @@ export class NotemdSettingTab extends PluginSettingTab { const button = navigation.createEl('button', { text: label }); button.type = 'button'; button.onclick = () => heading.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const settingIndex = settingItems.indexOf(heading); + if (settingIndex >= 0) categoryButtons.set(catalog[settingIndex].id, button); }); const applyFilter = () => { - const matchedIds = new Set(searchSettingCatalog(catalog, search.value).map(entry => entry.id)); - let visibleCount = 0; + const navigationState = resolveSettingsNavigation(catalog, { query: search.value, favoritesOnly, favoriteIds: favorites }); settingItems.forEach((item, index) => { const settingId = catalog[index].id; - const hidden = !matchedIds.has(settingId) || (favoritesOnly && !favorites.has(settingId)); + const hidden = !navigationState.visibleIds.has(settingId); item.toggleAttribute('hidden', hidden); - if (!hidden) visibleCount += 1; }); - resultCount.setText(formatI18n(copy.resultCount, { visible: visibleCount, total: settingItems.length })); - emptyState.hidden = visibleCount !== 0; + categoryButtons.forEach((button, categoryId) => { + button.hidden = !navigationState.visibleCategoryIds.has(categoryId); + }); + resultCount.setText(formatI18n(copy.resultCount, { visible: navigationState.visibleCount, total: navigationState.totalCount })); + emptyState.hidden = navigationState.visibleCount !== 0; }; settingItems.forEach((item, index) => { const settingId = catalog[index].id; diff --git a/src/ui/settings/SettingsNavigation.ts b/src/ui/settings/SettingsNavigation.ts new file mode 100644 index 00000000..67ea17ee --- /dev/null +++ b/src/ui/settings/SettingsNavigation.ts @@ -0,0 +1,28 @@ +import { searchSettingCatalog, SettingCatalogEntry } from './settingSearch'; + +export interface SettingsNavigationState { + query: string; + favoritesOnly: boolean; + favoriteIds: ReadonlySet; +} + +export interface SettingsNavigationResult { + visibleIds: ReadonlySet; + visibleCount: number; + totalCount: number; + visibleCategoryIds: ReadonlySet; +} + +export function resolveSettingsNavigation( + catalog: readonly SettingCatalogEntry[], + state: SettingsNavigationState +): SettingsNavigationResult { + const matches = searchSettingCatalog([...catalog], state.query) + .filter(entry => !state.favoritesOnly || state.favoriteIds.has(entry.id)); + return { + visibleIds: new Set(matches.map(entry => entry.id)), + visibleCount: matches.length, + totalCount: catalog.length, + visibleCategoryIds: new Set(matches.map(entry => entry.categoryId)) + }; +}