diff --git a/src/tools/folder-tree-generator/folder-tree-generator.models.test.ts b/src/tools/folder-tree-generator/folder-tree-generator.models.test.ts new file mode 100644 index 00000000..6b2c1dca --- /dev/null +++ b/src/tools/folder-tree-generator/folder-tree-generator.models.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { buildTree, renderTree } from './folder-tree-generator.models'; + +describe('folder-tree-generator: buildTree', () => { + it('builds a hierarchy from indentation', () => { + const tree = buildTree('src\n main.ts\n tools\n index.ts\npackage.json'); + expect(tree).toEqual([ + { + name: 'src', + children: [ + { name: 'main.ts', children: [] }, + { name: 'tools', children: [{ name: 'index.ts', children: [] }] }, + ], + }, + { name: 'package.json', children: [] }, + ]); + }); + + it('treats tabs as four spaces', () => { + const tree = buildTree('a\n\tb'); + expect(tree[0]?.children[0]?.name).toBe('b'); + }); + + it('skips empty lines', () => { + const tree = buildTree('a\n\n b\n'); + expect(tree).toEqual([{ name: 'a', children: [{ name: 'b', children: [] }] }]); + }); + + it('returns to root level after deep nesting', () => { + const tree = buildTree('a\n b\n c\nd'); + expect(tree).toHaveLength(2); + expect(tree[1]?.name).toBe('d'); + }); +}); + +describe('folder-tree-generator: renderTree', () => { + it('renders box-drawing output like the tree command', () => { + const tree = buildTree('src\n main.ts\n tools\n index.ts\npackage.json'); + expect(renderTree(tree)).toBe([ + 'src', + '├── main.ts', + '└── tools', + ' └── index.ts', + 'package.json', + ].join('\n')); + }); + + it('uses the vertical continuation line for non-last parents', () => { + const tree = buildTree('a\n b\n c'); + expect(renderTree(tree)).toBe(['a', '├── b', '└── c'].join('\n')); + }); + + it('renders nested siblings with continuation prefixes', () => { + const tree = buildTree('a\n x\n y\n deep\nz'); + expect(renderTree(tree)).toBe([ + 'a', + '├── x', + '└── y', + ' └── deep', + 'z', + ].join('\n')); + }); +}); diff --git a/src/tools/folder-tree-generator/folder-tree-generator.models.ts b/src/tools/folder-tree-generator/folder-tree-generator.models.ts new file mode 100644 index 00000000..ea943d8e --- /dev/null +++ b/src/tools/folder-tree-generator/folder-tree-generator.models.ts @@ -0,0 +1,54 @@ +export interface TreeNode { + name: string + children: TreeNode[] +} + +/** Build a tree from indented lines. Depth = indentation (tabs or a fixed + * number of spaces). Lines ending with '/' or equal to '.'/'..' style + * markers keep their name as-is. */ +export function buildTree(input: string): TreeNode[] { + const root: TreeNode = { name: '', children: [] }; + const stack: { node: TreeNode; depth: number }[] = [{ node: root, depth: -1 }]; + + for (const rawLine of input.split('\n')) { + const line = rawLine.replace(/\r$/, '').replace(/\t/g, ' '); + if (!line.trim()) { + continue; + } + + const indent = line.length - line.trimStart().length; + const name = line.trim(); + + while (stack.length > 1 && stack[stack.length - 1]!.depth >= indent) { + stack.pop(); + } + const parent = stack[stack.length - 1]!.node; + const node: TreeNode = { name, children: [] }; + parent.children.push(node); + stack.push({ node, depth: indent }); + } + + return root.children; +} + +/** Render as the classic `tree` box-drawing output. */ +export function renderTree(nodes: TreeNode[]): string { + const lines: string[] = []; + + const walk = (children: TreeNode[], prefixes: string[], isRoot = false) => { + children.forEach((child, index) => { + const isLast = index === children.length - 1; + // root-level entries render without any tree prefix, matching the + // reference tools and the `tree` command's own output style + const branch = isRoot ? '' : (isLast ? '└── ' : '├── '); + lines.push([...prefixes, branch].join('') + child.name); + if (child.children.length > 0) { + const childPrefixes = isRoot ? [] : [...prefixes, isLast ? ' ' : '│ ']; + walk(child.children, childPrefixes); + } + }); + }; + + walk(nodes, [], true); + return lines.join('\n'); +} diff --git a/src/tools/folder-tree-generator/folder-tree-generator.vue b/src/tools/folder-tree-generator/folder-tree-generator.vue new file mode 100644 index 00000000..3ba91dfc --- /dev/null +++ b/src/tools/folder-tree-generator/folder-tree-generator.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/tools/folder-tree-generator/index.ts b/src/tools/folder-tree-generator/index.ts new file mode 100644 index 00000000..31bb00fe --- /dev/null +++ b/src/tools/folder-tree-generator/index.ts @@ -0,0 +1,12 @@ +import { Sitemap } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Folder tree generator', + path: '/folder-tree-generator', + description: 'Turn an indented file/folder listing into a box-drawing tree, like the tree command', + keywords: ['folder', 'tree', 'directory', 'structure', 'ascii', 'diagram', 'indent'], + component: () => import('./folder-tree-generator.vue'), + icon: Sitemap, + createdAt: new Date('2026-10-01'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..5d75c2e7 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -21,6 +21,7 @@ import { tool as ibanValidatorAndParser } from './iban-validator-and-parser'; import { tool as stringObfuscator } from './string-obfuscator'; import { tool as textDiff } from './text-diff'; import { tool as emojiPicker } from './emoji-picker'; +import { tool as folderTreeGenerator } from './folder-tree-generator'; import { tool as passwordStrengthAnalyser } from './password-strength-analyser'; import { tool as yamlToToml } from './yaml-to-toml'; import { tool as jsonToToml } from './json-to-toml'; @@ -179,7 +180,7 @@ export const toolsByCategory: ToolCategory[] = [ components: [ loremIpsumGenerator, textStatistics, - emojiPicker, + emojiPicker, folderTreeGenerator, stringObfuscator, textDiff, numeronymGenerator,