fix(settings): refine plugin run scope tree UI

中文: 将范围设置统一为插件运行范围,并把文件夹选择区调整为对齐稳定的 Finder 式树形 UI。

English: Renames the scope settings to plugin run scope and refines the folder selector into a stable Finder-style tree UI.
This commit is contained in:
Dusk 2026-04-26 08:50:51 +08:00
parent 354e25b4ae
commit 23b9b83527
7 changed files with 327 additions and 24 deletions

View file

@ -0,0 +1,89 @@
# Plugin Run Scope UI Decisions
## 1. 最终文案决策
范围卡最终统一为:
1. 中文卡片标题:`插件运行范围`
2. 英文卡片标题:`Plugin run scope`
3. 中文范围下拉名称:`插件运行范围`
4. 英文范围下拉名称:`Plugin run scope`
范围卡顶部旧说明不再渲染。
## 2. 文件夹树行布局决策
每个树行固定为三列:
1. 展开槽
2. checkbox
3. 文件夹名
布局固定为:
1. `display: grid`
2. `grid-template-columns: 24px 24px minmax(0, 1fr)`
3. `align-items: center`
4. 固定最小高度
缩进只由 `depth * 18px` 控制,使用行容器的左边距实现。
## 3. 展开槽与文件夹名决策
展开槽规则固定为:
1. 有子节点时用轻量按钮
2. 无子节点时仍渲染占位元素
3. 两种情况下都保留 `folderToggle` dataset
4. 展开符号继续使用 `▸ / ▾`
文件夹名规则固定为:
1. 单行显示
2. `overflow: hidden`
3. `text-overflow: ellipsis`
4. `white-space: nowrap`
## 4. 已选祖先自动展开策略
补充展开已选祖先的时机固定为:
1. 文件夹树首次成功加载后
2. 作用范围切换到 `include` / `exclude`
3. 手动刷新文件夹树后
4. 设置页关闭后再次进入时
不在以下场景强制重置:
1. 用户手动点击展开/折叠后的普通重渲染
2. 勾选/取消勾选文件夹后的普通重渲染
实现方式是一次性“待补充展开”标志位,而不是每次 render 都强行根据已选项重算展开状态。
## 5. refresh 行为决策
`refreshFolderTree()` 保持现有语义:
1. 清空旧展开状态
2. 重新加载树
3. 加载完成后再次补充展开已选祖先
## 6. 保持不变的内容
本次明确保持不变:
1. `all / include / exclude` 的业务含义
2. 设置字段名和枚举值
3. `FolderScopeTree` 的选择压缩算法
4. 作用范围对同步逻辑的影响
5. 设置页局部刷新机制
6. 不新增 CSS 构建链路
## 7. 与计划的显式偏差
没有行为偏差。
仅有一个实现级说明:
1. 当前仓库已经有安全的 built-plugin sync 脚本
2. 因此最终同步继续复用 `build:obsidian` 和现有脚本,而不是再引入新的同步实现

View file

@ -0,0 +1,103 @@
# Plugin Run Scope UI Gap Report
## 审查范围
本次按真实仓库实现审查了以下位置:
1. [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)
2. [src/presentation/i18n/messages/zh.ts](src/presentation/i18n/messages/zh.ts)
3. [src/presentation/i18n/messages/en.ts](src/presentation/i18n/messages/en.ts)
4. [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts)
5. [src/presentation/settings/FolderScopeTree.test.ts](src/presentation/settings/FolderScopeTree.test.ts)
6. [scripts/sync-plugin-dist.mjs](scripts/sync-plugin-dist.mjs)
7. [scripts/obsidian-plugin-path.mjs](scripts/obsidian-plugin-path.mjs)
## 当前实现确认
### 1. 范围卡文案仍是旧版本
当前范围卡使用:
1. 卡片标题 `卡片同步范围`
2. 范围下拉名称 `运行范围`
3. 顶部说明 `保留现有作用范围和文件夹树逻辑;文件夹树按需加载,并支持局部刷新。`
与本轮目标的 `插件运行范围` 不一致。
### 2. 范围卡顶部说明目前仍会渲染
`renderScopeCard()` 当前第一行就是:
1. `containerEl.createEl("p", { text: t("settings.cards.scope.desc") })`
这会直接把旧说明文案渲染到页面上。
### 3. 当前文件夹树行结构还不是固定三列
`renderFolderNode()` 当前实现是:
1. 行容器只用 `paddingLeft = depth * 18px`
2. 行内依次直接放 toggle、checkbox、label
3. 没有固定宽度的展开槽
4. 没有稳定的三列 grid
5. label 也没有单行省略样式
结果是:
1. 无子节点时展开槽不具备明确占位样式
2. checkbox 对齐依赖默认 inline 布局
3. 长文件夹名可能撑乱排版
### 4. 当前没有“已选祖先自动展开”逻辑
当前展开状态只来自:
1. `expandedFolderPaths`
2. 用户点击 `folderToggle`
当前没有:
1. 初次加载树后自动展开已选项祖先
2. 切换到 include / exclude 后补充展开已选祖先
3. refresh 后重新展开已选祖先
因此如果 `includeFolders = ["notes/sub"]`,打开范围卡后 `notes` 不会自动展开。
### 5. 现有局部刷新行为已经满足复用条件
真实代码已经具备以下可复用行为:
1. `refreshFolderTree()` 只重渲染 scope card
2. `updateScopeMode()` 只重渲染 scope card
3. `updateFolderSelection()` 只重渲染 scope card
4. `FolderScopeTree.test.ts` 已经覆盖选择压缩算法
这意味着本次只需要改 UI 结构与补充展开策略,不需要动选择压缩逻辑。
### 6. build/sync 脚本当前已满足目标目录安全要求
当前 [scripts/sync-plugin-dist.mjs](scripts/sync-plugin-dist.mjs) 只会复制:
1. `dist/plugin/main.js`
2. `dist/plugin/manifest.json`
3. `dist/plugin/styles.css`(如果存在)
不会删除目标目录文件,也不会触碰 `data.json`
## 建议的最小落点
1. 只改 `PluginSettingTab.ts` 的范围卡渲染与树行样式
2. 更新 `settings.cards.scope.title``settings.scope.name`
3. 删除范围卡顶部说明的渲染,但不改业务逻辑
4. 给树行改为固定三列 grid并保留现有 dataset
5. 增加“按需补充展开已选祖先”的一次性标志位
6. 在 `PluginSettingTab.test.ts` 补标题、旧说明消失、树行对齐、已选祖先展开测试
## 与计划的显式偏差
没有产品级偏差。
唯一需要记录的是实现层面的仓库现实:
1. 当前 sync 脚本已经是安全复制 built plugin 文件的版本
2. 因此本轮不需要再改 build/sync 脚本,只需要继续按它的产物约束验证和同步

26
main.js

File diff suppressed because one or more lines are too long

View file

@ -191,7 +191,7 @@ export const en = {
priorityDesc: "Final deck priority: file-level custom deck > folder-mapped deck > default deck. Existing-card deck behavior stays aligned with the current sync implementation.",
},
scope: {
name: "Run scope",
name: "Plugin run scope",
summary: {
all: "Process Markdown files across the whole vault",
include: "Only process Markdown files in the checked folders below",
@ -284,7 +284,7 @@ export const en = {
},
},
scope: {
title: "Card sync scope",
title: "Plugin run scope",
desc: "Keep the existing scope modes and folder tree. The tree is loaded lazily and can be refreshed locally.",
refreshFolders: "Refresh folder list",
},

View file

@ -189,7 +189,7 @@ export const zh = {
priorityDesc: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡的 deck 行为保持与当前同步实现一致。",
},
scope: {
name: "运行范围",
name: "插件运行范围",
summary: {
all: "处理整个 vault 中的 Markdown 文件",
include: "仅处理下方勾选文件夹中的 Markdown 文件",
@ -282,7 +282,7 @@ export const zh = {
},
},
scope: {
title: "卡片同步范围",
title: "插件运行范围",
desc: "保留现有作用范围和文件夹树逻辑;文件夹树按需加载,并支持局部刷新。",
refreshFolders: "刷新文件夹列表",
},

View file

@ -1292,6 +1292,45 @@ describe("PluginSettingTab", () => {
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 03 组缺少答案字段");
});
it("renders the scope card with renamed copy and aligned folder tree rows", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
expect(queryByDataset(tab.containerEl, "settingsCardToggle", "scope").textContent).toContain("插件运行范围");
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(findSetting(tab.containerEl, "插件运行范围")).toBeDefined();
expect(() => findSetting(tab.containerEl, "运行范围")).toThrow("Setting not found");
expect(collectTexts(tab.containerEl)).not.toContain("保留现有作用范围和文件夹树逻辑;文件夹树按需加载,并支持局部刷新。");
const parentRow = queryByDataset(tab.containerEl, "folderRow", "notes");
const parentToggle = queryByDataset(tab.containerEl, "folderToggle", "notes");
const childRow = queryByDataset(tab.containerEl, "folderRow", "notes/sub");
const childToggle = queryByDataset(tab.containerEl, "folderToggle", "notes/sub");
const childCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes/sub");
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
expect(parentToggle.textContent).toBe("▾");
expect(parentRow.style.display).toBe("grid");
expect(parentRow.style.gridTemplateColumns).toBe("24px 24px minmax(0, 1fr)");
expect(parentRow.style.alignItems).toBe("center");
expect(childRow.style.marginLeft).toBe("18px");
expect(childToggle.style.width).toBe("24px");
expect(childToggle.style.height).toBe("24px");
expect(childCheckbox).toBeDefined();
expect(childLabel.style.whiteSpace).toBe("nowrap");
expect(childLabel.style.overflow).toBe("hidden");
expect(childLabel.style.textOverflow).toBe("ellipsis");
});
it("folder tree expand and check refresh only the scope card", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
@ -1307,6 +1346,9 @@ describe("PluginSettingTab", () => {
await flushPromises();
expect(plugin.listFolderTreeCalls).toBe(1);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
expect(queryByDataset(tab.containerEl, "folderRow", "notes")).toBeDefined();
expect(queryByDataset(tab.containerEl, "folderToggle", "notes")).toBeDefined();
expect(queryByDataset(tab.containerEl, "folderPathLabel", "notes")).toBeDefined();
await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click");
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);

View file

@ -82,6 +82,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private detectedAnkiNoteTypeCache: string[] | null = null;
private noteTypeCacheCheckToken = 0;
private pageHeaderResizeObserver: ResizeObserver | null = null;
private scopeNeedsSelectedAncestorExpansion = true;
constructor(plugin: AnkiHeadingSyncPlugin) {
super(plugin.app, plugin);
@ -101,6 +102,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.noteTypeCacheCheckPromise = null;
this.detectedAnkiNoteTypeCache = null;
this.noteTypeCacheCheckToken += 1;
this.scopeNeedsSelectedAncestorExpansion = true;
}
display(): void {
@ -593,8 +595,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private renderScopeCard(containerEl: HTMLElement): void {
containerEl.createEl("p", { text: t("settings.cards.scope.desc") });
new Setting(containerEl)
.setName(t("settings.scope.name"))
.setDesc(getScopeModeSummary(this.plugin.settings.scopeMode))
@ -634,12 +634,15 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return;
}
const selectedFolders = this.plugin.settings.scopeMode === "include"
? this.plugin.settings.includeFolders
: this.plugin.settings.excludeFolders;
this.expandSelectedFolderAncestorsIfNeeded(this.plugin.settings.scopeMode);
const selectedFolders = this.getSelectedFoldersForScopeMode(this.plugin.settings.scopeMode);
const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders);
const treeContainer = containerEl.createDiv();
treeContainer.dataset.scopeTree = "true";
treeContainer.style.display = "flex";
treeContainer.style.flexDirection = "column";
treeContainer.style.gap = "2px";
for (const node of selectionTree) {
this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0);
@ -1329,6 +1332,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.then((folderTree) => {
this.folderTree = folderTree;
this.folderTreeStatus = folderTree.length > 0 ? { rawMessage: "" } : { key: "settings.scope.empty" };
this.requestSelectedFolderAncestorExpansion();
})
.catch((error) => {
this.folderTree = [];
@ -1350,6 +1354,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private async updateScopeMode(scopeMode: ScopeMode): Promise<void> {
await this.plugin.updateSettings({ scopeMode });
if (scopeMode !== "all") {
this.requestSelectedFolderAncestorExpansion();
this.ensureFolderTreeLoaded();
}
this.renderCard("scope");
@ -1366,14 +1371,36 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
const row = containerEl.createDiv();
row.dataset.folderRow = node.path;
row.style.paddingLeft = `${depth * 18}px`;
row.style.display = "grid";
row.style.gridTemplateColumns = "24px 24px minmax(0, 1fr)";
row.style.alignItems = "center";
row.style.columnGap = "0px";
row.style.minHeight = "28px";
row.style.marginLeft = `${depth * 18}px`;
row.style.width = "100%";
row.style.boxSizing = "border-box";
const hasChildren = node.children.length > 0;
const expanded = hasChildren && this.expandedFolderPaths.has(node.path);
const toggleControl = row.createEl(hasChildren ? "button" : "span");
toggleControl.dataset.folderToggle = node.path;
toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : "";
toggleControl.style.width = "24px";
toggleControl.style.height = "24px";
toggleControl.style.display = "inline-flex";
toggleControl.style.alignItems = "center";
toggleControl.style.justifyContent = "center";
toggleControl.style.padding = "0";
toggleControl.style.margin = "0";
toggleControl.style.border = "0";
toggleControl.style.background = "transparent";
toggleControl.style.boxShadow = "none";
toggleControl.style.lineHeight = "1";
toggleControl.style.color = "var(--text-muted)";
if (hasChildren) {
toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
toggleControl.setAttr("title", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
toggleControl.style.cursor = "pointer";
toggleControl.addEventListener("click", () => {
if (this.expandedFolderPaths.has(node.path)) {
this.expandedFolderPaths.delete(node.path);
@ -1390,11 +1417,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
checkbox.checked = node.checked;
checkbox.indeterminate = node.indeterminate;
checkbox.dataset.folderPath = node.path;
checkbox.style.margin = "0";
checkbox.style.justifySelf = "center";
checkbox.addEventListener("change", () => {
void this.updateFolderSelection(scopeMode, node.path, checkbox.checked);
});
row.createEl("span", { text: node.name }).dataset.folderPathLabel = node.path;
const labelEl = row.createEl("span", { text: node.name });
labelEl.dataset.folderPathLabel = node.path;
labelEl.style.minWidth = "0";
labelEl.style.overflow = "hidden";
labelEl.style.textOverflow = "ellipsis";
labelEl.style.whiteSpace = "nowrap";
if (!hasChildren || !expanded) {
return;
@ -1402,11 +1436,35 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const childrenContainer = containerEl.createDiv();
childrenContainer.dataset.folderChildren = node.path;
childrenContainer.style.display = "flex";
childrenContainer.style.flexDirection = "column";
childrenContainer.style.gap = "2px";
for (const child of node.children) {
this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1);
}
}
private requestSelectedFolderAncestorExpansion(): void {
this.scopeNeedsSelectedAncestorExpansion = true;
}
private expandSelectedFolderAncestorsIfNeeded(scopeMode: ScopeMode): void {
if (!this.scopeNeedsSelectedAncestorExpansion) {
return;
}
this.scopeNeedsSelectedAncestorExpansion = false;
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) {
for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
this.expandedFolderPaths.add(ancestorPath);
}
}
}
private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] {
return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
}
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label");
groupEl.style.display = "inline-flex";
@ -1616,3 +1674,14 @@ function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
return t("settings.scope.treeDescription.exclude");
}
function getAncestorFolderPaths(folderPath: string): string[] {
const segments = folderPath.split("/").filter((segment) => segment.length > 0);
const ancestorPaths: string[] = [];
for (let index = 1; index < segments.length; index += 1) {
ancestorPaths.push(segments.slice(0, index).join("/"));
}
return ancestorPaths;
}