refactor(ui): separate history query and navigation state

This commit is contained in:
aliyun1121003339 2026-07-11 23:30:05 +08:00
parent 8c32069f6b
commit 97af9b3e96
8 changed files with 139 additions and 92 deletions

View file

@ -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`.

View file

@ -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`。

View file

@ -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<Record<DiagramHistoryExportKind, string>>;
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 };
}

View file

@ -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<Record<DiagramHistoryExportKind, string>>;
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<DiagramHistoryEntry[]>;
type SaveEntries = (entries: DiagramHistoryEntry[]) => Promise<void>;
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<void> {
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<DiagramHistoryPage> {
return queryEntries(await load(), query);
return queryDiagramHistoryEntries(await load(), query);
},
async get(id: string): Promise<DiagramHistoryEntry | null> {
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<boolean> {
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;

View file

@ -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> = {}): DiagramHistoryEntry {
return {
@ -19,6 +20,10 @@ function entry(id: string, completedAt: number, overrides: Partial<DiagramHistor
}
describe('diagram history repository', () => {
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' }),

View file

@ -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);
});
});

View file

@ -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<string, HTMLButtonElement>();
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;

View file

@ -0,0 +1,28 @@
import { searchSettingCatalog, SettingCatalogEntry } from './settingSearch';
export interface SettingsNavigationState {
query: string;
favoritesOnly: boolean;
favoriteIds: ReadonlySet<string>;
}
export interface SettingsNavigationResult {
visibleIds: ReadonlySet<string>;
visibleCount: number;
totalCount: number;
visibleCategoryIds: ReadonlySet<string>;
}
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))
};
}