Skip to content

Commit 7ec2feb

Browse files
committed
initial commit
0 parents commit 7ec2feb

File tree

7 files changed

+343
-0
lines changed

7 files changed

+343
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Node
2+
node_modules/
3+
package-lock.json
4+
5+
# Build artifacts
6+
bin/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 RuskyDev
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the “Software”), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21+
DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# @ruskydev/better-tree
2+
3+
A fast, customizable directory tree viewer for the terminal.
4+
5+
6+
## Installation
7+
8+
You can run it directly with `npx` (no install needed):
9+
10+
```bash
11+
npx @ruskydev/better-tree --help
12+
````
13+
14+
Or install globally:
15+
16+
```bash
17+
npm install -g @ruskydev/better-tree
18+
better-tree --help
19+
```
20+
21+
## Usage
22+
23+
```bash
24+
better-tree [directory] [options]
25+
```
26+
27+
### Options
28+
29+
```
30+
--ignore <patterns...> Ignore matching files/folders (supports *, **)
31+
--depth <n> Limit recursion depth
32+
--files-only Show only files
33+
--dirs-only Show only directories
34+
--help Show this help message
35+
```
36+
37+
### Examples
38+
39+
```bash
40+
# Show current directory tree
41+
npx @ruskydev/better-tree .
42+
43+
# Ignore node_modules and dist
44+
npx @ruskydev/better-tree src --ignore node_modules dist
45+
46+
# Limit depth to 2
47+
npx @ruskydev/better-tree --depth 2
48+
49+
# Show only directories
50+
npx @ruskydev/better-tree --dirs-only
51+
```
52+
53+
## License
54+
This project is licensed under the [MIT License](LICENSE).

better-tree.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
import fs from "fs/promises";
3+
import path from "path";
4+
import process from "process";
5+
6+
// ANSI colors
7+
const colors = {
8+
reset: "\x1b[0m",
9+
blue: "\x1b[34m", // directories
10+
white: "\x1b[37m", // files
11+
cyan: "\x1b[36m", // symlinks
12+
};
13+
14+
// --------------------
15+
// HELP MESSAGE
16+
// --------------------
17+
function showHelp() {
18+
console.log(`
19+
Usage: better-tree [directory] [options]
20+
21+
Options:
22+
--ignore <patterns...> Ignore matching files/folders (supports *, **)
23+
--depth <n> Limit recursion depth
24+
--files-only Show only files
25+
--dirs-only Show only directories
26+
--help Show this help message
27+
28+
Examples:
29+
better-tree .
30+
better-tree src --ignore node_modules dist
31+
better-tree --depth 2 --files-only
32+
better-tree --dirs-only
33+
`);
34+
process.exit(0);
35+
}
36+
37+
// --------------------
38+
// CLI ARGUMENTS PARSING
39+
// --------------------
40+
const args = process.argv.slice(2);
41+
if (args.includes("--help")) showHelp();
42+
43+
function extractOption(optionName, defaultValue = null, valueIndexOffset = 1) {
44+
const index = args.indexOf(optionName);
45+
if (index !== -1) {
46+
const value = args[index + valueIndexOffset];
47+
args.splice(index, valueIndexOffset + 1);
48+
return value ?? defaultValue;
49+
}
50+
return defaultValue;
51+
}
52+
53+
function hasFlag(flag) {
54+
return args.includes(flag);
55+
}
56+
57+
const ignoreIndex = args.indexOf("--ignore");
58+
const ignorePatterns = ignoreIndex !== -1 ? args.slice(ignoreIndex + 1) : [];
59+
if (ignoreIndex !== -1) args.splice(ignoreIndex);
60+
61+
const maxDepth = parseInt(extractOption("--depth", Infinity), 10) || Infinity;
62+
const filesOnly = hasFlag("--files-only");
63+
const dirsOnly = hasFlag("--dirs-only");
64+
const targetDir = path.resolve(args[0] || ".");
65+
66+
// --------------------
67+
// IGNORE PATTERNS
68+
// --------------------
69+
const compiledPatterns = ignorePatterns.map((pattern) => ({
70+
regex: new RegExp(
71+
"^" +
72+
pattern
73+
.replace(/\./g, "\\.")
74+
.replace(/\*\*/g, ".*")
75+
.replace(/\*/g, "[^/]*") +
76+
"$",
77+
),
78+
}));
79+
80+
function shouldIgnore(relativePath) {
81+
return compiledPatterns.some(
82+
({ regex }) =>
83+
regex.test(relativePath) || regex.test(path.basename(relativePath)),
84+
);
85+
}
86+
87+
// --------------------
88+
// CONCURRENCY LIMITER
89+
// --------------------
90+
let activeCount = 0;
91+
const queue = [];
92+
const maxConcurrency = 20;
93+
94+
function limitConcurrency(task) {
95+
return new Promise((resolve, reject) => {
96+
const run = async () => {
97+
activeCount++;
98+
try {
99+
const result = await task();
100+
resolve(result);
101+
} catch (err) {
102+
reject(err);
103+
} finally {
104+
activeCount--;
105+
if (queue.length) queue.shift()();
106+
}
107+
};
108+
if (activeCount < maxConcurrency) {
109+
run();
110+
} else {
111+
queue.push(run);
112+
}
113+
});
114+
}
115+
116+
// --------------------
117+
// TREE PRINTING
118+
// --------------------
119+
async function printTree(dir, prefix = "", depth = 0, relativeBase = "") {
120+
if (depth > maxDepth) return;
121+
122+
let entries;
123+
try {
124+
entries = await fs.readdir(dir, { withFileTypes: true });
125+
} catch {
126+
return;
127+
}
128+
129+
const filteredEntries = entries
130+
.map((entry) => ({
131+
entry,
132+
relativePath: path.join(relativeBase, entry.name).replace(/\\/g, "/"),
133+
}))
134+
.filter((item) => !shouldIgnore(item.relativePath))
135+
.filter((item) => {
136+
if (filesOnly && item.entry.isDirectory()) return false;
137+
if (dirsOnly && !item.entry.isDirectory()) return false;
138+
return true;
139+
})
140+
.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
141+
142+
const lastIndex = filteredEntries.length - 1;
143+
144+
for (let i = 0; i < filteredEntries.length; i++) {
145+
const { entry } = filteredEntries[i];
146+
const connector = i === lastIndex ? "└── " : "├── ";
147+
148+
let nameColored = entry.name;
149+
if (entry.isDirectory()) {
150+
nameColored = colors.blue + entry.name + colors.reset;
151+
} else if (entry.isSymbolicLink()) {
152+
nameColored = colors.cyan + entry.name + colors.reset;
153+
} else {
154+
nameColored = colors.white + entry.name + colors.reset;
155+
}
156+
157+
console.log(prefix + connector + nameColored);
158+
159+
if (entry.isDirectory() && !filesOnly) {
160+
const nextPrefix = prefix + (i === lastIndex ? " " : "│ ");
161+
await limitConcurrency(() =>
162+
printTree(
163+
path.join(dir, entry.name),
164+
nextPrefix,
165+
depth + 1,
166+
filteredEntries[i].relativePath,
167+
),
168+
);
169+
}
170+
}
171+
}
172+
173+
// --------------------
174+
// START
175+
// --------------------
176+
await printTree(targetDir);

package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@ruskydev/better-tree",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"license": "MIT",
6+
"bin": {
7+
"better-tree": "./bin/better-tree.min.js"
8+
},
9+
"scripts": {
10+
"build": "npm run test && node ./scripts/build.js",
11+
"test": "vitest run tests",
12+
"prepare": "npm run build"
13+
},
14+
"devDependencies": {
15+
"esbuild": "^0.23.0",
16+
"vitest": "^1.6.0"
17+
}
18+
}

scripts/build.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { execSync } from "child_process";
2+
import { mkdirSync } from "fs";
3+
4+
mkdirSync("bin", { recursive: true });
5+
6+
execSync(
7+
`esbuild better-tree.js --bundle --platform=node --format=esm --minify --outfile=bin/better-tree.min.js`,
8+
{ stdio: "inherit" },
9+
);

tests/better-tree.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { execSync } from "child_process";
2+
import fs from "fs";
3+
import os from "os";
4+
import path from "path";
5+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
6+
7+
let testDir;
8+
let cliPath = path.resolve("./better-tree.js");
9+
10+
beforeAll(() => {
11+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "better-tree-"));
12+
13+
fs.writeFileSync(path.join(testDir, "file1.txt"), "hello");
14+
fs.writeFileSync(path.join(testDir, "file2.log"), "log");
15+
16+
fs.mkdirSync(path.join(testDir, "sub"));
17+
fs.writeFileSync(path.join(testDir, "sub", "nested.txt"), "nested");
18+
19+
fs.mkdirSync(path.join(testDir, "node_modules"));
20+
fs.writeFileSync(path.join(testDir, "node_modules", "ignore.js"), "ignore");
21+
});
22+
23+
afterAll(() => {
24+
fs.rmSync(testDir, { recursive: true, force: true });
25+
});
26+
27+
describe("better-tree CLI", () => {
28+
it("prints tree structure", () => {
29+
const output = execSync(`node ${cliPath} ${testDir}`, {
30+
encoding: "utf-8",
31+
});
32+
expect(output).toContain("file1.txt");
33+
expect(output).toContain("sub");
34+
});
35+
36+
it("respects --ignore", () => {
37+
const output = execSync(
38+
`node ${cliPath} ${testDir} --ignore node_modules`,
39+
{ encoding: "utf-8" },
40+
);
41+
expect(output).not.toContain("node_modules");
42+
});
43+
44+
it("respects --files-only", () => {
45+
const output = execSync(`node ${cliPath} ${testDir} --files-only`, {
46+
encoding: "utf-8",
47+
});
48+
expect(output).toContain("file1.txt");
49+
expect(output).not.toContain("sub");
50+
});
51+
52+
it("respects --dirs-only", () => {
53+
const output = execSync(`node ${cliPath} ${testDir} --dirs-only`, {
54+
encoding: "utf-8",
55+
});
56+
expect(output).toContain("sub");
57+
expect(output).not.toContain("file1.txt");
58+
});
59+
});

0 commit comments

Comments
 (0)