Skip to content

Commit

Permalink
feat(new tool): Folder Tree Diagram
Browse files Browse the repository at this point in the history
  • Loading branch information
sharevb committed Apr 28, 2024
1 parent 9eac9cb commit 6efc4ed
Show file tree
Hide file tree
Showing 10 changed files with 659 additions and 0 deletions.
44 changes: 44 additions & 0 deletions src/tools/folder-structure-diagram/folder-structure-diagram.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { generateTree } from './lib/generate-tree';
import { parseInput } from './lib/parse-input';
import { withDefaultOnError } from '@/utils/defaults';
const inputStructure = ref([
'my-app',
' src',
' index.html',
' main.ts',
' main.scss',
' - build',
' - index.html',
' main.js',
' main.css',
'',
' ',
' .prettierrc.json',
' .gitlab-ci.yml',
' README.md',
'empty dir',
].join('\n'));
const outputTree = computed(() => withDefaultOnError(() => generateTree(parseInput(inputStructure.value)), ''));
</script>

<template>
<div>
<c-input-text
v-model:value="inputStructure"
label="Your indented structure"
placeholder="Paste your indented structure here..."
rows="20"
multiline
raw-text
monospace
/>

<n-divider />

<n-form-item label="Your tree-like structure:">
<TextareaCopyable :value="outputTree" />
</n-form-item>
</div>
</template>
12 changes: 12 additions & 0 deletions src/tools/folder-structure-diagram/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Folder } from '@vicons/tabler';
import { defineTool } from '../tool';

export const tool = defineTool({
name: 'Folder Structure Diagram',
path: '/folder-structure-diagram',
description: 'tree-like utility for generating ASCII folder structure diagrams',
keywords: ['folder', 'structure', 'diagram', 'tree', 'ascii'],
component: () => import('./folder-structure-diagram.vue'),
icon: Folder,
createdAt: new Date('2024-04-20'),
});
20 changes: 20 additions & 0 deletions src/tools/folder-structure-diagram/lib/FileStructure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Represents a single item in a file system
* (i.e. a file or a folder)
*/
export interface FileStructure {
/** The name of the file or folder */
name: string

/** If a folder, the contents of the folder */
children: FileStructure[]

/**
* The number of spaces in front of the name
* in the original source string
*/
indentCount: number

/** The parent directory of this file or folder */
parent: FileStructure | null
}
164 changes: 164 additions & 0 deletions src/tools/folder-structure-diagram/lib/generate-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { generateTree } from './generate-tree';
import { mockInput } from './mock-input';
import { parseInput } from './parse-input';

describe('generateTree', () => {
it('returns an UTF-8 representation of the provided FileStructure object', () => {
const actual = generateTree(parseInput(mockInput));

const expected = `
.
├── my-app
│ ├── src
│ │ ├── index.html
│ │ ├── main.ts
│ │ └── main.scss
│ ├── build
│ │ ├── index.html
│ │ ├── main.js
│ │ └── main.css
│ ├── .prettierrc.json
│ ├── .gitlab-ci.yml
│ └── README.md
└── empty dir
`.trim();

expect(actual).toEqual(expected);
});

it('returns an ASCII representation of the provided FileStructure object', () => {
const actual = generateTree(parseInput(mockInput), { charset: 'ascii' });

const expected = `
.
|-- my-app
| |-- src
| | |-- index.html
| | |-- main.ts
| | \`-- main.scss
| |-- build
| | |-- index.html
| | |-- main.js
| | \`-- main.css
| |-- .prettierrc.json
| |-- .gitlab-ci.yml
| \`-- README.md
\`-- empty dir
`.trim();

expect(actual).toEqual(expected);
});

it('does not render lines for parent directories that have already printed all of their children', () => {
const input = `
grandparent
parent
child
parent
child
grandchild
`;

const actual = generateTree(parseInput(input));

const expected = `
.
└── grandparent
├── parent
│ └── child
└── parent
└── child
└── grandchild
`.trim();

expect(actual).toEqual(expected);
});

it('appends a trailing slash to directories if trailingDirSlash === true', () => {
const input = `
grandparent
parent/
child
parent//
child
grandchild
`;

const actual = generateTree(parseInput(input), { trailingDirSlash: true });

const expected = `
.
└── grandparent/
├── parent/
│ └── child
└── parent//
└── child/
└── grandchild
`.trim();

expect(actual).toEqual(expected);
});

it('prints each items\' full path if fullPath === true', () => {
const input = `
grandparent
parent/
child
parent//
child
grandchild
`;

const actual = generateTree(parseInput(input), { fullPath: true });

const expected = `
.
└── ./grandparent
├── ./grandparent/parent/
│ └── ./grandparent/parent/child
└── ./grandparent/parent//
└── ./grandparent/parent//child
└── ./grandparent/parent//child/grandchild
`.trim();

expect(actual).toEqual(expected);
});

it('does not render the root dot if rootDot === false', () => {
const input = `
grandparent
parent
child
parent
child
grandchild
`;

const actual = generateTree(parseInput(input), { rootDot: false });

const expected = `
grandparent
├── parent
│ └── child
└── parent
└── child
└── grandchild
`.trim();

expect(actual).toEqual(expected);
});
});
138 changes: 138 additions & 0 deletions src/tools/folder-structure-diagram/lib/generate-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { RecursiveArray } from 'lodash';
import defaultsDeep from 'lodash.defaultsdeep';
import flattenDeep from 'lodash.flattendeep';
import last from 'lodash.last';
import type { FileStructure } from './FileStructure';
import { LINE_STRINGS } from './line-strings';

/**
* Represents all rendering options available
* when calling `generateTree`
*/
interface GenerateTreeOptions {
/**
* Which set of characters to use when
* rendering directory lines
*/
charset?: 'ascii' | 'utf-8'

/**
* Whether or not to append trailing slashes
* to directories. Items that already include a
* trailing slash will not have another appended.
*/
trailingDirSlash?: boolean

/**
* Whether or not to print the full
* path of the item
*/
fullPath?: boolean

/**
* Whether or not to render a dot as the root of the tree
*/
rootDot?: boolean
}

/** The default options if no options are provided */
const defaultOptions: GenerateTreeOptions = {
charset: 'utf-8',
trailingDirSlash: false,
fullPath: false,
rootDot: true,
};

/**
* Generates an ASCII tree diagram, given a FileStructure
* @param structure The FileStructure object to convert into ASCII
* @param options The rendering options
*/
export function generateTree(structure: FileStructure,
options?: GenerateTreeOptions): string {
return flattenDeep([
getAsciiLine(structure, defaultsDeep({}, options, defaultOptions)),
structure.children.map(c => generateTree(c, options)) as RecursiveArray<
string
>,
])
// Remove null entries. Should only occur for the very first node
// when `options.rootDot === false`
.filter(line => line != null)
.join('\n');
}

/**
* Returns a line of ASCII that represents
* a single FileStructure object
* @param structure The file to render
* @param options The rendering options
*/
function getAsciiLine(structure: FileStructure,
options: GenerateTreeOptions): string | null {
const lines = LINE_STRINGS[options.charset as string];

// Special case for the root element
if (!structure.parent) {
return options.rootDot ? structure.name : null;
}

const chunks = [
isLastChild(structure) ? lines.LAST_CHILD : lines.CHILD,
getName(structure, options),
];

let current = structure.parent;
while (current && current.parent) {
chunks.unshift(isLastChild(current) ? lines.EMPTY : lines.DIRECTORY);
current = current.parent;
}

// Join all the chunks together to create the final line.
// If we're not rendering the root `.`, chop off the first 4 characters.
return chunks.join('').substring(options.rootDot ? 0 : lines.CHILD.length);
}

/**
* Returns the name of a file or folder according to the
* rules specified by the rendering rules
* @param structure The file or folder to get the name of
* @param options The rendering options
*/
function getName(structure: FileStructure,
options: GenerateTreeOptions): string {
const nameChunks = [structure.name];

// Optionally append a trailing slash
if (
// if the trailing slash option is enabled
options.trailingDirSlash
// and if the item has at least one child
&& structure.children.length > 0
// and if the item doesn't already have a trailing slash
&& !/\/\s*$/.test(structure.name)
) {
nameChunks.push('/');
}

// Optionally prefix the name with its full path
if (options.fullPath && structure.parent && structure.parent) {
nameChunks.unshift(
getName(
structure.parent,
defaultsDeep({}, { trailingDirSlash: true }, options),
),
);
}

return nameChunks.join('');
}

/**
* A utility function do determine if a file or folder
* is the last child of its parent
* @param structure The file or folder to test
*/
function isLastChild(structure: FileStructure): boolean {
return Boolean(structure.parent && last(structure.parent.children) === structure);
}

0 comments on commit 6efc4ed

Please sign in to comment.