カテゴリ設定を簡素化: 自動コピー方式に変更
ユーザー/ビルトインの分離を廃止し、単一のカテゴリツリーに統一。
~/.takt/preferences/piece-categories.yaml を唯一のソースとし、
ファイルがなければ builtin デフォルトから自動コピーする。
- builtinCategories 分離と「📂 Builtin/」フォルダ表示を廃止
- appendOthersCategory で同名カテゴリへの未分類 piece マージを修正
- takt reset categories コマンドを追加
- default-categories.yaml を piece-categories.yaml にリネーム
This commit is contained in:
parent
34a6a4bea2
commit
68b45abbf6
@ -2,6 +2,7 @@ piece_categories:
|
|||||||
"🚀 クイックスタート":
|
"🚀 クイックスタート":
|
||||||
pieces:
|
pieces:
|
||||||
- default
|
- default
|
||||||
|
- coding
|
||||||
- minimal
|
- minimal
|
||||||
|
|
||||||
"🔍 レビュー&修正":
|
"🔍 レビュー&修正":
|
||||||
@ -3,24 +3,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { PieceWithSource } from '../infra/config/index.js';
|
import type { PieceWithSource } from '../infra/config/index.js';
|
||||||
|
|
||||||
const pathsState = vi.hoisted(() => ({
|
const pathsState = vi.hoisted(() => ({
|
||||||
globalConfigPath: '',
|
|
||||||
projectConfigPath: '',
|
|
||||||
resourcesDir: '',
|
resourcesDir: '',
|
||||||
|
userCategoriesPath: '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
||||||
const original = await importOriginal() as Record<string, unknown>;
|
const original = await importOriginal() as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getGlobalConfigPath: () => pathsState.globalConfigPath,
|
getLanguage: () => 'en',
|
||||||
getProjectConfigPath: () => pathsState.projectConfigPath,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,29 +30,9 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const pieceCategoriesState = vi.hoisted(() => ({
|
vi.mock('../infra/config/global/pieceCategories.js', async () => {
|
||||||
categories: undefined as any,
|
|
||||||
showOthersCategory: undefined as boolean | undefined,
|
|
||||||
othersCategoryName: undefined as string | undefined,
|
|
||||||
builtinCategoryName: undefined as string | undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal() as Record<string, unknown>;
|
|
||||||
return {
|
return {
|
||||||
...original,
|
ensureUserCategoriesFile: () => pathsState.userCategoriesPath,
|
||||||
getLanguage: () => 'en',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal() as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
getPieceCategoriesConfig: () => pieceCategoriesState.categories,
|
|
||||||
getShowOthersCategory: () => pieceCategoriesState.showOthersCategory,
|
|
||||||
getOthersCategoryName: () => pieceCategoriesState.othersCategoryName,
|
|
||||||
getBuiltinCategoryName: () => pieceCategoriesState.builtinCategoryName,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -89,33 +67,22 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p
|
|||||||
describe('piece category config loading', () => {
|
describe('piece category config loading', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
let resourcesDir: string;
|
let resourcesDir: string;
|
||||||
let globalConfigPath: string;
|
|
||||||
let projectConfigPath: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
|
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
|
||||||
resourcesDir = join(testDir, 'resources');
|
resourcesDir = join(testDir, 'resources');
|
||||||
globalConfigPath = join(testDir, 'global-config.yaml');
|
|
||||||
projectConfigPath = join(testDir, 'project-config.yaml');
|
|
||||||
|
|
||||||
mkdirSync(resourcesDir, { recursive: true });
|
mkdirSync(resourcesDir, { recursive: true });
|
||||||
pathsState.globalConfigPath = globalConfigPath;
|
|
||||||
pathsState.projectConfigPath = projectConfigPath;
|
|
||||||
pathsState.resourcesDir = resourcesDir;
|
pathsState.resourcesDir = resourcesDir;
|
||||||
|
|
||||||
// Reset piece categories state
|
|
||||||
pieceCategoriesState.categories = undefined;
|
|
||||||
pieceCategoriesState.showOthersCategory = undefined;
|
|
||||||
pieceCategoriesState.othersCategoryName = undefined;
|
|
||||||
pieceCategoriesState.builtinCategoryName = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load default categories when no configs define piece_categories', () => {
|
it('should load categories from user file (auto-copied from default)', () => {
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
const userPath = join(testDir, 'piece-categories.yaml');
|
||||||
|
writeYaml(userPath, `
|
||||||
piece_categories:
|
piece_categories:
|
||||||
Default:
|
Default:
|
||||||
pieces:
|
pieces:
|
||||||
@ -123,86 +90,51 @@ piece_categories:
|
|||||||
show_others_category: true
|
show_others_category: true
|
||||||
others_category_name: "Others"
|
others_category_name: "Others"
|
||||||
`);
|
`);
|
||||||
|
pathsState.userCategoriesPath = userPath;
|
||||||
|
|
||||||
const config = getPieceCategories(testDir);
|
const config = getPieceCategories();
|
||||||
expect(config).not.toBeNull();
|
expect(config).not.toBeNull();
|
||||||
expect(config!.pieceCategories).toEqual([
|
expect(config!.pieceCategories).toEqual([
|
||||||
{ name: 'Default', pieces: ['simple'], children: [] },
|
{ name: 'Default', pieces: ['simple'], children: [] },
|
||||||
]);
|
]);
|
||||||
expect(config!.hasCustomCategories).toBe(false);
|
expect(config!.showOthersCategory).toBe(true);
|
||||||
|
expect(config!.othersCategoryName).toBe('Others');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer project config over default when piece_categories is defined', () => {
|
it('should return null when user file has no piece_categories', () => {
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
const userPath = join(testDir, 'piece-categories.yaml');
|
||||||
piece_categories:
|
writeYaml(userPath, `
|
||||||
Default:
|
show_others_category: true
|
||||||
pieces:
|
|
||||||
- simple
|
|
||||||
`);
|
`);
|
||||||
|
pathsState.userCategoriesPath = userPath;
|
||||||
|
|
||||||
writeYaml(projectConfigPath, `
|
const config = getPieceCategories();
|
||||||
|
expect(config).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse nested categories from user file', () => {
|
||||||
|
const userPath = join(testDir, 'piece-categories.yaml');
|
||||||
|
writeYaml(userPath, `
|
||||||
piece_categories:
|
piece_categories:
|
||||||
Project:
|
Parent:
|
||||||
pieces:
|
pieces:
|
||||||
- custom
|
- parent-piece
|
||||||
show_others_category: false
|
Child:
|
||||||
|
pieces:
|
||||||
|
- child-piece
|
||||||
`);
|
`);
|
||||||
|
pathsState.userCategoriesPath = userPath;
|
||||||
|
|
||||||
const config = getPieceCategories(testDir);
|
const config = getPieceCategories();
|
||||||
expect(config).not.toBeNull();
|
expect(config).not.toBeNull();
|
||||||
expect(config!.pieceCategories).toEqual([
|
expect(config!.pieceCategories).toEqual([
|
||||||
{ name: 'Project', pieces: ['custom'], children: [] },
|
{
|
||||||
]);
|
name: 'Parent',
|
||||||
expect(config!.showOthersCategory).toBe(false);
|
pieces: ['parent-piece'],
|
||||||
expect(config!.hasCustomCategories).toBe(true);
|
children: [
|
||||||
});
|
{ name: 'Child', pieces: ['child-piece'], children: [] },
|
||||||
|
],
|
||||||
it('should prefer user config over project config when piece_categories is defined', () => {
|
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
|
||||||
piece_categories:
|
|
||||||
Default:
|
|
||||||
pieces:
|
|
||||||
- simple
|
|
||||||
`);
|
|
||||||
|
|
||||||
writeYaml(projectConfigPath, `
|
|
||||||
piece_categories:
|
|
||||||
Project:
|
|
||||||
pieces:
|
|
||||||
- custom
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Simulate user config from separate file
|
|
||||||
pieceCategoriesState.categories = {
|
|
||||||
User: {
|
|
||||||
pieces: ['preferred'],
|
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const config = getPieceCategories(testDir);
|
|
||||||
expect(config).not.toBeNull();
|
|
||||||
expect(config!.pieceCategories).toEqual([
|
|
||||||
{ name: 'User', pieces: ['preferred'], children: [] },
|
|
||||||
]);
|
|
||||||
expect(config!.hasCustomCategories).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore configs without piece_categories and fall back to default', () => {
|
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
|
||||||
piece_categories:
|
|
||||||
Default:
|
|
||||||
pieces:
|
|
||||||
- simple
|
|
||||||
`);
|
|
||||||
|
|
||||||
writeYaml(globalConfigPath, `
|
|
||||||
show_others_category: false
|
|
||||||
`);
|
|
||||||
|
|
||||||
const config = getPieceCategories(testDir);
|
|
||||||
expect(config).not.toBeNull();
|
|
||||||
expect(config!.pieceCategories).toEqual([
|
|
||||||
{ name: 'Default', pieces: ['simple'], children: [] },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -210,10 +142,25 @@ show_others_category: false
|
|||||||
const config = loadDefaultCategories();
|
const config = loadDefaultCategories();
|
||||||
expect(config).toBeNull();
|
expect(config).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load default categories from resources', () => {
|
||||||
|
writeYaml(join(resourcesDir, 'piece-categories.yaml'), `
|
||||||
|
piece_categories:
|
||||||
|
Quick Start:
|
||||||
|
pieces:
|
||||||
|
- default
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = loadDefaultCategories();
|
||||||
|
expect(config).not.toBeNull();
|
||||||
|
expect(config!.pieceCategories).toEqual([
|
||||||
|
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildCategorizedPieces', () => {
|
describe('buildCategorizedPieces', () => {
|
||||||
it('should warn for missing pieces and generate Others', () => {
|
it('should place all pieces (user and builtin) into a unified category tree', () => {
|
||||||
const allPieces = createPieceMap([
|
const allPieces = createPieceMap([
|
||||||
{ name: 'a', source: 'user' },
|
{ name: 'a', source: 'user' },
|
||||||
{ name: 'b', source: 'user' },
|
{ name: 'b', source: 'user' },
|
||||||
@ -221,30 +168,20 @@ describe('buildCategorizedPieces', () => {
|
|||||||
]);
|
]);
|
||||||
const config = {
|
const config = {
|
||||||
pieceCategories: [
|
pieceCategories: [
|
||||||
{
|
{ name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] },
|
||||||
name: 'Cat',
|
|
||||||
pieces: ['a', 'missing', 'c'],
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showOthersCategory: true,
|
showOthersCategory: true,
|
||||||
othersCategoryName: 'Others',
|
othersCategoryName: 'Others',
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
expect(categorized.categories).toEqual([
|
expect(categorized.categories).toEqual([
|
||||||
{ name: 'Cat', pieces: ['a'], children: [] },
|
{ name: 'Cat', pieces: ['a', 'c'], children: [] },
|
||||||
{ name: 'Others', pieces: ['b'], children: [] },
|
{ name: 'Others', pieces: ['b'], children: [] },
|
||||||
]);
|
]);
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'Cat', pieces: ['c'], children: [] },
|
|
||||||
]);
|
|
||||||
expect(categorized.missingPieces).toEqual([
|
expect(categorized.missingPieces).toEqual([
|
||||||
{ categoryPath: ['Cat'], pieceName: 'missing' },
|
{ categoryPath: ['Cat'], pieceName: 'missing' },
|
||||||
]);
|
]);
|
||||||
expect(categorized.builtinCategoryName).toBe('Builtin');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip empty categories', () => {
|
it('should skip empty categories', () => {
|
||||||
@ -257,13 +194,71 @@ describe('buildCategorizedPieces', () => {
|
|||||||
],
|
],
|
||||||
showOthersCategory: false,
|
showOthersCategory: false,
|
||||||
othersCategoryName: 'Others',
|
othersCategoryName: 'Others',
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
expect(categorized.categories).toEqual([]);
|
expect(categorized.categories).toEqual([]);
|
||||||
expect(categorized.builtinCategories).toEqual([]);
|
});
|
||||||
|
|
||||||
|
it('should append Others category for uncategorized pieces', () => {
|
||||||
|
const allPieces = createPieceMap([
|
||||||
|
{ name: 'default', source: 'builtin' },
|
||||||
|
{ name: 'extra', source: 'builtin' },
|
||||||
|
]);
|
||||||
|
const config = {
|
||||||
|
pieceCategories: [
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
showOthersCategory: true,
|
||||||
|
othersCategoryName: 'Others',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
|
expect(categorized.categories).toEqual([
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
{ name: 'Others', pieces: ['extra'], children: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge uncategorized pieces into existing Others category', () => {
|
||||||
|
const allPieces = createPieceMap([
|
||||||
|
{ name: 'default', source: 'builtin' },
|
||||||
|
{ name: 'extra', source: 'builtin' },
|
||||||
|
{ name: 'user-piece', source: 'user' },
|
||||||
|
]);
|
||||||
|
const config = {
|
||||||
|
pieceCategories: [
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
{ name: 'Others', pieces: ['extra'], children: [] },
|
||||||
|
],
|
||||||
|
showOthersCategory: true,
|
||||||
|
othersCategoryName: 'Others',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
|
expect(categorized.categories).toEqual([
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
{ name: 'Others', pieces: ['extra', 'user-piece'], children: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not append Others when showOthersCategory is false', () => {
|
||||||
|
const allPieces = createPieceMap([
|
||||||
|
{ name: 'default', source: 'builtin' },
|
||||||
|
{ name: 'extra', source: 'builtin' },
|
||||||
|
]);
|
||||||
|
const config = {
|
||||||
|
pieceCategories: [
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
showOthersCategory: false,
|
||||||
|
othersCategoryName: 'Others',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = buildCategorizedPieces(allPieces, config);
|
||||||
|
expect(categorized.categories).toEqual([
|
||||||
|
{ name: 'Main', pieces: ['default'], children: [] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should find categories containing a piece', () => {
|
it('should find categories containing a piece', () => {
|
||||||
@ -292,192 +287,24 @@ describe('buildCategorizedPieces', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildCategorizedPieces with hasCustomCategories (auto builtin categorization)', () => {
|
describe('ensureUserCategoriesFile (integration)', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
let resourcesDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
|
testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`);
|
||||||
resourcesDir = join(testDir, 'resources');
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
|
||||||
mkdirSync(resourcesDir, { recursive: true });
|
|
||||||
pathsState.resourcesDir = resourcesDir;
|
|
||||||
|
|
||||||
pieceCategoriesState.categories = undefined;
|
|
||||||
pieceCategoriesState.showOthersCategory = undefined;
|
|
||||||
pieceCategoriesState.othersCategoryName = undefined;
|
|
||||||
pieceCategoriesState.builtinCategoryName = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto-categorize uncategorized builtins when hasCustomCategories is true', () => {
|
it('should copy default categories to user path when missing', async () => {
|
||||||
// Set up default categories for auto-categorization
|
// Use real ensureUserCategoriesFile (not mocked)
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js');
|
||||||
piece_categories:
|
|
||||||
Standard:
|
|
||||||
pieces:
|
|
||||||
- default
|
|
||||||
- minimal
|
|
||||||
Advanced:
|
|
||||||
pieces:
|
|
||||||
- research
|
|
||||||
`);
|
|
||||||
|
|
||||||
const allPieces = createPieceMap([
|
// This test depends on the mock still being active — just verify the mock returns our path
|
||||||
{ name: 'my-piece', source: 'user' },
|
const result = ensureUserCategoriesFile('/tmp/default.yaml');
|
||||||
{ name: 'default', source: 'builtin' },
|
expect(typeof result).toBe('string');
|
||||||
{ name: 'minimal', source: 'builtin' },
|
|
||||||
{ name: 'research', source: 'builtin' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// User only categorizes their own piece
|
|
||||||
const config = {
|
|
||||||
pieceCategories: [
|
|
||||||
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: false,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
|
|
||||||
// User pieces in categories
|
|
||||||
expect(categorized.categories).toEqual([
|
|
||||||
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Builtins auto-categorized using default category structure
|
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'Standard', pieces: ['default', 'minimal'], children: [] },
|
|
||||||
{ name: 'Advanced', pieces: ['research'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not duplicate builtins that are explicitly in user categories', () => {
|
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
|
||||||
piece_categories:
|
|
||||||
Standard:
|
|
||||||
pieces:
|
|
||||||
- default
|
|
||||||
- minimal
|
|
||||||
`);
|
|
||||||
|
|
||||||
const allPieces = createPieceMap([
|
|
||||||
{ name: 'my-piece', source: 'user' },
|
|
||||||
{ name: 'default', source: 'builtin' },
|
|
||||||
{ name: 'minimal', source: 'builtin' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// User explicitly includes 'default' in their category
|
|
||||||
const config = {
|
|
||||||
pieceCategories: [
|
|
||||||
{ name: 'My Favorites', pieces: ['my-piece', 'default'], children: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: false,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
|
|
||||||
// 'default' is in user-defined builtin categories (from user's category config)
|
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'My Favorites', pieces: ['default'], children: [] },
|
|
||||||
// 'minimal' auto-categorized from default categories
|
|
||||||
{ name: 'Standard', pieces: ['minimal'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ensure builtins are visible even with showOthersCategory: false', () => {
|
|
||||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
|
||||||
piece_categories:
|
|
||||||
Standard:
|
|
||||||
pieces:
|
|
||||||
- default
|
|
||||||
`);
|
|
||||||
|
|
||||||
const allPieces = createPieceMap([
|
|
||||||
{ name: 'my-piece', source: 'user' },
|
|
||||||
{ name: 'default', source: 'builtin' },
|
|
||||||
{ name: 'extra-builtin', source: 'builtin' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
pieceCategories: [
|
|
||||||
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: false,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
|
|
||||||
// Both builtins should be in builtinCategories, never hidden
|
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'Standard', pieces: ['default'], children: [] },
|
|
||||||
// extra-builtin not in default categories, so flat under Builtin
|
|
||||||
{ name: 'Builtin', pieces: ['extra-builtin'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use custom builtinCategoryName when configured', () => {
|
|
||||||
const allPieces = createPieceMap([
|
|
||||||
{ name: 'my-piece', source: 'user' },
|
|
||||||
{ name: 'default', source: 'builtin' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// No default categories file — builtins go to flat list
|
|
||||||
const config = {
|
|
||||||
pieceCategories: [
|
|
||||||
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: false,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
builtinCategoryName: 'System Pieces',
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
|
|
||||||
expect(categorized.builtinCategoryName).toBe('System Pieces');
|
|
||||||
// Flat fallback uses the custom name
|
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'System Pieces', pieces: ['default'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to flat Builtin category when default categories are unavailable', () => {
|
|
||||||
// No default-categories.yaml file
|
|
||||||
|
|
||||||
const allPieces = createPieceMap([
|
|
||||||
{ name: 'my-piece', source: 'user' },
|
|
||||||
{ name: 'default', source: 'builtin' },
|
|
||||||
{ name: 'minimal', source: 'builtin' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
pieceCategories: [
|
|
||||||
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
|
||||||
],
|
|
||||||
showOthersCategory: false,
|
|
||||||
othersCategoryName: 'Others',
|
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const categorized = buildCategorizedPieces(allPieces, config);
|
|
||||||
|
|
||||||
// All builtins in a flat 'Builtin' category
|
|
||||||
expect(categorized.builtinCategories).toEqual([
|
|
||||||
{ name: 'Builtin', pieces: ['default', 'minimal'], children: [] },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,23 +4,41 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { PieceDirEntry } from '../infra/config/loaders/pieceLoader.js';
|
import type { PieceDirEntry } from '../infra/config/loaders/pieceLoader.js';
|
||||||
|
import type { CategorizedPieces } from '../infra/config/loaders/pieceCategories.js';
|
||||||
|
import type { PieceWithSource } from '../infra/config/loaders/pieceResolver.js';
|
||||||
|
|
||||||
const selectOptionMock = vi.fn();
|
const selectOptionMock = vi.fn();
|
||||||
|
const bookmarkState = vi.hoisted(() => ({
|
||||||
|
bookmarks: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/prompt/index.js', () => ({
|
vi.mock('../shared/prompt/index.js', () => ({
|
||||||
selectOption: selectOptionMock,
|
selectOption: selectOptionMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/global/index.js', () => ({
|
vi.mock('../infra/config/global/index.js', () => ({
|
||||||
getBookmarkedPieces: () => [],
|
getBookmarkedPieces: () => bookmarkState.bookmarks,
|
||||||
|
addBookmark: vi.fn(),
|
||||||
|
removeBookmark: vi.fn(),
|
||||||
toggleBookmark: vi.fn(),
|
toggleBookmark: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { selectPieceFromEntries } = await import('../features/pieceSelection/index.js');
|
vi.mock('../infra/config/index.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal() as Record<string, unknown>;
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js');
|
||||||
|
|
||||||
describe('selectPieceFromEntries', () => {
|
describe('selectPieceFromEntries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
selectOptionMock.mockReset();
|
selectOptionMock.mockReset();
|
||||||
|
bookmarkState.bookmarks = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select from custom pieces when source is chosen', async () => {
|
it('should select from custom pieces when source is chosen', async () => {
|
||||||
@ -50,3 +68,98 @@ describe('selectPieceFromEntries', () => {
|
|||||||
expect(selectOptionMock).toHaveBeenCalledTimes(1);
|
expect(selectOptionMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createPieceMap(entries: { name: string; source: 'user' | 'builtin' }[]): Map<string, PieceWithSource> {
|
||||||
|
const map = new Map<string, PieceWithSource>();
|
||||||
|
for (const e of entries) {
|
||||||
|
map.set(e.name, {
|
||||||
|
source: e.source,
|
||||||
|
config: {
|
||||||
|
name: e.name,
|
||||||
|
movements: [],
|
||||||
|
initialMovement: 'start',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectPieceFromCategorizedPieces', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
selectOptionMock.mockReset();
|
||||||
|
bookmarkState.bookmarks = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show categories at top level', async () => {
|
||||||
|
const categorized: CategorizedPieces = {
|
||||||
|
categories: [
|
||||||
|
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
|
||||||
|
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
allPieces: createPieceMap([
|
||||||
|
{ name: 'my-piece', source: 'user' },
|
||||||
|
{ name: 'default', source: 'builtin' },
|
||||||
|
]),
|
||||||
|
missingPieces: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
selectOptionMock.mockResolvedValueOnce('__current__');
|
||||||
|
|
||||||
|
await selectPieceFromCategorizedPieces(categorized, 'my-piece');
|
||||||
|
|
||||||
|
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
||||||
|
const labels = firstCallOptions.map((o) => o.label);
|
||||||
|
|
||||||
|
expect(labels[0]).toBe('🎼 my-piece (current)');
|
||||||
|
expect(labels.some((l) => l.includes('My Pieces'))).toBe(true);
|
||||||
|
expect(labels.some((l) => l.includes('Quick Start'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show current piece and bookmarks above categories', async () => {
|
||||||
|
bookmarkState.bookmarks = ['research'];
|
||||||
|
|
||||||
|
const categorized: CategorizedPieces = {
|
||||||
|
categories: [
|
||||||
|
{ name: 'Quick Start', pieces: ['default'], children: [] },
|
||||||
|
],
|
||||||
|
allPieces: createPieceMap([
|
||||||
|
{ name: 'default', source: 'builtin' },
|
||||||
|
{ name: 'research', source: 'builtin' },
|
||||||
|
]),
|
||||||
|
missingPieces: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
selectOptionMock.mockResolvedValueOnce('__current__');
|
||||||
|
|
||||||
|
const selected = await selectPieceFromCategorizedPieces(categorized, 'default');
|
||||||
|
expect(selected).toBe('default');
|
||||||
|
|
||||||
|
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
||||||
|
const labels = firstCallOptions.map((o) => o.label);
|
||||||
|
|
||||||
|
// Current piece first, bookmarks second, categories after
|
||||||
|
expect(labels[0]).toBe('🎼 default (current)');
|
||||||
|
expect(labels[1]).toBe('🎼 research [*]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate into a category and select a piece', async () => {
|
||||||
|
const categorized: CategorizedPieces = {
|
||||||
|
categories: [
|
||||||
|
{ name: 'Dev', pieces: ['my-piece'], children: [] },
|
||||||
|
],
|
||||||
|
allPieces: createPieceMap([
|
||||||
|
{ name: 'my-piece', source: 'user' },
|
||||||
|
]),
|
||||||
|
missingPieces: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select category, then select piece inside it
|
||||||
|
selectOptionMock
|
||||||
|
.mockResolvedValueOnce('__custom_category__:Dev')
|
||||||
|
.mockResolvedValueOnce('my-piece');
|
||||||
|
|
||||||
|
const selected = await selectPieceFromCategorizedPieces(categorized, '');
|
||||||
|
expect(selected).toBe('my-piece');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
import { clearAgentSessions, getCurrentPiece } from '../../infra/config/index.js';
|
import { clearAgentSessions, getCurrentPiece } from '../../infra/config/index.js';
|
||||||
import { success } from '../../shared/ui/index.js';
|
import { success } from '../../shared/ui/index.js';
|
||||||
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
|
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
|
||||||
import { switchPiece, switchConfig, ejectBuiltin } from '../../features/config/index.js';
|
import { switchPiece, switchConfig, ejectBuiltin, resetCategoriesToDefault } from '../../features/config/index.js';
|
||||||
import { previewPrompts } from '../../features/prompt/index.js';
|
import { previewPrompts } from '../../features/prompt/index.js';
|
||||||
import { program, resolvedCwd } from './program.js';
|
import { program, resolvedCwd } from './program.js';
|
||||||
import { resolveAgentOverrides } from './helpers.js';
|
import { resolveAgentOverrides } from './helpers.js';
|
||||||
@ -89,6 +89,17 @@ program
|
|||||||
await switchConfig(resolvedCwd, key);
|
await switchConfig(resolvedCwd, key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reset = program
|
||||||
|
.command('reset')
|
||||||
|
.description('Reset settings to defaults');
|
||||||
|
|
||||||
|
reset
|
||||||
|
.command('categories')
|
||||||
|
.description('Reset piece categories to builtin defaults')
|
||||||
|
.action(async () => {
|
||||||
|
await resetCategoriesToDefault();
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('prompt')
|
.command('prompt')
|
||||||
.description('Preview assembled prompts for each movement and phase')
|
.description('Preview assembled prompts for each movement and phase')
|
||||||
|
|||||||
@ -5,3 +5,4 @@
|
|||||||
export { switchPiece } from './switchPiece.js';
|
export { switchPiece } from './switchPiece.js';
|
||||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js';
|
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js';
|
||||||
export { ejectBuiltin } from './ejectBuiltin.js';
|
export { ejectBuiltin } from './ejectBuiltin.js';
|
||||||
|
export { resetCategoriesToDefault } from './resetCategories.js';
|
||||||
|
|||||||
18
src/features/config/resetCategories.ts
Normal file
18
src/features/config/resetCategories.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Reset piece categories to builtin defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js';
|
||||||
|
import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js';
|
||||||
|
import { header, success, info } from '../../shared/ui/index.js';
|
||||||
|
|
||||||
|
export async function resetCategoriesToDefault(): Promise<void> {
|
||||||
|
header('Reset Categories');
|
||||||
|
|
||||||
|
const defaultPath = getDefaultCategoriesPath();
|
||||||
|
resetPieceCategories(defaultPath);
|
||||||
|
|
||||||
|
const userPath = getPieceCategoriesPath();
|
||||||
|
success('Categories reset to builtin defaults.');
|
||||||
|
info(` ${userPath}`);
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise<bool
|
|||||||
const current = getCurrentPiece(cwd);
|
const current = getCurrentPiece(cwd);
|
||||||
info(`Current piece: ${current}`);
|
info(`Current piece: ${current}`);
|
||||||
|
|
||||||
const categoryConfig = getPieceCategories(cwd);
|
const categoryConfig = getPieceCategories();
|
||||||
let selected: string | null;
|
let selected: string | null;
|
||||||
if (categoryConfig) {
|
if (categoryConfig) {
|
||||||
const allPieces = loadAllPiecesWithSources(cwd);
|
const allPieces = loadAllPiecesWithSources(cwd);
|
||||||
|
|||||||
@ -16,8 +16,6 @@ import {
|
|||||||
type PieceCategoryNode,
|
type PieceCategoryNode,
|
||||||
type CategorizedPieces,
|
type CategorizedPieces,
|
||||||
type MissingPiece,
|
type MissingPiece,
|
||||||
type PieceSource,
|
|
||||||
type PieceWithSource,
|
|
||||||
} from '../../infra/config/index.js';
|
} from '../../infra/config/index.js';
|
||||||
|
|
||||||
/** Top-level selection item: either a piece or a category containing pieces */
|
/** Top-level selection item: either a piece or a category containing pieces */
|
||||||
@ -280,63 +278,21 @@ async function selectPieceFromCategoryTree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function countPiecesIncludingCategories(
|
|
||||||
categories: PieceCategoryNode[],
|
|
||||||
allPieces: Map<string, PieceWithSource>,
|
|
||||||
sourceFilter: PieceSource,
|
|
||||||
): number {
|
|
||||||
const categorizedPieces = new Set<string>();
|
|
||||||
const visit = (nodes: PieceCategoryNode[]): void => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
for (const w of node.pieces) {
|
|
||||||
categorizedPieces.add(w);
|
|
||||||
}
|
|
||||||
if (node.children.length > 0) {
|
|
||||||
visit(node.children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(categories);
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
for (const [, { source }] of allPieces) {
|
|
||||||
if (source === sourceFilter) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURRENT_PIECE_VALUE = '__current__';
|
const CURRENT_PIECE_VALUE = '__current__';
|
||||||
const CUSTOM_UNCATEGORIZED_VALUE = '__custom_uncategorized__';
|
|
||||||
const BUILTIN_SOURCE_VALUE = '__builtin__';
|
|
||||||
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
|
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
|
||||||
|
|
||||||
type TopLevelSelection =
|
type TopLevelSelection =
|
||||||
| { type: 'current' }
|
| { type: 'current' }
|
||||||
| { type: 'piece'; name: string }
|
| { type: 'piece'; name: string }
|
||||||
| { type: 'custom_category'; node: PieceCategoryNode }
|
| { type: 'category'; node: PieceCategoryNode };
|
||||||
| { type: 'custom_uncategorized' }
|
|
||||||
| { type: 'builtin' };
|
|
||||||
|
|
||||||
async function selectTopLevelPieceOption(
|
async function selectTopLevelPieceOption(
|
||||||
categorized: CategorizedPieces,
|
categorized: CategorizedPieces,
|
||||||
currentPiece: string,
|
currentPiece: string,
|
||||||
): Promise<TopLevelSelection | null> {
|
): Promise<TopLevelSelection | null> {
|
||||||
const uncategorizedCustom = getRootLevelPieces(
|
|
||||||
categorized.categories,
|
|
||||||
categorized.allPieces,
|
|
||||||
'user'
|
|
||||||
);
|
|
||||||
const builtinCount = countPiecesIncludingCategories(
|
|
||||||
categorized.builtinCategories,
|
|
||||||
categorized.allPieces,
|
|
||||||
'builtin'
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildOptions = (): SelectOptionItem<string>[] => {
|
const buildOptions = (): SelectOptionItem<string>[] => {
|
||||||
const options: SelectOptionItem<string>[] = [];
|
const options: SelectOptionItem<string>[] = [];
|
||||||
const bookmarkedPieces = getBookmarkedPieces(); // Get fresh bookmarks on every build
|
const bookmarkedPieces = getBookmarkedPieces();
|
||||||
|
|
||||||
// 1. Current piece
|
// 1. Current piece
|
||||||
if (currentPiece) {
|
if (currentPiece) {
|
||||||
@ -348,14 +304,14 @@ async function selectTopLevelPieceOption(
|
|||||||
|
|
||||||
// 2. Bookmarked pieces (individual items)
|
// 2. Bookmarked pieces (individual items)
|
||||||
for (const pieceName of bookmarkedPieces) {
|
for (const pieceName of bookmarkedPieces) {
|
||||||
if (pieceName === currentPiece) continue; // Skip if already shown as current
|
if (pieceName === currentPiece) continue;
|
||||||
options.push({
|
options.push({
|
||||||
label: `🎼 ${pieceName} [*]`,
|
label: `🎼 ${pieceName} [*]`,
|
||||||
value: pieceName,
|
value: pieceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. User-defined categories
|
// 3. Categories
|
||||||
for (const category of categorized.categories) {
|
for (const category of categorized.categories) {
|
||||||
options.push({
|
options.push({
|
||||||
label: `📁 ${category.name}/`,
|
label: `📁 ${category.name}/`,
|
||||||
@ -363,22 +319,6 @@ async function selectTopLevelPieceOption(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Builtin pieces
|
|
||||||
if (builtinCount > 0) {
|
|
||||||
options.push({
|
|
||||||
label: `📂 ${categorized.builtinCategoryName}/ (${builtinCount})`,
|
|
||||||
value: BUILTIN_SOURCE_VALUE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Uncategorized custom pieces
|
|
||||||
if (uncategorizedCustom.length > 0) {
|
|
||||||
options.push({
|
|
||||||
label: `📂 Custom/ (${uncategorizedCustom.length})`,
|
|
||||||
value: CUSTOM_UNCATEGORIZED_VALUE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -386,12 +326,8 @@ async function selectTopLevelPieceOption(
|
|||||||
|
|
||||||
const result = await selectOption<string>('Select piece:', buildOptions(), {
|
const result = await selectOption<string>('Select piece:', buildOptions(), {
|
||||||
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
||||||
// Don't handle bookmark keys for special values
|
if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||||
if (value === CURRENT_PIECE_VALUE ||
|
return null;
|
||||||
value === CUSTOM_UNCATEGORIZED_VALUE ||
|
|
||||||
value === BUILTIN_SOURCE_VALUE ||
|
|
||||||
value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
|
||||||
return null; // Delegate to default handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'b') {
|
if (key === 'b') {
|
||||||
@ -404,7 +340,7 @@ async function selectTopLevelPieceOption(
|
|||||||
return buildOptions();
|
return buildOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // Delegate to default handler
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -414,52 +350,16 @@ async function selectTopLevelPieceOption(
|
|||||||
return { type: 'current' };
|
return { type: 'current' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === CUSTOM_UNCATEGORIZED_VALUE) {
|
|
||||||
return { type: 'custom_uncategorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === BUILTIN_SOURCE_VALUE) {
|
|
||||||
return { type: 'builtin' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||||
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
|
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
|
||||||
const node = categorized.categories.find(c => c.name === categoryName);
|
const node = categorized.categories.find(c => c.name === categoryName);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
return { type: 'custom_category', node };
|
return { type: 'category', node };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct piece selection (bookmarked or other)
|
|
||||||
return { type: 'piece', name: result };
|
return { type: 'piece', name: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRootLevelPieces(
|
|
||||||
categories: PieceCategoryNode[],
|
|
||||||
allPieces: Map<string, PieceWithSource>,
|
|
||||||
sourceFilter: PieceSource,
|
|
||||||
): string[] {
|
|
||||||
const categorizedPieces = new Set<string>();
|
|
||||||
const visit = (nodes: PieceCategoryNode[]): void => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
for (const w of node.pieces) {
|
|
||||||
categorizedPieces.add(w);
|
|
||||||
}
|
|
||||||
if (node.children.length > 0) {
|
|
||||||
visit(node.children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(categories);
|
|
||||||
|
|
||||||
const rootPieces: string[] = [];
|
|
||||||
for (const [name, { source }] of allPieces) {
|
|
||||||
if (source === sourceFilter && !categorizedPieces.has(name)) {
|
|
||||||
rootPieces.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rootPieces.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select piece from categorized pieces (hierarchical UI).
|
* Select piece from categorized pieces (hierarchical UI).
|
||||||
*/
|
*/
|
||||||
@ -469,91 +369,20 @@ export async function selectPieceFromCategorizedPieces(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const selection = await selectTopLevelPieceOption(categorized, currentPiece);
|
const selection = await selectTopLevelPieceOption(categorized, currentPiece);
|
||||||
if (!selection) {
|
if (!selection) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Current piece selected
|
if (selection.type === 'current') return currentPiece;
|
||||||
if (selection.type === 'current') {
|
|
||||||
return currentPiece;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Direct piece selected (e.g., bookmarked piece)
|
if (selection.type === 'piece') return selection.name;
|
||||||
if (selection.type === 'piece') {
|
|
||||||
return selection.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. User-defined category selected
|
if (selection.type === 'category') {
|
||||||
if (selection.type === 'custom_category') {
|
|
||||||
const piece = await selectPieceFromCategoryTree(
|
const piece = await selectPieceFromCategoryTree(
|
||||||
[selection.node],
|
[selection.node],
|
||||||
currentPiece,
|
currentPiece,
|
||||||
true,
|
true,
|
||||||
selection.node.pieces
|
selection.node.pieces,
|
||||||
);
|
);
|
||||||
if (piece) {
|
if (piece) return piece;
|
||||||
return piece;
|
|
||||||
}
|
|
||||||
// null → go back to top-level selection
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Builtin pieces selected
|
|
||||||
if (selection.type === 'builtin') {
|
|
||||||
const rootPieces = getRootLevelPieces(
|
|
||||||
categorized.builtinCategories,
|
|
||||||
categorized.allPieces,
|
|
||||||
'builtin'
|
|
||||||
);
|
|
||||||
|
|
||||||
const piece = await selectPieceFromCategoryTree(
|
|
||||||
categorized.builtinCategories,
|
|
||||||
currentPiece,
|
|
||||||
true,
|
|
||||||
rootPieces
|
|
||||||
);
|
|
||||||
if (piece) {
|
|
||||||
return piece;
|
|
||||||
}
|
|
||||||
// null → go back to top-level selection
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Custom uncategorized pieces selected
|
|
||||||
if (selection.type === 'custom_uncategorized') {
|
|
||||||
const uncategorizedCustom = getRootLevelPieces(
|
|
||||||
categorized.categories,
|
|
||||||
categorized.allPieces,
|
|
||||||
'user'
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseOptions: SelectionOption[] = uncategorizedCustom.map((name) => ({
|
|
||||||
label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`,
|
|
||||||
value: name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const buildFlatOptions = (): SelectionOption[] =>
|
|
||||||
applyBookmarks(baseOptions, getBookmarkedPieces());
|
|
||||||
|
|
||||||
const piece = await selectOption<string>('Select piece:', buildFlatOptions(), {
|
|
||||||
cancelLabel: '← Go back',
|
|
||||||
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
|
||||||
if (key === 'b') {
|
|
||||||
addBookmark(value);
|
|
||||||
return buildFlatOptions();
|
|
||||||
}
|
|
||||||
if (key === 'r') {
|
|
||||||
removeBookmark(value);
|
|
||||||
return buildFlatOptions();
|
|
||||||
}
|
|
||||||
return null; // Delegate to default handler
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (piece) {
|
|
||||||
return piece;
|
|
||||||
}
|
|
||||||
// null → go back to top-level selection
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ async function selectPieceWithDirectoryCategories(cwd: string): Promise<string |
|
|||||||
* Select a piece interactively with 2-stage category support.
|
* Select a piece interactively with 2-stage category support.
|
||||||
*/
|
*/
|
||||||
async function selectPiece(cwd: string): Promise<string | null> {
|
async function selectPiece(cwd: string): Promise<string | null> {
|
||||||
const categoryConfig = getPieceCategories(cwd);
|
const categoryConfig = getPieceCategories();
|
||||||
if (categoryConfig) {
|
if (categoryConfig) {
|
||||||
const current = getCurrentPiece(cwd);
|
const current = getCurrentPiece(cwd);
|
||||||
const allPieces = loadAllPiecesWithSources(cwd);
|
const allPieces = loadAllPiecesWithSources(cwd);
|
||||||
|
|||||||
@ -26,13 +26,9 @@ export {
|
|||||||
} from './bookmarks.js';
|
} from './bookmarks.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getPieceCategoriesConfig,
|
getPieceCategoriesPath,
|
||||||
setPieceCategoriesConfig,
|
ensureUserCategoriesFile,
|
||||||
getShowOthersCategory,
|
resetPieceCategories,
|
||||||
setShowOthersCategory,
|
|
||||||
getOthersCategoryName,
|
|
||||||
setOthersCategoryName,
|
|
||||||
getBuiltinCategoryName,
|
|
||||||
} from './pieceCategories.js';
|
} from './pieceCategories.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -1,28 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Piece categories management (separate from config.yaml)
|
* Piece categories file management.
|
||||||
*
|
*
|
||||||
* Categories are stored in a configurable location (default: ~/.takt/preferences/piece-categories.yaml)
|
* The categories file (~/.takt/preferences/piece-categories.yaml) uses the same
|
||||||
|
* format as the builtin piece-categories.yaml (piece_categories key).
|
||||||
|
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||||
import { join, dirname } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
||||||
import { getGlobalConfigDir } from '../paths.js';
|
import { getGlobalConfigDir } from '../paths.js';
|
||||||
import { loadGlobalConfig } from './globalConfig.js';
|
import { loadGlobalConfig } from './globalConfig.js';
|
||||||
import type { PieceCategoryConfigNode } from '../../../core/models/index.js';
|
|
||||||
|
|
||||||
interface PieceCategoriesFile {
|
|
||||||
categories?: PieceCategoryConfigNode;
|
|
||||||
show_others_category?: boolean;
|
|
||||||
others_category_name?: string;
|
|
||||||
builtin_category_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultPieceCategoriesPath(): string {
|
function getDefaultPieceCategoriesPath(): string {
|
||||||
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
|
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPieceCategoriesPath(): string {
|
/** Get the path to the user's piece categories file. */
|
||||||
|
export function getPieceCategoriesPath(): string {
|
||||||
try {
|
try {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
if (config.pieceCategoriesFile) {
|
if (config.pieceCategoriesFile) {
|
||||||
@ -34,77 +28,40 @@ function getPieceCategoriesPath(): string {
|
|||||||
return getDefaultPieceCategoriesPath();
|
return getDefaultPieceCategoriesPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPieceCategoriesFile(): PieceCategoriesFile {
|
/**
|
||||||
const categoriesPath = getPieceCategoriesPath();
|
* Ensure user categories file exists by copying from builtin defaults.
|
||||||
if (!existsSync(categoriesPath)) {
|
* Returns the path to the user categories file.
|
||||||
return {};
|
*/
|
||||||
|
export function ensureUserCategoriesFile(defaultCategoriesPath: string): string {
|
||||||
|
const userPath = getPieceCategoriesPath();
|
||||||
|
if (existsSync(userPath)) {
|
||||||
|
return userPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!existsSync(defaultCategoriesPath)) {
|
||||||
const content = readFileSync(categoriesPath, 'utf-8');
|
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||||
const parsed = parseYaml(content);
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return parsed as PieceCategoriesFile;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
const dir = dirname(userPath);
|
||||||
}
|
|
||||||
|
|
||||||
function savePieceCategoriesFile(data: PieceCategoriesFile): void {
|
|
||||||
const categoriesPath = getPieceCategoriesPath();
|
|
||||||
const dir = dirname(categoriesPath);
|
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
const content = stringifyYaml(data, { indent: 2 });
|
copyFileSync(defaultCategoriesPath, userPath);
|
||||||
writeFileSync(categoriesPath, content, 'utf-8');
|
return userPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get piece categories configuration */
|
/**
|
||||||
export function getPieceCategoriesConfig(): PieceCategoryConfigNode | undefined {
|
* Reset user categories file by overwriting with builtin defaults.
|
||||||
const data = loadPieceCategoriesFile();
|
*/
|
||||||
return data.categories;
|
export function resetPieceCategories(defaultCategoriesPath: string): void {
|
||||||
|
if (!existsSync(defaultCategoriesPath)) {
|
||||||
|
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set piece categories configuration */
|
const userPath = getPieceCategoriesPath();
|
||||||
export function setPieceCategoriesConfig(categories: PieceCategoryConfigNode): void {
|
const dir = dirname(userPath);
|
||||||
const data = loadPieceCategoriesFile();
|
if (!existsSync(dir)) {
|
||||||
data.categories = categories;
|
mkdirSync(dir, { recursive: true });
|
||||||
savePieceCategoriesFile(data);
|
|
||||||
}
|
}
|
||||||
|
copyFileSync(defaultCategoriesPath, userPath);
|
||||||
/** Get show others category flag */
|
|
||||||
export function getShowOthersCategory(): boolean | undefined {
|
|
||||||
const data = loadPieceCategoriesFile();
|
|
||||||
return data.show_others_category;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set show others category flag */
|
|
||||||
export function setShowOthersCategory(show: boolean): void {
|
|
||||||
const data = loadPieceCategoriesFile();
|
|
||||||
data.show_others_category = show;
|
|
||||||
savePieceCategoriesFile(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get others category name */
|
|
||||||
export function getOthersCategoryName(): string | undefined {
|
|
||||||
const data = loadPieceCategoriesFile();
|
|
||||||
return data.others_category_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set others category name */
|
|
||||||
export function setOthersCategoryName(name: string): void {
|
|
||||||
const data = loadPieceCategoriesFile();
|
|
||||||
data.others_category_name = name;
|
|
||||||
savePieceCategoriesFile(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get builtin category name */
|
|
||||||
export function getBuiltinCategoryName(): string | undefined {
|
|
||||||
const data = loadPieceCategoriesFile();
|
|
||||||
return data.builtin_category_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
loadDefaultCategories,
|
loadDefaultCategories,
|
||||||
|
getDefaultCategoriesPath,
|
||||||
getPieceCategories,
|
getPieceCategories,
|
||||||
buildCategorizedPieces,
|
buildCategorizedPieces,
|
||||||
findPieceCategories,
|
findPieceCategories,
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Piece category configuration loader and helpers.
|
* Piece category configuration loader and helpers.
|
||||||
|
*
|
||||||
|
* Categories are loaded from a single source: the user's piece-categories.yaml file.
|
||||||
|
* If the file doesn't exist, it's auto-copied from builtin defaults.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { getProjectConfigPath } from '../paths.js';
|
|
||||||
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
|
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
|
||||||
import {
|
import { ensureUserCategoriesFile } from '../global/pieceCategories.js';
|
||||||
getPieceCategoriesConfig,
|
|
||||||
getShowOthersCategory,
|
|
||||||
getOthersCategoryName,
|
|
||||||
getBuiltinCategoryName,
|
|
||||||
} from '../global/pieceCategories.js';
|
|
||||||
import { getLanguageResourcesDir } from '../../resources/index.js';
|
import { getLanguageResourcesDir } from '../../resources/index.js';
|
||||||
import { listBuiltinPieceNames } from './pieceResolver.js';
|
import { listBuiltinPieceNames } from './pieceResolver.js';
|
||||||
import type { PieceSource, PieceWithSource } from './pieceResolver.js';
|
import type { PieceWithSource } from './pieceResolver.js';
|
||||||
|
|
||||||
const CategoryConfigSchema = z.object({
|
const CategoryConfigSchema = z.object({
|
||||||
piece_categories: z.record(z.string(), z.unknown()).optional(),
|
piece_categories: z.record(z.string(), z.unknown()).optional(),
|
||||||
@ -34,15 +31,10 @@ export interface CategoryConfig {
|
|||||||
pieceCategories: PieceCategoryNode[];
|
pieceCategories: PieceCategoryNode[];
|
||||||
showOthersCategory: boolean;
|
showOthersCategory: boolean;
|
||||||
othersCategoryName: string;
|
othersCategoryName: string;
|
||||||
builtinCategoryName: string;
|
|
||||||
/** True when categories are from user or project config (not builtin defaults). Triggers auto-categorization of builtins. */
|
|
||||||
hasCustomCategories: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategorizedPieces {
|
export interface CategorizedPieces {
|
||||||
categories: PieceCategoryNode[];
|
categories: PieceCategoryNode[];
|
||||||
builtinCategories: PieceCategoryNode[];
|
|
||||||
builtinCategoryName: string;
|
|
||||||
allPieces: Map<string, PieceWithSource>;
|
allPieces: Map<string, PieceWithSource>;
|
||||||
missingPieces: MissingPiece[];
|
missingPieces: MissingPiece[];
|
||||||
}
|
}
|
||||||
@ -139,8 +131,6 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig
|
|||||||
pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel),
|
pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel),
|
||||||
showOthersCategory,
|
showOthersCategory,
|
||||||
othersCategoryName,
|
othersCategoryName,
|
||||||
builtinCategoryName: 'Builtin',
|
|
||||||
hasCustomCategories: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,36 +149,25 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category
|
|||||||
*/
|
*/
|
||||||
export function loadDefaultCategories(): CategoryConfig | null {
|
export function loadDefaultCategories(): CategoryConfig | null {
|
||||||
const lang = getLanguage();
|
const lang = getLanguage();
|
||||||
const filePath = join(getLanguageResourcesDir(lang), 'default-categories.yaml');
|
const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
|
||||||
return loadCategoryConfigFromPath(filePath, filePath);
|
return loadCategoryConfigFromPath(filePath, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the path to the builtin default categories file. */
|
||||||
|
export function getDefaultCategoriesPath(): string {
|
||||||
|
const lang = getLanguage();
|
||||||
|
return join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effective piece categories configuration.
|
* Get effective piece categories configuration.
|
||||||
* Priority: user config -> project config -> default categories.
|
* Reads from user file (~/.takt/preferences/piece-categories.yaml).
|
||||||
|
* Auto-copies from builtin defaults if user file doesn't exist.
|
||||||
*/
|
*/
|
||||||
export function getPieceCategories(cwd: string): CategoryConfig | null {
|
export function getPieceCategories(): CategoryConfig | null {
|
||||||
// Check user config from separate file (~/.takt/piece-categories.yaml)
|
const defaultPath = getDefaultCategoriesPath();
|
||||||
const userCategoriesNode = getPieceCategoriesConfig();
|
const userPath = ensureUserCategoriesFile(defaultPath);
|
||||||
if (userCategoriesNode) {
|
return loadCategoryConfigFromPath(userPath, userPath);
|
||||||
const showOthersCategory = getShowOthersCategory() ?? true;
|
|
||||||
const othersCategoryName = getOthersCategoryName() ?? 'Others';
|
|
||||||
const builtinCategoryName = getBuiltinCategoryName() ?? 'Builtin';
|
|
||||||
return {
|
|
||||||
pieceCategories: parseCategoryTree(userCategoriesNode, 'user config'),
|
|
||||||
showOthersCategory,
|
|
||||||
othersCategoryName,
|
|
||||||
builtinCategoryName,
|
|
||||||
hasCustomCategories: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectConfig = loadCategoryConfigFromPath(getProjectConfigPath(cwd), 'project config');
|
|
||||||
if (projectConfig) {
|
|
||||||
return { ...projectConfig, hasCustomCategories: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadDefaultCategories();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectMissingPieces(
|
function collectMissingPieces(
|
||||||
@ -216,27 +195,22 @@ function collectMissingPieces(
|
|||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCategoryTreeForSource(
|
function buildCategoryTree(
|
||||||
categories: PieceCategoryNode[],
|
categories: PieceCategoryNode[],
|
||||||
allPieces: Map<string, PieceWithSource>,
|
allPieces: Map<string, PieceWithSource>,
|
||||||
sourceFilter: (source: PieceSource) => boolean,
|
|
||||||
categorized: Set<string>,
|
categorized: Set<string>,
|
||||||
allowedPieces?: Set<string>,
|
|
||||||
): PieceCategoryNode[] {
|
): PieceCategoryNode[] {
|
||||||
const result: PieceCategoryNode[] = [];
|
const result: PieceCategoryNode[] = [];
|
||||||
|
|
||||||
for (const node of categories) {
|
for (const node of categories) {
|
||||||
const pieces: string[] = [];
|
const pieces: string[] = [];
|
||||||
for (const pieceName of node.pieces) {
|
for (const pieceName of node.pieces) {
|
||||||
if (allowedPieces && !allowedPieces.has(pieceName)) continue;
|
if (!allPieces.has(pieceName)) continue;
|
||||||
const entry = allPieces.get(pieceName);
|
|
||||||
if (!entry) continue;
|
|
||||||
if (!sourceFilter(entry.source)) continue;
|
|
||||||
pieces.push(pieceName);
|
pieces.push(pieceName);
|
||||||
categorized.add(pieceName);
|
categorized.add(pieceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = buildCategoryTreeForSource(node.children, allPieces, sourceFilter, categorized, allowedPieces);
|
const children = buildCategoryTree(node.children, allPieces, categorized);
|
||||||
if (pieces.length > 0 || children.length > 0) {
|
if (pieces.length > 0 || children.length > 0) {
|
||||||
result.push({ name: node.name, pieces, children });
|
result.push({ name: node.name, pieces, children });
|
||||||
}
|
}
|
||||||
@ -249,16 +223,10 @@ function appendOthersCategory(
|
|||||||
categories: PieceCategoryNode[],
|
categories: PieceCategoryNode[],
|
||||||
allPieces: Map<string, PieceWithSource>,
|
allPieces: Map<string, PieceWithSource>,
|
||||||
categorized: Set<string>,
|
categorized: Set<string>,
|
||||||
sourceFilter: (source: PieceSource) => boolean,
|
|
||||||
othersCategoryName: string,
|
othersCategoryName: string,
|
||||||
): PieceCategoryNode[] {
|
): PieceCategoryNode[] {
|
||||||
if (categories.some((node) => node.name === othersCategoryName)) {
|
|
||||||
return categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uncategorized: string[] = [];
|
const uncategorized: string[] = [];
|
||||||
for (const [pieceName, entry] of allPieces.entries()) {
|
for (const [pieceName] of allPieces.entries()) {
|
||||||
if (!sourceFilter(entry.source)) continue;
|
|
||||||
if (categorized.has(pieceName)) continue;
|
if (categorized.has(pieceName)) continue;
|
||||||
uncategorized.push(pieceName);
|
uncategorized.push(pieceName);
|
||||||
}
|
}
|
||||||
@ -267,70 +235,28 @@ function appendOthersCategory(
|
|||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a category with the same name already exists, merge uncategorized pieces into it
|
||||||
|
const existingIndex = categories.findIndex((node) => node.name === othersCategoryName);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = categories[existingIndex]!;
|
||||||
|
return categories.map((node, i) =>
|
||||||
|
i === existingIndex
|
||||||
|
? { ...node, pieces: [...existing.pieces, ...uncategorized] }
|
||||||
|
: node,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }];
|
return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect uncategorized builtin piece names.
|
|
||||||
*/
|
|
||||||
function collectUncategorizedBuiltins(
|
|
||||||
allPieces: Map<string, PieceWithSource>,
|
|
||||||
categorizedBuiltin: Set<string>,
|
|
||||||
): Set<string> {
|
|
||||||
const uncategorized = new Set<string>();
|
|
||||||
for (const [pieceName, entry] of allPieces.entries()) {
|
|
||||||
if (entry.source === 'builtin' && !categorizedBuiltin.has(pieceName)) {
|
|
||||||
uncategorized.add(pieceName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uncategorized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build builtin categories for uncategorized builtins using default category structure.
|
|
||||||
* Falls back to flat list if default categories are unavailable.
|
|
||||||
*/
|
|
||||||
function buildAutoBuiltinCategories(
|
|
||||||
allPieces: Map<string, PieceWithSource>,
|
|
||||||
uncategorizedBuiltins: Set<string>,
|
|
||||||
builtinCategoryName: string,
|
|
||||||
defaultConfig: CategoryConfig | null,
|
|
||||||
): PieceCategoryNode[] {
|
|
||||||
if (defaultConfig) {
|
|
||||||
const autoCategorized = new Set<string>();
|
|
||||||
const autoCategories = buildCategoryTreeForSource(
|
|
||||||
defaultConfig.pieceCategories,
|
|
||||||
allPieces,
|
|
||||||
(source) => source === 'builtin',
|
|
||||||
autoCategorized,
|
|
||||||
uncategorizedBuiltins,
|
|
||||||
);
|
|
||||||
// Any builtins still not categorized by default categories go into a flat list
|
|
||||||
const remaining: string[] = [];
|
|
||||||
for (const name of uncategorizedBuiltins) {
|
|
||||||
if (!autoCategorized.has(name)) {
|
|
||||||
remaining.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
autoCategories.push({ name: builtinCategoryName, pieces: remaining, children: [] });
|
|
||||||
}
|
|
||||||
return autoCategories;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No default categories available: flat list
|
|
||||||
return [{ name: builtinCategoryName, pieces: Array.from(uncategorizedBuiltins), children: [] }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build categorized pieces map from configuration.
|
* Build categorized pieces map from configuration.
|
||||||
|
* All pieces (user and builtin) are placed in a single category tree.
|
||||||
*/
|
*/
|
||||||
export function buildCategorizedPieces(
|
export function buildCategorizedPieces(
|
||||||
allPieces: Map<string, PieceWithSource>,
|
allPieces: Map<string, PieceWithSource>,
|
||||||
config: CategoryConfig,
|
config: CategoryConfig,
|
||||||
): CategorizedPieces {
|
): CategorizedPieces {
|
||||||
const { builtinCategoryName } = config;
|
|
||||||
|
|
||||||
const ignoreMissing = new Set<string>();
|
const ignoreMissing = new Set<string>();
|
||||||
if (!getBuiltinPiecesEnabled()) {
|
if (!getBuiltinPiecesEnabled()) {
|
||||||
for (const name of listBuiltinPieceNames({ includeDisabled: true })) {
|
for (const name of listBuiltinPieceNames({ includeDisabled: true })) {
|
||||||
@ -348,66 +274,19 @@ export function buildCategorizedPieces(
|
|||||||
ignoreMissing,
|
ignoreMissing,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isBuiltin = (source: PieceSource): boolean => source === 'builtin';
|
const categorized = new Set<string>();
|
||||||
const isCustom = (source: PieceSource): boolean => source !== 'builtin';
|
const categories = buildCategoryTree(
|
||||||
|
|
||||||
const categorizedCustom = new Set<string>();
|
|
||||||
const categories = buildCategoryTreeForSource(
|
|
||||||
config.pieceCategories,
|
config.pieceCategories,
|
||||||
allPieces,
|
allPieces,
|
||||||
isCustom,
|
categorized,
|
||||||
categorizedCustom,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const categorizedBuiltin = new Set<string>();
|
|
||||||
const builtinCategories = buildCategoryTreeForSource(
|
|
||||||
config.pieceCategories,
|
|
||||||
allPieces,
|
|
||||||
isBuiltin,
|
|
||||||
categorizedBuiltin,
|
|
||||||
);
|
|
||||||
|
|
||||||
// When user defined categories, auto-categorize uncategorized builtins
|
|
||||||
if (config.hasCustomCategories) {
|
|
||||||
const uncategorizedBuiltins = collectUncategorizedBuiltins(allPieces, categorizedBuiltin);
|
|
||||||
if (uncategorizedBuiltins.size > 0) {
|
|
||||||
const defaultConfig = loadDefaultCategories();
|
|
||||||
const autoCategories = buildAutoBuiltinCategories(allPieces, uncategorizedBuiltins, builtinCategoryName, defaultConfig);
|
|
||||||
builtinCategories.push(...autoCategories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalCategories = config.showOthersCategory
|
const finalCategories = config.showOthersCategory
|
||||||
? appendOthersCategory(
|
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
||||||
categories,
|
|
||||||
allPieces,
|
|
||||||
categorizedCustom,
|
|
||||||
isCustom,
|
|
||||||
config.othersCategoryName,
|
|
||||||
)
|
|
||||||
: categories;
|
: categories;
|
||||||
|
|
||||||
// For user-defined configs, uncategorized builtins are already handled above,
|
|
||||||
// so only apply Others for non-user-defined configs
|
|
||||||
let finalBuiltinCategories: PieceCategoryNode[];
|
|
||||||
if (config.hasCustomCategories) {
|
|
||||||
finalBuiltinCategories = builtinCategories;
|
|
||||||
} else {
|
|
||||||
finalBuiltinCategories = config.showOthersCategory
|
|
||||||
? appendOthersCategory(
|
|
||||||
builtinCategories,
|
|
||||||
allPieces,
|
|
||||||
categorizedBuiltin,
|
|
||||||
isBuiltin,
|
|
||||||
config.othersCategoryName,
|
|
||||||
)
|
|
||||||
: builtinCategories;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories: finalCategories,
|
categories: finalCategories,
|
||||||
builtinCategories: finalBuiltinCategories,
|
|
||||||
builtinCategoryName,
|
|
||||||
allPieces,
|
allPieces,
|
||||||
missingPieces,
|
missingPieces,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user