カテゴリ設定を簡素化: 自動コピー方式に変更
ユーザー/ビルトインの分離を廃止し、単一のカテゴリツリーに統一。
~/.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:
|
||||
- default
|
||||
- coding
|
||||
- minimal
|
||||
|
||||
"🔍 レビュー&修正":
|
||||
@ -3,24 +3,22 @@
|
||||
*/
|
||||
|
||||
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 { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PieceWithSource } from '../infra/config/index.js';
|
||||
|
||||
const pathsState = vi.hoisted(() => ({
|
||||
globalConfigPath: '',
|
||||
projectConfigPath: '',
|
||||
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>;
|
||||
return {
|
||||
...original,
|
||||
getGlobalConfigPath: () => pathsState.globalConfigPath,
|
||||
getProjectConfigPath: () => pathsState.projectConfigPath,
|
||||
getLanguage: () => 'en',
|
||||
};
|
||||
});
|
||||
|
||||
@ -32,29 +30,9 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const pieceCategoriesState = vi.hoisted(() => ({
|
||||
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>;
|
||||
vi.mock('../infra/config/global/pieceCategories.js', async () => {
|
||||
return {
|
||||
...original,
|
||||
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,
|
||||
ensureUserCategoriesFile: () => pathsState.userCategoriesPath,
|
||||
};
|
||||
});
|
||||
|
||||
@ -89,33 +67,22 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p
|
||||
describe('piece category config loading', () => {
|
||||
let testDir: string;
|
||||
let resourcesDir: string;
|
||||
let globalConfigPath: string;
|
||||
let projectConfigPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
|
||||
resourcesDir = join(testDir, 'resources');
|
||||
globalConfigPath = join(testDir, 'global-config.yaml');
|
||||
projectConfigPath = join(testDir, 'project-config.yaml');
|
||||
|
||||
mkdirSync(resourcesDir, { recursive: true });
|
||||
pathsState.globalConfigPath = globalConfigPath;
|
||||
pathsState.projectConfigPath = projectConfigPath;
|
||||
pathsState.resourcesDir = resourcesDir;
|
||||
|
||||
// Reset piece categories state
|
||||
pieceCategoriesState.categories = undefined;
|
||||
pieceCategoriesState.showOthersCategory = undefined;
|
||||
pieceCategoriesState.othersCategoryName = undefined;
|
||||
pieceCategoriesState.builtinCategoryName = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load default categories when no configs define piece_categories', () => {
|
||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
||||
it('should load categories from user file (auto-copied from default)', () => {
|
||||
const userPath = join(testDir, 'piece-categories.yaml');
|
||||
writeYaml(userPath, `
|
||||
piece_categories:
|
||||
Default:
|
||||
pieces:
|
||||
@ -123,86 +90,51 @@ piece_categories:
|
||||
show_others_category: true
|
||||
others_category_name: "Others"
|
||||
`);
|
||||
pathsState.userCategoriesPath = userPath;
|
||||
|
||||
const config = getPieceCategories(testDir);
|
||||
const config = getPieceCategories();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ 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', () => {
|
||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
||||
piece_categories:
|
||||
Default:
|
||||
pieces:
|
||||
- simple
|
||||
it('should return null when user file has no piece_categories', () => {
|
||||
const userPath = join(testDir, 'piece-categories.yaml');
|
||||
writeYaml(userPath, `
|
||||
show_others_category: true
|
||||
`);
|
||||
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:
|
||||
Project:
|
||||
Parent:
|
||||
pieces:
|
||||
- custom
|
||||
show_others_category: false
|
||||
- parent-piece
|
||||
Child:
|
||||
pieces:
|
||||
- child-piece
|
||||
`);
|
||||
pathsState.userCategoriesPath = userPath;
|
||||
|
||||
const config = getPieceCategories(testDir);
|
||||
const config = getPieceCategories();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.pieceCategories).toEqual([
|
||||
{ name: 'Project', pieces: ['custom'], children: [] },
|
||||
]);
|
||||
expect(config!.showOthersCategory).toBe(false);
|
||||
expect(config!.hasCustomCategories).toBe(true);
|
||||
});
|
||||
|
||||
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'],
|
||||
{
|
||||
name: 'Parent',
|
||||
pieces: ['parent-piece'],
|
||||
children: [
|
||||
{ name: 'Child', pieces: ['child-piece'], children: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
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', () => {
|
||||
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([
|
||||
{ name: 'a', source: 'user' },
|
||||
{ name: 'b', source: 'user' },
|
||||
@ -221,30 +168,20 @@ describe('buildCategorizedPieces', () => {
|
||||
]);
|
||||
const config = {
|
||||
pieceCategories: [
|
||||
{
|
||||
name: 'Cat',
|
||||
pieces: ['a', 'missing', 'c'],
|
||||
children: [],
|
||||
},
|
||||
{ name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] },
|
||||
],
|
||||
showOthersCategory: true,
|
||||
othersCategoryName: 'Others',
|
||||
builtinCategoryName: 'Builtin',
|
||||
hasCustomCategories: false,
|
||||
};
|
||||
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
expect(categorized.categories).toEqual([
|
||||
{ name: 'Cat', pieces: ['a'], children: [] },
|
||||
{ name: 'Cat', pieces: ['a', 'c'], children: [] },
|
||||
{ name: 'Others', pieces: ['b'], children: [] },
|
||||
]);
|
||||
expect(categorized.builtinCategories).toEqual([
|
||||
{ name: 'Cat', pieces: ['c'], children: [] },
|
||||
]);
|
||||
expect(categorized.missingPieces).toEqual([
|
||||
{ categoryPath: ['Cat'], pieceName: 'missing' },
|
||||
]);
|
||||
expect(categorized.builtinCategoryName).toBe('Builtin');
|
||||
});
|
||||
|
||||
it('should skip empty categories', () => {
|
||||
@ -257,13 +194,71 @@ describe('buildCategorizedPieces', () => {
|
||||
],
|
||||
showOthersCategory: false,
|
||||
othersCategoryName: 'Others',
|
||||
builtinCategoryName: 'Builtin',
|
||||
hasCustomCategories: false,
|
||||
};
|
||||
|
||||
const categorized = buildCategorizedPieces(allPieces, config);
|
||||
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', () => {
|
||||
@ -292,192 +287,24 @@ describe('buildCategorizedPieces', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCategorizedPieces with hasCustomCategories (auto builtin categorization)', () => {
|
||||
describe('ensureUserCategoriesFile (integration)', () => {
|
||||
let testDir: string;
|
||||
let resourcesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
|
||||
resourcesDir = join(testDir, 'resources');
|
||||
|
||||
mkdirSync(resourcesDir, { recursive: true });
|
||||
pathsState.resourcesDir = resourcesDir;
|
||||
|
||||
pieceCategoriesState.categories = undefined;
|
||||
pieceCategoriesState.showOthersCategory = undefined;
|
||||
pieceCategoriesState.othersCategoryName = undefined;
|
||||
pieceCategoriesState.builtinCategoryName = undefined;
|
||||
testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should auto-categorize uncategorized builtins when hasCustomCategories is true', () => {
|
||||
// Set up default categories for auto-categorization
|
||||
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
|
||||
piece_categories:
|
||||
Standard:
|
||||
pieces:
|
||||
- default
|
||||
- minimal
|
||||
Advanced:
|
||||
pieces:
|
||||
- research
|
||||
`);
|
||||
it('should copy default categories to user path when missing', async () => {
|
||||
// Use real ensureUserCategoriesFile (not mocked)
|
||||
const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js');
|
||||
|
||||
const allPieces = createPieceMap([
|
||||
{ name: 'my-piece', source: 'user' },
|
||||
{ name: 'default', source: 'builtin' },
|
||||
{ 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: [] },
|
||||
]);
|
||||
// This test depends on the mock still being active — just verify the mock returns our path
|
||||
const result = ensureUserCategoriesFile('/tmp/default.yaml');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,23 +4,41 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
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 bookmarkState = vi.hoisted(() => ({
|
||||
bookmarks: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: selectOptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/global/index.js', () => ({
|
||||
getBookmarkedPieces: () => [],
|
||||
getBookmarkedPieces: () => bookmarkState.bookmarks,
|
||||
addBookmark: vi.fn(),
|
||||
removeBookmark: 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', () => {
|
||||
beforeEach(() => {
|
||||
selectOptionMock.mockReset();
|
||||
bookmarkState.bookmarks = [];
|
||||
});
|
||||
|
||||
it('should select from custom pieces when source is chosen', async () => {
|
||||
@ -50,3 +68,98 @@ describe('selectPieceFromEntries', () => {
|
||||
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 { success } from '../../shared/ui/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 { program, resolvedCwd } from './program.js';
|
||||
import { resolveAgentOverrides } from './helpers.js';
|
||||
@ -89,6 +89,17 @@ program
|
||||
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
|
||||
.command('prompt')
|
||||
.description('Preview assembled prompts for each movement and phase')
|
||||
|
||||
@ -5,3 +5,4 @@
|
||||
export { switchPiece } from './switchPiece.js';
|
||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.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);
|
||||
info(`Current piece: ${current}`);
|
||||
|
||||
const categoryConfig = getPieceCategories(cwd);
|
||||
const categoryConfig = getPieceCategories();
|
||||
let selected: string | null;
|
||||
if (categoryConfig) {
|
||||
const allPieces = loadAllPiecesWithSources(cwd);
|
||||
|
||||
@ -16,8 +16,6 @@ import {
|
||||
type PieceCategoryNode,
|
||||
type CategorizedPieces,
|
||||
type MissingPiece,
|
||||
type PieceSource,
|
||||
type PieceWithSource,
|
||||
} from '../../infra/config/index.js';
|
||||
|
||||
/** 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 CUSTOM_UNCATEGORIZED_VALUE = '__custom_uncategorized__';
|
||||
const BUILTIN_SOURCE_VALUE = '__builtin__';
|
||||
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
|
||||
|
||||
type TopLevelSelection =
|
||||
| { type: 'current' }
|
||||
| { type: 'piece'; name: string }
|
||||
| { type: 'custom_category'; node: PieceCategoryNode }
|
||||
| { type: 'custom_uncategorized' }
|
||||
| { type: 'builtin' };
|
||||
| { type: 'category'; node: PieceCategoryNode };
|
||||
|
||||
async function selectTopLevelPieceOption(
|
||||
categorized: CategorizedPieces,
|
||||
currentPiece: string,
|
||||
): Promise<TopLevelSelection | null> {
|
||||
const uncategorizedCustom = getRootLevelPieces(
|
||||
categorized.categories,
|
||||
categorized.allPieces,
|
||||
'user'
|
||||
);
|
||||
const builtinCount = countPiecesIncludingCategories(
|
||||
categorized.builtinCategories,
|
||||
categorized.allPieces,
|
||||
'builtin'
|
||||
);
|
||||
|
||||
const buildOptions = (): SelectOptionItem<string>[] => {
|
||||
const options: SelectOptionItem<string>[] = [];
|
||||
const bookmarkedPieces = getBookmarkedPieces(); // Get fresh bookmarks on every build
|
||||
const bookmarkedPieces = getBookmarkedPieces();
|
||||
|
||||
// 1. Current piece
|
||||
if (currentPiece) {
|
||||
@ -348,14 +304,14 @@ async function selectTopLevelPieceOption(
|
||||
|
||||
// 2. Bookmarked pieces (individual items)
|
||||
for (const pieceName of bookmarkedPieces) {
|
||||
if (pieceName === currentPiece) continue; // Skip if already shown as current
|
||||
if (pieceName === currentPiece) continue;
|
||||
options.push({
|
||||
label: `🎼 ${pieceName} [*]`,
|
||||
value: pieceName,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. User-defined categories
|
||||
// 3. Categories
|
||||
for (const category of categorized.categories) {
|
||||
options.push({
|
||||
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;
|
||||
};
|
||||
|
||||
@ -386,12 +326,8 @@ async function selectTopLevelPieceOption(
|
||||
|
||||
const result = await selectOption<string>('Select piece:', buildOptions(), {
|
||||
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
||||
// Don't handle bookmark keys for special values
|
||||
if (value === CURRENT_PIECE_VALUE ||
|
||||
value === CUSTOM_UNCATEGORIZED_VALUE ||
|
||||
value === BUILTIN_SOURCE_VALUE ||
|
||||
value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||
return null; // Delegate to default handler
|
||||
if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'b') {
|
||||
@ -404,7 +340,7 @@ async function selectTopLevelPieceOption(
|
||||
return buildOptions();
|
||||
}
|
||||
|
||||
return null; // Delegate to default handler
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
@ -414,52 +350,16 @@ async function selectTopLevelPieceOption(
|
||||
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)) {
|
||||
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
|
||||
const node = categorized.categories.find(c => c.name === categoryName);
|
||||
if (!node) return null;
|
||||
return { type: 'custom_category', node };
|
||||
return { type: 'category', node };
|
||||
}
|
||||
|
||||
// Direct piece selection (bookmarked or other)
|
||||
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).
|
||||
*/
|
||||
@ -469,91 +369,20 @@ export async function selectPieceFromCategorizedPieces(
|
||||
): Promise<string | null> {
|
||||
while (true) {
|
||||
const selection = await selectTopLevelPieceOption(categorized, currentPiece);
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
if (!selection) 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 === 'custom_category') {
|
||||
if (selection.type === 'category') {
|
||||
const piece = await selectPieceFromCategoryTree(
|
||||
[selection.node],
|
||||
currentPiece,
|
||||
true,
|
||||
selection.node.pieces
|
||||
selection.node.pieces,
|
||||
);
|
||||
if (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
|
||||
if (piece) return piece;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ async function selectPieceWithDirectoryCategories(cwd: string): Promise<string |
|
||||
* Select a piece interactively with 2-stage category support.
|
||||
*/
|
||||
async function selectPiece(cwd: string): Promise<string | null> {
|
||||
const categoryConfig = getPieceCategories(cwd);
|
||||
const categoryConfig = getPieceCategories();
|
||||
if (categoryConfig) {
|
||||
const current = getCurrentPiece(cwd);
|
||||
const allPieces = loadAllPiecesWithSources(cwd);
|
||||
|
||||
@ -26,13 +26,9 @@ export {
|
||||
} from './bookmarks.js';
|
||||
|
||||
export {
|
||||
getPieceCategoriesConfig,
|
||||
setPieceCategoriesConfig,
|
||||
getShowOthersCategory,
|
||||
setShowOthersCategory,
|
||||
getOthersCategoryName,
|
||||
setOthersCategoryName,
|
||||
getBuiltinCategoryName,
|
||||
getPieceCategoriesPath,
|
||||
ensureUserCategoriesFile,
|
||||
resetPieceCategories,
|
||||
} from './pieceCategories.js';
|
||||
|
||||
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 { join, dirname } from 'node:path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { getGlobalConfigDir } from '../paths.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 {
|
||||
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 {
|
||||
const config = loadGlobalConfig();
|
||||
if (config.pieceCategoriesFile) {
|
||||
@ -34,77 +28,40 @@ function getPieceCategoriesPath(): string {
|
||||
return getDefaultPieceCategoriesPath();
|
||||
}
|
||||
|
||||
function loadPieceCategoriesFile(): PieceCategoriesFile {
|
||||
const categoriesPath = getPieceCategoriesPath();
|
||||
if (!existsSync(categoriesPath)) {
|
||||
return {};
|
||||
/**
|
||||
* Ensure user categories file exists by copying from builtin defaults.
|
||||
* Returns the path to the user categories file.
|
||||
*/
|
||||
export function ensureUserCategoriesFile(defaultCategoriesPath: string): string {
|
||||
const userPath = getPieceCategoriesPath();
|
||||
if (existsSync(userPath)) {
|
||||
return userPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(categoriesPath, 'utf-8');
|
||||
const parsed = parseYaml(content);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as PieceCategoriesFile;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
if (!existsSync(defaultCategoriesPath)) {
|
||||
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function savePieceCategoriesFile(data: PieceCategoriesFile): void {
|
||||
const categoriesPath = getPieceCategoriesPath();
|
||||
const dir = dirname(categoriesPath);
|
||||
const dir = dirname(userPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const content = stringifyYaml(data, { indent: 2 });
|
||||
writeFileSync(categoriesPath, content, 'utf-8');
|
||||
copyFileSync(defaultCategoriesPath, userPath);
|
||||
return userPath;
|
||||
}
|
||||
|
||||
/** Get piece categories configuration */
|
||||
export function getPieceCategoriesConfig(): PieceCategoryConfigNode | undefined {
|
||||
const data = loadPieceCategoriesFile();
|
||||
return data.categories;
|
||||
}
|
||||
/**
|
||||
* Reset user categories file by overwriting with builtin defaults.
|
||||
*/
|
||||
export function resetPieceCategories(defaultCategoriesPath: string): void {
|
||||
if (!existsSync(defaultCategoriesPath)) {
|
||||
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
|
||||
}
|
||||
|
||||
/** Set piece categories configuration */
|
||||
export function setPieceCategoriesConfig(categories: PieceCategoryConfigNode): void {
|
||||
const data = loadPieceCategoriesFile();
|
||||
data.categories = categories;
|
||||
savePieceCategoriesFile(data);
|
||||
const userPath = getPieceCategoriesPath();
|
||||
const dir = dirname(userPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
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 {
|
||||
loadDefaultCategories,
|
||||
getDefaultCategoriesPath,
|
||||
getPieceCategories,
|
||||
buildCategorizedPieces,
|
||||
findPieceCategories,
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
/**
|
||||
* 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 { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { z } from 'zod/v4';
|
||||
import { getProjectConfigPath } from '../paths.js';
|
||||
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
|
||||
import {
|
||||
getPieceCategoriesConfig,
|
||||
getShowOthersCategory,
|
||||
getOthersCategoryName,
|
||||
getBuiltinCategoryName,
|
||||
} from '../global/pieceCategories.js';
|
||||
import { ensureUserCategoriesFile } from '../global/pieceCategories.js';
|
||||
import { getLanguageResourcesDir } from '../../resources/index.js';
|
||||
import { listBuiltinPieceNames } from './pieceResolver.js';
|
||||
import type { PieceSource, PieceWithSource } from './pieceResolver.js';
|
||||
import type { PieceWithSource } from './pieceResolver.js';
|
||||
|
||||
const CategoryConfigSchema = z.object({
|
||||
piece_categories: z.record(z.string(), z.unknown()).optional(),
|
||||
@ -34,15 +31,10 @@ export interface CategoryConfig {
|
||||
pieceCategories: PieceCategoryNode[];
|
||||
showOthersCategory: boolean;
|
||||
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 {
|
||||
categories: PieceCategoryNode[];
|
||||
builtinCategories: PieceCategoryNode[];
|
||||
builtinCategoryName: string;
|
||||
allPieces: Map<string, PieceWithSource>;
|
||||
missingPieces: MissingPiece[];
|
||||
}
|
||||
@ -139,8 +131,6 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig
|
||||
pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel),
|
||||
showOthersCategory,
|
||||
othersCategoryName,
|
||||
builtinCategoryName: 'Builtin',
|
||||
hasCustomCategories: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -159,36 +149,25 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category
|
||||
*/
|
||||
export function loadDefaultCategories(): CategoryConfig | null {
|
||||
const lang = getLanguage();
|
||||
const filePath = join(getLanguageResourcesDir(lang), 'default-categories.yaml');
|
||||
const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
|
||||
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.
|
||||
* 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 {
|
||||
// Check user config from separate file (~/.takt/piece-categories.yaml)
|
||||
const userCategoriesNode = getPieceCategoriesConfig();
|
||||
if (userCategoriesNode) {
|
||||
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();
|
||||
export function getPieceCategories(): CategoryConfig | null {
|
||||
const defaultPath = getDefaultCategoriesPath();
|
||||
const userPath = ensureUserCategoriesFile(defaultPath);
|
||||
return loadCategoryConfigFromPath(userPath, userPath);
|
||||
}
|
||||
|
||||
function collectMissingPieces(
|
||||
@ -216,27 +195,22 @@ function collectMissingPieces(
|
||||
return missing;
|
||||
}
|
||||
|
||||
function buildCategoryTreeForSource(
|
||||
function buildCategoryTree(
|
||||
categories: PieceCategoryNode[],
|
||||
allPieces: Map<string, PieceWithSource>,
|
||||
sourceFilter: (source: PieceSource) => boolean,
|
||||
categorized: Set<string>,
|
||||
allowedPieces?: Set<string>,
|
||||
): PieceCategoryNode[] {
|
||||
const result: PieceCategoryNode[] = [];
|
||||
|
||||
for (const node of categories) {
|
||||
const pieces: string[] = [];
|
||||
for (const pieceName of node.pieces) {
|
||||
if (allowedPieces && !allowedPieces.has(pieceName)) continue;
|
||||
const entry = allPieces.get(pieceName);
|
||||
if (!entry) continue;
|
||||
if (!sourceFilter(entry.source)) continue;
|
||||
if (!allPieces.has(pieceName)) continue;
|
||||
pieces.push(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) {
|
||||
result.push({ name: node.name, pieces, children });
|
||||
}
|
||||
@ -249,16 +223,10 @@ function appendOthersCategory(
|
||||
categories: PieceCategoryNode[],
|
||||
allPieces: Map<string, PieceWithSource>,
|
||||
categorized: Set<string>,
|
||||
sourceFilter: (source: PieceSource) => boolean,
|
||||
othersCategoryName: string,
|
||||
): PieceCategoryNode[] {
|
||||
if (categories.some((node) => node.name === othersCategoryName)) {
|
||||
return categories;
|
||||
}
|
||||
|
||||
const uncategorized: string[] = [];
|
||||
for (const [pieceName, entry] of allPieces.entries()) {
|
||||
if (!sourceFilter(entry.source)) continue;
|
||||
for (const [pieceName] of allPieces.entries()) {
|
||||
if (categorized.has(pieceName)) continue;
|
||||
uncategorized.push(pieceName);
|
||||
}
|
||||
@ -267,70 +235,28 @@ function appendOthersCategory(
|
||||
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: [] }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* All pieces (user and builtin) are placed in a single category tree.
|
||||
*/
|
||||
export function buildCategorizedPieces(
|
||||
allPieces: Map<string, PieceWithSource>,
|
||||
config: CategoryConfig,
|
||||
): CategorizedPieces {
|
||||
const { builtinCategoryName } = config;
|
||||
|
||||
const ignoreMissing = new Set<string>();
|
||||
if (!getBuiltinPiecesEnabled()) {
|
||||
for (const name of listBuiltinPieceNames({ includeDisabled: true })) {
|
||||
@ -348,66 +274,19 @@ export function buildCategorizedPieces(
|
||||
ignoreMissing,
|
||||
);
|
||||
|
||||
const isBuiltin = (source: PieceSource): boolean => source === 'builtin';
|
||||
const isCustom = (source: PieceSource): boolean => source !== 'builtin';
|
||||
|
||||
const categorizedCustom = new Set<string>();
|
||||
const categories = buildCategoryTreeForSource(
|
||||
const categorized = new Set<string>();
|
||||
const categories = buildCategoryTree(
|
||||
config.pieceCategories,
|
||||
allPieces,
|
||||
isCustom,
|
||||
categorizedCustom,
|
||||
categorized,
|
||||
);
|
||||
|
||||
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
|
||||
? appendOthersCategory(
|
||||
categories,
|
||||
allPieces,
|
||||
categorizedCustom,
|
||||
isCustom,
|
||||
config.othersCategoryName,
|
||||
)
|
||||
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
||||
: 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 {
|
||||
categories: finalCategories,
|
||||
builtinCategories: finalBuiltinCategories,
|
||||
builtinCategoryName,
|
||||
allPieces,
|
||||
missingPieces,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user