projclean 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/core/cleaner.js +60 -0
- package/dist/core/constants.js +41 -0
- package/dist/core/detector.js +60 -0
- package/dist/core/paths.js +35 -0
- package/dist/core/scanner.js +92 -0
- package/dist/core/size.js +81 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +98 -0
- package/dist/ui/App.js +158 -0
- package/dist/ui/ConfirmDialog.js +21 -0
- package/dist/ui/ProjectItem.js +15 -0
- package/dist/ui/ProjectList.js +74 -0
- package/dist/ui/StatusBar.js +7 -0
- package/dist/ui/columns.js +11 -0
- package/dist/ui/logger.js +20 -0
- package/dist/utils/format.js +50 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alexjcm
|
|
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 all
|
|
13
|
+
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 FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# projclean
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
Interactive TUI to reclaim disk space by finding and cleaning `build/` (Gradle) and `target/` (Maven) folders across all your JVM projects. Scans up to 8 directory levels deep from your home directory.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Fast parallel scanning** — uses `fast-glob` with up to 8 levels deep from `~/`, skipping irrelevant folders (`node_modules`, `.git`, `build`, `target`).
|
|
17
|
+
- **Safety validation** — verifies standard JVM build artifacts before marking a folder for deletion, preventing accidental removal of non-JVM `build/` directories.
|
|
18
|
+
- **Multi-module aware** — consolidates multi-module Gradle/Maven projects (JAR, WAR, EAR, etc.) into a single entry showing accurate module count and combined size.
|
|
19
|
+
- **Concurrent size calculation** — folder sizes are computed in parallel with up to 16 concurrent I/O workers while the TUI is already interactive.
|
|
20
|
+
- **Clean interactive TUI** — keyboard-driven list with selection, bulk operations, and a confirmation dialog before any deletion.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Node.js >= 24
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx projclean
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install globally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g projclean
|
|
36
|
+
projclean
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Key Bindings
|
|
40
|
+
|
|
41
|
+
| Key | Action |
|
|
42
|
+
|-----|--------|
|
|
43
|
+
| `↑` / `k` | Move cursor up |
|
|
44
|
+
| `↓` / `j` | Move cursor down |
|
|
45
|
+
| `g` / `G` | Jump to top / bottom of list |
|
|
46
|
+
| `SPACE` | Select / deselect (advances cursor) |
|
|
47
|
+
| `ENTER` | Select / deselect (stays on row) |
|
|
48
|
+
| `a` | Select / deselect all projects |
|
|
49
|
+
| `D` | Delete selected projects |
|
|
50
|
+
| `Q` / `ESC` | Quit |
|
|
51
|
+
|
|
52
|
+
## Running Locally
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/alexjcm/projclean.git
|
|
56
|
+
cd projclean
|
|
57
|
+
npm install
|
|
58
|
+
npm run dev
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Building
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run build
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Compiles TypeScript to `dist/` and marks the output as executable. Test locally with:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm link
|
|
71
|
+
projclean
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Future Enhancements
|
|
75
|
+
|
|
76
|
+
- [ ] Support for other ecosystems (Node.js `node_modules`, Python `__pycache__`, etc.)
|
|
77
|
+
- [ ] iOS project cleanup (`DerivedData`)
|
|
78
|
+
- [ ] Filter / search by project name
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { CONCURRENCY } from './constants.js';
|
|
3
|
+
function getActionableMessage(code) {
|
|
4
|
+
switch (code) {
|
|
5
|
+
case 'EACCES':
|
|
6
|
+
return 'No permission to delete. Run with sudo or adjust folder permissions.';
|
|
7
|
+
case 'EPERM':
|
|
8
|
+
case 'EBUSY':
|
|
9
|
+
return 'Folder is in use by another process. Close IDE or run: ./gradlew --stop';
|
|
10
|
+
case 'ENOENT':
|
|
11
|
+
return 'Folder no longer exists — probably deleted earlier.';
|
|
12
|
+
default:
|
|
13
|
+
return `Unknown error (${code}). Check system logs.`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Deletes the build folders of the given projects.
|
|
18
|
+
*/
|
|
19
|
+
export async function cleanProjects(projects) {
|
|
20
|
+
const results = [];
|
|
21
|
+
let index = 0;
|
|
22
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, projects.length) }).map(async () => {
|
|
23
|
+
while (index < projects.length) {
|
|
24
|
+
const project = projects[index++];
|
|
25
|
+
if (!project)
|
|
26
|
+
continue; // TS safety
|
|
27
|
+
try {
|
|
28
|
+
const paths = [...project.submoduleBuildPaths];
|
|
29
|
+
if (project.buildPath !== null) {
|
|
30
|
+
paths.push(project.buildPath);
|
|
31
|
+
}
|
|
32
|
+
await Promise.all(paths.map(p => fs.rm(p, { recursive: true, force: true })));
|
|
33
|
+
results.push({
|
|
34
|
+
project,
|
|
35
|
+
freed: project.size, // Assuming we trust the snapshot sized before cleaning
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
let code = 'ENOENT'; // fallback
|
|
40
|
+
if (err instanceof Error && 'code' in err && typeof err.code === 'string') {
|
|
41
|
+
// Coerce to known code or default message
|
|
42
|
+
const c = err.code;
|
|
43
|
+
if (['EACCES', 'EPERM', 'EBUSY', 'ENOENT'].includes(c)) {
|
|
44
|
+
code = c;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
results.push({
|
|
48
|
+
project,
|
|
49
|
+
freed: null,
|
|
50
|
+
error: {
|
|
51
|
+
code,
|
|
52
|
+
message: getActionableMessage(code)
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await Promise.all(workers);
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximum directory depth to scan from the user's home directory.
|
|
3
|
+
*/
|
|
4
|
+
export const SCAN_DEPTH = 8;
|
|
5
|
+
/**
|
|
6
|
+
* Registry of supported build systems. Order determines precedence.
|
|
7
|
+
*/
|
|
8
|
+
export const SUPPORTED_BUILD_SYSTEMS = [
|
|
9
|
+
{
|
|
10
|
+
type: 'gradle',
|
|
11
|
+
primaryIndicator: 'build.gradle',
|
|
12
|
+
alternativeIndicators: ['build.gradle.kts'],
|
|
13
|
+
outputDir: 'build',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
type: 'maven',
|
|
17
|
+
primaryIndicator: 'pom.xml',
|
|
18
|
+
outputDir: 'target',
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Maximum number of concurrent I/O operations during folder size calculation.
|
|
23
|
+
*/
|
|
24
|
+
export const CONCURRENCY = 16;
|
|
25
|
+
/**
|
|
26
|
+
* Directory names to skip entirely during the recursive scan.
|
|
27
|
+
*/
|
|
28
|
+
export const IGNORED_DIRS = new Set([
|
|
29
|
+
'.git',
|
|
30
|
+
'.idea',
|
|
31
|
+
'.vscode',
|
|
32
|
+
'node_modules',
|
|
33
|
+
'target',
|
|
34
|
+
'build',
|
|
35
|
+
]);
|
|
36
|
+
export const EXIT_CODES = {
|
|
37
|
+
SUCCESS: 0,
|
|
38
|
+
FATAL_ERROR: 1,
|
|
39
|
+
INVALID_ARGUMENT: 2,
|
|
40
|
+
SIGINT: 130,
|
|
41
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizePath, toBuildPath } from './paths.js';
|
|
4
|
+
import { SUPPORTED_BUILD_SYSTEMS } from './constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* Verifies if a directory contains standard JVM build output subdirectories.
|
|
7
|
+
* This is a "strict" check to prevent accidental deletion of non-JVM folders.
|
|
8
|
+
*/
|
|
9
|
+
async function isJVMBuildFolder(dir, type) {
|
|
10
|
+
const subdirs = type === 'gradle'
|
|
11
|
+
? ['classes', 'libs', 'resources', 'tmp', 'kotlin', 'reports', 'intermediates', 'outputs', 'test-results', 'generated']
|
|
12
|
+
: ['classes', 'generated-sources', 'maven-status', 'surefire-reports', 'maven-archiver', 'test-classes'];
|
|
13
|
+
// Run all access checks in parallel — resolves as soon as the first one succeeds.
|
|
14
|
+
try {
|
|
15
|
+
await Promise.any(subdirs.map((sub) => fs.access(path.join(dir, sub))));
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Attempts to classify and validate a directory as a JVM project.
|
|
24
|
+
*/
|
|
25
|
+
export async function detectProject(dir) {
|
|
26
|
+
let buildType = null;
|
|
27
|
+
for (const system of SUPPORTED_BUILD_SYSTEMS) {
|
|
28
|
+
const indicators = [system.primaryIndicator, ...(system.alternativeIndicators ?? [])];
|
|
29
|
+
try {
|
|
30
|
+
await Promise.any(indicators.map((ind) => fs.access(path.join(dir, ind))));
|
|
31
|
+
buildType = system.type;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// None of this system's indicators exist — try next
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (buildType === null)
|
|
39
|
+
return null;
|
|
40
|
+
// Validate build folder existence AND standard contents (safety first!)
|
|
41
|
+
const expectedBuildPath = toBuildPath(dir, buildType);
|
|
42
|
+
let buildPath = null;
|
|
43
|
+
try {
|
|
44
|
+
await fs.access(expectedBuildPath);
|
|
45
|
+
if (await isJVMBuildFolder(expectedBuildPath, buildType)) {
|
|
46
|
+
buildPath = expectedBuildPath;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// build folder does not exist — buildPath stays null
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
id: normalizePath(dir),
|
|
54
|
+
rootPath: normalizePath(dir),
|
|
55
|
+
buildPath,
|
|
56
|
+
buildType,
|
|
57
|
+
submoduleBuildPaths: [],
|
|
58
|
+
size: null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SUPPORTED_BUILD_SYSTEMS } from './constants.js';
|
|
4
|
+
/**
|
|
5
|
+
* Returns the user's home directory as the root for scanning,
|
|
6
|
+
* with separators normalized to forward slashes.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveScanRoot() {
|
|
9
|
+
return normalizePath(os.homedir());
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Normalizes path separators to forward slashes for consistent
|
|
13
|
+
* internal storage and fast-glob patterns.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizePath(p) {
|
|
16
|
+
return path.normalize(p).replaceAll(path.sep, '/');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Replaces the home directory prefix with '~' if the path is inside it.
|
|
20
|
+
*/
|
|
21
|
+
export function replaceHomeWithTilde(p) {
|
|
22
|
+
const home = os.homedir().replaceAll(path.sep, '/');
|
|
23
|
+
const normalized = normalizePath(p);
|
|
24
|
+
return normalized.startsWith(home)
|
|
25
|
+
? `~${normalized.slice(home.length)}`
|
|
26
|
+
: normalized;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Constructs the absolute path to the build output folder of a project.
|
|
30
|
+
*/
|
|
31
|
+
export function toBuildPath(projectRoot, type) {
|
|
32
|
+
const config = SUPPORTED_BUILD_SYSTEMS.find((s) => s.type === type);
|
|
33
|
+
const folder = config?.outputDir ?? 'build';
|
|
34
|
+
return normalizePath(path.join(projectRoot, folder));
|
|
35
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { resolveScanRoot, normalizePath } from './paths.js';
|
|
5
|
+
import { detectProject } from './detector.js';
|
|
6
|
+
import { SCAN_DEPTH, IGNORED_DIRS, SUPPORTED_BUILD_SYSTEMS } from './constants.js';
|
|
7
|
+
/**
|
|
8
|
+
* Scans the user's home directory for JVM projects with existing build folders.
|
|
9
|
+
*/
|
|
10
|
+
export class Scanner extends EventEmitter {
|
|
11
|
+
async scan() {
|
|
12
|
+
const root = resolveScanRoot();
|
|
13
|
+
const ignore = [...IGNORED_DIRS].map((d) => `**/${d}/**`);
|
|
14
|
+
const patterns = SUPPORTED_BUILD_SYSTEMS.flatMap((sys) => {
|
|
15
|
+
const indicators = [sys.primaryIndicator, ...(sys.alternativeIndicators ?? [])];
|
|
16
|
+
return indicators.map((ind) => `${root}/**/${ind}`);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
const files = await fg(patterns, {
|
|
20
|
+
onlyFiles: true,
|
|
21
|
+
deep: SCAN_DEPTH,
|
|
22
|
+
ignore,
|
|
23
|
+
suppressErrors: true,
|
|
24
|
+
});
|
|
25
|
+
// Deduplicate directories (a project may have both build.gradle and build.gradle.kts)
|
|
26
|
+
const candidateDirs = [
|
|
27
|
+
...new Set(files.map((f) => normalizePath(path.dirname(f)))),
|
|
28
|
+
];
|
|
29
|
+
// Sort by path so that parent directories always come before their children.
|
|
30
|
+
candidateDirs.sort();
|
|
31
|
+
const roots = new Map();
|
|
32
|
+
const emittedIds = new Set();
|
|
33
|
+
const BATCH_SIZE = 10;
|
|
34
|
+
for (let i = 0; i < candidateDirs.length; i += BATCH_SIZE) {
|
|
35
|
+
const batch = candidateDirs.slice(i, i + BATCH_SIZE);
|
|
36
|
+
const results = await Promise.all(batch.map((dir) => detectProject(dir)));
|
|
37
|
+
for (const project of results) {
|
|
38
|
+
if (!project)
|
|
39
|
+
continue;
|
|
40
|
+
// Find the nearest registered ancestor — O(depth) path-segment walk
|
|
41
|
+
// instead of O(n_roots) linear scan across all known roots.
|
|
42
|
+
const parent = (() => {
|
|
43
|
+
const segments = project.rootPath.split('/');
|
|
44
|
+
for (let i = segments.length - 1; i > 0; i--) {
|
|
45
|
+
const candidate = segments.slice(0, i).join('/');
|
|
46
|
+
const root = roots.get(candidate);
|
|
47
|
+
if (root !== undefined)
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
})();
|
|
52
|
+
if (parent !== undefined) {
|
|
53
|
+
if (project.buildPath !== null) {
|
|
54
|
+
// Deduplicate build paths within a project
|
|
55
|
+
if (!parent.submoduleBuildPaths.includes(project.buildPath)) {
|
|
56
|
+
parent.submoduleBuildPaths.push(project.buildPath);
|
|
57
|
+
}
|
|
58
|
+
if (emittedIds.has(parent.id)) {
|
|
59
|
+
this.emit('submodule', {
|
|
60
|
+
parentId: parent.id,
|
|
61
|
+
buildPath: project.buildPath,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Parent was staged but not emitted yet; now it has a reason to exist!
|
|
66
|
+
emittedIds.add(parent.id);
|
|
67
|
+
this.emit('project', { ...parent, submoduleBuildPaths: [...parent.submoduleBuildPaths] });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// This is a potential root
|
|
73
|
+
const newRoot = { ...project, submoduleBuildPaths: [] };
|
|
74
|
+
roots.set(newRoot.id, newRoot);
|
|
75
|
+
if (newRoot.buildPath !== null) {
|
|
76
|
+
emittedIds.add(newRoot.id);
|
|
77
|
+
this.emit('project', { ...newRoot });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Give the UI a chance to render before the next batch
|
|
82
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.emit('done');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONCURRENCY } from './constants.js';
|
|
4
|
+
/**
|
|
5
|
+
* Calculates the total size of a directory recursively.
|
|
6
|
+
* Handles missing permissions gracefully by ignoring restricted folders.
|
|
7
|
+
*
|
|
8
|
+
* @param dirPath Absolute path to the directory
|
|
9
|
+
* @returns Total size in bytes, or null if the base directory is inaccessible
|
|
10
|
+
*/
|
|
11
|
+
export async function calculateSize(dirPath) {
|
|
12
|
+
try {
|
|
13
|
+
const stats = await fs.stat(dirPath);
|
|
14
|
+
if (!stats.isDirectory()) {
|
|
15
|
+
return stats.size;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isSystemError(error) && ['EACCES', 'EPERM', 'ENOENT', 'EBUSY'].includes(error.code)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
let totalSize = 0;
|
|
25
|
+
const dirQueue = [dirPath];
|
|
26
|
+
let activeWorkers = 0;
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let hasError = false;
|
|
29
|
+
const processQueue = () => {
|
|
30
|
+
if (hasError)
|
|
31
|
+
return;
|
|
32
|
+
if (dirQueue.length === 0 && activeWorkers === 0) {
|
|
33
|
+
resolve(totalSize);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
while (dirQueue.length > 0 && activeWorkers < CONCURRENCY) {
|
|
37
|
+
const currentDir = dirQueue.shift();
|
|
38
|
+
activeWorkers++;
|
|
39
|
+
processDirectory(currentDir).finally(() => {
|
|
40
|
+
activeWorkers--;
|
|
41
|
+
processQueue();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const processDirectory = async (dir) => {
|
|
46
|
+
try {
|
|
47
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const entryPath = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
dirQueue.push(entryPath);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Using stat for accurate size, or assuming file size is small enough
|
|
55
|
+
// For extreme speed, we could skip stat for files, or just stat files concurrently too
|
|
56
|
+
try {
|
|
57
|
+
const fileStat = await fs.stat(entryPath);
|
|
58
|
+
totalSize += fileStat.size;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (isSystemError(err) && ['EACCES', 'EPERM', 'ENOENT'].includes(err.code))
|
|
62
|
+
continue;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (isSystemError(err) && ['EACCES', 'EPERM', 'ENOENT'].includes(err.code)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
hasError = true;
|
|
73
|
+
reject(err);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
processQueue();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function isSystemError(err) {
|
|
80
|
+
return err instanceof Error && 'code' in err && typeof err.code === 'string';
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import util from 'node:util';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { render } from 'ink';
|
|
8
|
+
import { App } from './ui/App.js';
|
|
9
|
+
import { logger } from './ui/logger.js';
|
|
10
|
+
import { EXIT_CODES } from './core/constants.js';
|
|
11
|
+
import { formatBytes } from './utils/format.js';
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
function getVersion() {
|
|
14
|
+
try {
|
|
15
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
17
|
+
return pkg.version || '0.0.0';
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return '0.0.0';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function printHelp() {
|
|
24
|
+
const v = getVersion();
|
|
25
|
+
console.log(`
|
|
26
|
+
projclean v${v}
|
|
27
|
+
|
|
28
|
+
Interactive CLI to detect and clean Maven target/ and Gradle build/ folders.
|
|
29
|
+
Scans up to 6 levels deep from your home directory to find valid JVM projects.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
projclean
|
|
33
|
+
|
|
34
|
+
Flags:
|
|
35
|
+
--version Print version and exit
|
|
36
|
+
--help Print this help message and exit
|
|
37
|
+
|
|
38
|
+
Shortcuts (when running):
|
|
39
|
+
↑/k, ↓/j Navigate project list
|
|
40
|
+
g / G Jump to top / bottom
|
|
41
|
+
SPACE Select/Deselect project (advances cursor)
|
|
42
|
+
ENTER Select/Deselect project (stays on row)
|
|
43
|
+
a Select/Deselect all projects
|
|
44
|
+
D Initiate deletion of selected projects
|
|
45
|
+
Q, ESC Quit the application
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
function checkNodeVersion() {
|
|
49
|
+
const v = process.versions.node;
|
|
50
|
+
const major = parseInt(v.split('.')[0] || '0', 10);
|
|
51
|
+
if (major < 24) {
|
|
52
|
+
logger.error(`This CLI requires Node.js 24 or higher. You are using v${v}`);
|
|
53
|
+
process.exit(EXIT_CODES.FATAL_ERROR);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function main() {
|
|
57
|
+
let values;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = util.parseArgs({
|
|
60
|
+
options: {
|
|
61
|
+
version: { type: 'boolean', short: 'v' },
|
|
62
|
+
help: { type: 'boolean', short: 'h' },
|
|
63
|
+
},
|
|
64
|
+
strict: true,
|
|
65
|
+
});
|
|
66
|
+
values = parsed.values;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
logger.error('Invalid argument.', err);
|
|
70
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENT);
|
|
71
|
+
}
|
|
72
|
+
if (values.version) {
|
|
73
|
+
logger.info(`projclean v${getVersion()}`);
|
|
74
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
75
|
+
}
|
|
76
|
+
if (values.help) {
|
|
77
|
+
printHelp();
|
|
78
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
79
|
+
}
|
|
80
|
+
checkNodeVersion();
|
|
81
|
+
// Track cumulative space freed across the session
|
|
82
|
+
let totalFreedInSession = 0;
|
|
83
|
+
const handleSpaceFreed = (bytes) => {
|
|
84
|
+
totalFreedInSession += bytes;
|
|
85
|
+
};
|
|
86
|
+
const { waitUntilExit } = render(React.createElement(App, { onSpaceFreed: handleSpaceFreed }), {
|
|
87
|
+
exitOnCtrlC: false,
|
|
88
|
+
});
|
|
89
|
+
await waitUntilExit();
|
|
90
|
+
console.log(`\n Space released: ${formatBytes(totalFreedInSession)}\n`);
|
|
91
|
+
process.on('exit', () => {
|
|
92
|
+
process.stdout.write('\x1B[?25h');
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
main().catch((err) => {
|
|
96
|
+
logger.error('Unexpected fatal error', err);
|
|
97
|
+
process.exit(EXIT_CODES.FATAL_ERROR);
|
|
98
|
+
});
|
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
3
|
+
import { Box, Static, Text, useApp, useInput } from 'ink';
|
|
4
|
+
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import { Scanner } from '../core/scanner.js';
|
|
6
|
+
import { calculateSize } from '../core/size.js';
|
|
7
|
+
import { cleanProjects } from '../core/cleaner.js';
|
|
8
|
+
import { EXIT_CODES } from '../core/constants.js';
|
|
9
|
+
import { ProjectList } from './ProjectList.js';
|
|
10
|
+
import { StatusBar } from './StatusBar.js';
|
|
11
|
+
import { ConfirmDialog } from './ConfirmDialog.js';
|
|
12
|
+
import { formatBytes } from '../utils/format.js';
|
|
13
|
+
export const App = ({ onSpaceFreed }) => {
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const [projects, setProjects] = useState([]);
|
|
16
|
+
const [scanStatus, setScanStatus] = useState('scanning');
|
|
17
|
+
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
18
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
19
|
+
const [cleanResults, setCleanResults] = useState([]);
|
|
20
|
+
const [isCleaning, setIsCleaning] = useState(false);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const scanner = new Scanner();
|
|
23
|
+
scanner.on('project', (project) => {
|
|
24
|
+
setProjects((prev) => {
|
|
25
|
+
const exists = prev.some((p) => p.id === project.id);
|
|
26
|
+
if (exists)
|
|
27
|
+
return prev;
|
|
28
|
+
return [...prev, project];
|
|
29
|
+
});
|
|
30
|
+
// Calculate size for the project's own build folder
|
|
31
|
+
if (project.buildPath !== null) {
|
|
32
|
+
calculateSize(project.buildPath)
|
|
33
|
+
.then((size) => {
|
|
34
|
+
setProjects((prev) => prev.map((p) => p.id === project.id ? { ...p, size: (p.size ?? 0) + (size ?? 0) } : p));
|
|
35
|
+
})
|
|
36
|
+
.catch((err) => {
|
|
37
|
+
const msg = `Failed to size root for ${project.rootPath}`;
|
|
38
|
+
import('./logger.js').then(({ logger }) => logger.error(msg, err));
|
|
39
|
+
setProjects((prev) => prev.map((p) => (p.id === project.id ? { ...p, size: p.size ?? 0 } : p)));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// ALSO calculate size for any submodules already present
|
|
43
|
+
for (const subPath of project.submoduleBuildPaths) {
|
|
44
|
+
calculateSize(subPath)
|
|
45
|
+
.then((size) => {
|
|
46
|
+
setProjects((prev) => prev.map((p) => p.id === project.id ? { ...p, size: (p.size ?? 0) + (size ?? 0) } : p));
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
const msg = `Failed to size initial submodule ${subPath} of ${project.id}`;
|
|
50
|
+
import('./logger.js').then(({ logger }) => logger.error(msg, err));
|
|
51
|
+
setProjects((prev) => prev.map((p) => (p.id === project.id ? { ...p, size: p.size ?? 0 } : p)));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
scanner.on('submodule', ({ parentId, buildPath }) => {
|
|
56
|
+
setProjects((prev) => prev.map((p) => p.id === parentId
|
|
57
|
+
? { ...p, submoduleBuildPaths: [...p.submoduleBuildPaths, buildPath] }
|
|
58
|
+
: p));
|
|
59
|
+
// Start size calculation for the new submodule
|
|
60
|
+
calculateSize(buildPath)
|
|
61
|
+
.then((size) => {
|
|
62
|
+
setProjects((prev) => prev.map((p) => p.id === parentId ? { ...p, size: (p.size ?? 0) + (size ?? 0) } : p));
|
|
63
|
+
})
|
|
64
|
+
.catch((err) => {
|
|
65
|
+
const msg = `Failed to size submodule ${buildPath} of ${parentId}`;
|
|
66
|
+
import('./logger.js').then(({ logger }) => logger.error(msg, err));
|
|
67
|
+
setProjects((prev) => prev.map((p) => (p.id === parentId ? { ...p, size: p.size ?? 0 } : p)));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
scanner.on('done', () => {
|
|
71
|
+
setScanStatus('done');
|
|
72
|
+
});
|
|
73
|
+
scanner.on('error', (_err) => {
|
|
74
|
+
setScanStatus('done');
|
|
75
|
+
});
|
|
76
|
+
scanner.scan();
|
|
77
|
+
return () => {
|
|
78
|
+
scanner.removeAllListeners();
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
// Global Ctrl+C handler
|
|
82
|
+
useInput((input, key) => {
|
|
83
|
+
if (key.ctrl && input === 'c') {
|
|
84
|
+
process.exitCode = EXIT_CODES.SIGINT;
|
|
85
|
+
exit();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Handlers
|
|
89
|
+
const handleToggleSelection = useCallback((id) => {
|
|
90
|
+
setSelectedIds((prev) => {
|
|
91
|
+
const next = new Set(prev);
|
|
92
|
+
if (next.has(id))
|
|
93
|
+
next.delete(id);
|
|
94
|
+
else
|
|
95
|
+
next.add(id);
|
|
96
|
+
return next;
|
|
97
|
+
});
|
|
98
|
+
}, []);
|
|
99
|
+
const handleToggleAll = useCallback(() => {
|
|
100
|
+
setSelectedIds((prev) => {
|
|
101
|
+
if (prev.size === projects.length && projects.length > 0) {
|
|
102
|
+
return new Set(); // Deselect all
|
|
103
|
+
}
|
|
104
|
+
return new Set(projects.map((p) => p.id)); // Select all
|
|
105
|
+
});
|
|
106
|
+
}, [projects]);
|
|
107
|
+
const handleDeleteRequested = useCallback(() => {
|
|
108
|
+
if (selectedIds.size > 0) {
|
|
109
|
+
setConfirmOpen(true);
|
|
110
|
+
}
|
|
111
|
+
}, [selectedIds.size]);
|
|
112
|
+
const handleConfirmCancel = useCallback(() => {
|
|
113
|
+
setConfirmOpen(false);
|
|
114
|
+
}, []);
|
|
115
|
+
const handleConfirmAccept = useCallback(async () => {
|
|
116
|
+
setConfirmOpen(false);
|
|
117
|
+
setIsCleaning(true);
|
|
118
|
+
const selectedProjects = projects.filter((p) => selectedIds.has(p.id));
|
|
119
|
+
const results = await cleanProjects(selectedProjects);
|
|
120
|
+
// Calculate total freed in this batch and report it to the caller
|
|
121
|
+
const freedInBatch = results.reduce((acc, r) => acc + (r.freed ?? 0), 0);
|
|
122
|
+
onSpaceFreed(freedInBatch);
|
|
123
|
+
// Append to static logs with a unique key to prevent Ink duplicate key warnings
|
|
124
|
+
const resultsWithKeys = results.map(r => ({ ...r, uniqueKey: r.project.id + '-' + Math.random().toString(36).substring(2) }));
|
|
125
|
+
setCleanResults(prev => [...prev, ...resultsWithKeys]);
|
|
126
|
+
// Remove successful ones from the list
|
|
127
|
+
const successIds = new Set(results.filter(r => r.freed !== null).map(r => r.project.id));
|
|
128
|
+
setProjects(prev => prev.filter(p => !successIds.has(p.id)));
|
|
129
|
+
setSelectedIds(prev => {
|
|
130
|
+
const next = new Set(prev);
|
|
131
|
+
for (const id of successIds) {
|
|
132
|
+
next.delete(id);
|
|
133
|
+
}
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
setIsCleaning(false);
|
|
137
|
+
}, [projects, selectedIds, onSpaceFreed]);
|
|
138
|
+
// Derived state
|
|
139
|
+
const totalSelectedSpace = useMemo(() => {
|
|
140
|
+
let total = 0;
|
|
141
|
+
for (const p of projects) {
|
|
142
|
+
if (selectedIds.has(p.id) && p.size !== null) {
|
|
143
|
+
total += p.size;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return total;
|
|
147
|
+
}, [projects, selectedIds]);
|
|
148
|
+
const totalLiberableSpace = useMemo(() => {
|
|
149
|
+
return projects.reduce((acc, p) => acc + (p.size ?? 0), 0);
|
|
150
|
+
}, [projects]);
|
|
151
|
+
// If cleaning is completely done and Ink is unmounting, we just show the static results
|
|
152
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: cleanResults, children: (result) => {
|
|
153
|
+
if (result.error) {
|
|
154
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { color: "red", children: "\u2716" }) }), _jsx(Box, { children: _jsxs(Text, { color: "red", wrap: "truncate-end", children: [result.project.buildPath ?? result.project.rootPath, ": ", result.error.message] }) })] }, result.uniqueKey));
|
|
155
|
+
}
|
|
156
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { color: "green", children: "\u2714" }) }), _jsx(Box, { width: 15, children: _jsx(Text, { dimColor: true, children: formatBytes(result.freed ?? 0) }) }), _jsx(Box, { children: _jsx(Text, { wrap: "truncate-end", children: result.project.buildPath ?? result.project.rootPath }) })] }, result.uniqueKey));
|
|
157
|
+
} }), isCleaning ? (_jsx(Box, { marginTop: 1, paddingX: 2, children: _jsx(Spinner, { label: "Cleaning selected projects\u2026" }) })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(ProjectList, { projects: projects, status: scanStatus, selectedIds: selectedIds, onToggleSelection: handleToggleSelection, onToggleAll: handleToggleAll, onDeleteRequested: handleDeleteRequested, isActive: !confirmOpen, totalLiberable: totalLiberableSpace }), _jsx(ConfirmDialog, { isOpen: confirmOpen, selectedCount: selectedIds.size, totalSpace: totalSelectedSpace, onConfirm: handleConfirmAccept, onCancel: handleConfirmCancel }), _jsx(StatusBar, { selectedSpace: totalSelectedSpace, confirmOpen: confirmOpen })] }))] }));
|
|
158
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { formatBytes } from '../utils/format.js';
|
|
5
|
+
export const ConfirmDialog = ({ isOpen, selectedCount, totalSpace, onConfirm, onCancel, }) => {
|
|
6
|
+
useInput((input, key) => {
|
|
7
|
+
if (key.return) {
|
|
8
|
+
onConfirm();
|
|
9
|
+
}
|
|
10
|
+
else if (key.escape || input.toLowerCase() === 'q' || input.toLowerCase() === 'n') {
|
|
11
|
+
onCancel();
|
|
12
|
+
}
|
|
13
|
+
else if (input.toLowerCase() === 'y') {
|
|
14
|
+
onConfirm();
|
|
15
|
+
}
|
|
16
|
+
}, { isActive: isOpen });
|
|
17
|
+
if (!isOpen) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return (_jsxs(Box, { marginTop: 1, paddingX: 1, paddingY: 0, borderStyle: "round", borderColor: "yellow", flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsxs(Text, { children: ["Delete build folders of", ' ', _jsx(Text, { bold: true, color: "white", children: selectedCount }), selectedCount === 1 ? ' project' : ' projects', "?", ' ', _jsxs(Text, { dimColor: true, children: ["(", formatBytes(totalSpace), ")"] })] })] }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, color: "green", children: "Y/Enter" }), _jsx(Text, { dimColor: true, children: " to confirm " }), _jsx(Text, { bold: true, color: "red", children: "N/Esc" }), _jsx(Text, { dimColor: true, children: " to cancel" })] })] }));
|
|
21
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import { formatBytes } from '../utils/format.js';
|
|
6
|
+
import { replaceHomeWithTilde } from '../core/paths.js';
|
|
7
|
+
import { COL_CHECK, COL_MODULES, COL_SIZE } from './columns.js';
|
|
8
|
+
export const ProjectItem = ({ project, isSelected, isFocused, maxPathWidth, }) => {
|
|
9
|
+
const isMaven = project.buildType === 'maven';
|
|
10
|
+
const typeLabel = isMaven ? 'M' : 'G';
|
|
11
|
+
const typeColor = isMaven ? 'cyan' : 'green';
|
|
12
|
+
const rowColor = isSelected ? 'green' : (isFocused ? 'white' : undefined);
|
|
13
|
+
const moduleCount = project.submoduleBuildPaths.length + (project.buildPath ? 1 : 0);
|
|
14
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: COL_CHECK, flexShrink: 0, children: _jsx(Text, { color: isSelected ? 'green' : (isFocused ? 'white' : undefined), bold: isSelected || isFocused, children: isSelected ? '● ' : (isFocused ? '› ' : '○ ') }) }), _jsxs(Box, { width: maxPathWidth, flexGrow: 1, marginRight: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: !isFocused && !isSelected, underline: isFocused, color: rowColor, wrap: "truncate-end", bold: isSelected, children: replaceHomeWithTilde(project.rootPath) }), _jsx(Text, { color: isSelected ? 'green' : typeColor, dimColor: !isFocused && !isSelected, bold: true, children: ` (${typeLabel})` })] }), _jsx(Box, { width: COL_MODULES, flexShrink: 0, marginRight: 1, children: moduleCount > 1 && (_jsx(Text, { dimColor: !isSelected, color: isSelected ? 'green' : 'cyan', children: `${moduleCount} mods` })) }), _jsx(Box, { width: COL_SIZE, flexShrink: 0, justifyContent: "flex-end", children: project.size === null ? (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Spinner, { type: "dots" }), _jsx(Text, { color: "yellow", children: "sizing" })] })) : (_jsx(Text, { color: rowColor, dimColor: !isFocused && !isSelected, children: formatBytes(project.size) })) })] }));
|
|
15
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import { Spinner } from '@inkjs/ui';
|
|
5
|
+
import { ProjectItem } from './ProjectItem.js';
|
|
6
|
+
import { formatBytes } from '../utils/format.js';
|
|
7
|
+
import { EXIT_CODES } from '../core/constants.js';
|
|
8
|
+
import { calcPathWidth } from './columns.js';
|
|
9
|
+
export const ProjectList = ({ projects, status, selectedIds, onToggleSelection, onToggleAll, onDeleteRequested, isActive, totalLiberable, }) => {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const { stdout } = useStdout();
|
|
12
|
+
const rows = stdout?.rows ?? 24;
|
|
13
|
+
const columns = stdout?.columns ?? 80;
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
15
|
+
// Compute once here and pass down — avoids N useStdout listeners in ProjectItem rows.
|
|
16
|
+
const maxPathWidth = calcPathWidth(columns);
|
|
17
|
+
// Maximum number of items to display on screen
|
|
18
|
+
const visibleCount = Math.max(5, rows - 10);
|
|
19
|
+
const activeCursor = projects.length === 0 ? 0 : Math.min(cursor, projects.length - 1);
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
if (projects.length === 0 && input !== 'q')
|
|
22
|
+
return;
|
|
23
|
+
if (key.upArrow || input === 'k') {
|
|
24
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
25
|
+
}
|
|
26
|
+
else if (key.downArrow || input === 'j') {
|
|
27
|
+
setCursor((c) => Math.min(projects.length - 1, c + 1));
|
|
28
|
+
}
|
|
29
|
+
else if (input === 'g') {
|
|
30
|
+
setCursor(0);
|
|
31
|
+
}
|
|
32
|
+
else if (input === 'G') {
|
|
33
|
+
setCursor(projects.length - 1);
|
|
34
|
+
}
|
|
35
|
+
else if (input === ' ' || key.return) {
|
|
36
|
+
const p = projects[activeCursor];
|
|
37
|
+
if (p)
|
|
38
|
+
onToggleSelection(p.id);
|
|
39
|
+
if (input === ' ') {
|
|
40
|
+
setCursor((c) => Math.min(projects.length - 1, c + 1));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (input === 'a') {
|
|
44
|
+
onToggleAll();
|
|
45
|
+
}
|
|
46
|
+
else if (input === 'd' || input === 'D') {
|
|
47
|
+
if (selectedIds.size > 0) {
|
|
48
|
+
onDeleteRequested();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (input === 'q' || input === 'Q') {
|
|
52
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
53
|
+
exit();
|
|
54
|
+
}
|
|
55
|
+
}, { isActive: isActive });
|
|
56
|
+
// Calculate slice of projects to display
|
|
57
|
+
let startIndex = 0;
|
|
58
|
+
if (projects.length > visibleCount) {
|
|
59
|
+
// Keep cursor roughly in the middle if possible
|
|
60
|
+
startIndex = Math.max(0, activeCursor - Math.floor(visibleCount / 2));
|
|
61
|
+
// Don't scroll past the bottom
|
|
62
|
+
if (startIndex + visibleCount > projects.length) {
|
|
63
|
+
startIndex = projects.length - visibleCount;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const visibleProjects = projects.slice(startIndex, startIndex + visibleCount);
|
|
67
|
+
const isScanning = status === 'scanning';
|
|
68
|
+
// Only show total once at least one size has been resolved
|
|
69
|
+
const showTotal = totalLiberable > 0;
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { paddingX: 1, marginBottom: 1, children: projects.length === 0 && isScanning ? (_jsx(Box, { marginLeft: 1, children: _jsx(Spinner, { label: "Scanning for Gradle/Maven projects..." }) })) : projects.length === 0 && status === 'done' ? (_jsx(Box, { borderStyle: "round", borderColor: "yellow", padding: 1, width: "100%", children: _jsx(Text, { color: "yellow", children: "No cleanable JVM projects found in home directory." }) })) : (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexGrow: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: ["Found ", projects.length, " ", projects.length === 1 ? 'project' : 'projects'] }), isScanning && (_jsx(Box, { marginLeft: 2, children: _jsx(Spinner, { label: "Scanning..." }) }))] }), showTotal && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Total Liberable: " }), _jsx(Text, { color: "cyan", bold: true, children: formatBytes(totalLiberable) })] }))] })) }), projects.length > 0 && (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: 3 }), _jsx(Box, { width: maxPathWidth, flexGrow: 1, marginRight: 2, children: _jsx(Text, { color: "gray", bold: true, children: "PROJECT (TYPE)" }) }), _jsx(Box, { width: 8, marginRight: 1, children: _jsx(Text, { color: "gray", bold: true, children: "MODULES" }) }), _jsx(Box, { width: 15, justifyContent: "flex-end", children: _jsx(Text, { color: "gray", bold: true, children: "SIZE" }) })] }), _jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(columns - 2, 100)) }) })] })), _jsx(Box, { flexDirection: "column", paddingX: 1, minHeight: visibleCount, children: visibleProjects.map((project, i) => {
|
|
71
|
+
const globalIndex = startIndex + i;
|
|
72
|
+
return (_jsx(ProjectItem, { project: project, isSelected: selectedIds.has(project.id), isFocused: isActive && globalIndex === activeCursor, maxPathWidth: maxPathWidth }, project.id));
|
|
73
|
+
}) })] }));
|
|
74
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { formatBytes } from '../utils/format.js';
|
|
5
|
+
export const StatusBar = ({ selectedSpace, confirmOpen }) => {
|
|
6
|
+
return (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsxs(Box, { paddingX: 1, backgroundColor: "gray", children: [_jsx(Box, { flexGrow: 1, gap: 2, children: _jsxs(Box, { children: [_jsx(Text, { color: "black", bold: true, children: ' Selected: ' }), _jsx(Text, { color: "green", bold: true, children: formatBytes(selectedSpace) })] }) }), _jsx(Box, { children: confirmOpen ? (_jsx(Text, { color: "black", dimColor: true, children: "Waiting for confirmation\u2026" })) : (_jsxs(Text, { color: "black", children: [_jsx(Text, { bold: true, children: "\u2191\u2193/jk" }), " move \u2022 ", _jsx(Text, { bold: true, children: "g/G" }), " top/bottom \u2022", ' ', _jsx(Text, { bold: true, children: "SPACE" }), " select \u2022 ", _jsx(Text, { bold: true, children: "a" }), " all \u2022", ' ', _jsx(Text, { bold: true, children: "D" }), " delete \u2022 ", _jsx(Text, { bold: true, children: "Q" }), " quit"] })) })] }) }));
|
|
7
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column layout constants shared between ProjectList (headers)
|
|
3
|
+
* and ProjectItem (rows). Centralised here to prevent misalignment.
|
|
4
|
+
*/
|
|
5
|
+
export const COL_CHECK = 3;
|
|
6
|
+
export const COL_MODULES = 8;
|
|
7
|
+
export const COL_SIZE = 15;
|
|
8
|
+
export const MIN_PATH_WIDTH = 40;
|
|
9
|
+
export function calcPathWidth(terminalColumns) {
|
|
10
|
+
return Math.max(MIN_PATH_WIDTH, terminalColumns - (COL_CHECK + COL_MODULES + COL_SIZE + 5));
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
export const logger = {
|
|
3
|
+
info: (msg) => console.log(` ${msg}`),
|
|
4
|
+
success: (msg) => console.log(`✔ ${msg}`),
|
|
5
|
+
warn: (msg) => console.warn(`⚠ ${msg}`),
|
|
6
|
+
error: (msg, err) => {
|
|
7
|
+
if (process.env['NODE_ENV'] === 'development') {
|
|
8
|
+
const logMsg = `[${new Date().toISOString()}] ✖ ${msg}${err instanceof Error ? `: ${err.message}` : ''}\n`;
|
|
9
|
+
try {
|
|
10
|
+
fs.appendFileSync('projclean.log', logMsg);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Fallback or ignore if write fails
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
console.error(`✖ ${msg}`);
|
|
17
|
+
if (err instanceof Error)
|
|
18
|
+
console.error(` ${err.message}`);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const BYTES_IN_KB = 1024;
|
|
2
|
+
const BYTES_IN_MB = 1024 * 1024;
|
|
3
|
+
const BYTES_IN_GB = 1024 * 1024 * 1024;
|
|
4
|
+
/**
|
|
5
|
+
* Converts a byte count into a human-readable string.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* formatBytes(358_400_000) // "341.8 MB"
|
|
9
|
+
* formatBytes(2_147_483_648) // "2.0 GB"
|
|
10
|
+
* formatBytes(512) // "512 B"
|
|
11
|
+
*/
|
|
12
|
+
export function formatBytes(bytes) {
|
|
13
|
+
if (bytes >= BYTES_IN_GB) {
|
|
14
|
+
return `${(bytes / BYTES_IN_GB).toFixed(1)} GB`;
|
|
15
|
+
}
|
|
16
|
+
if (bytes >= BYTES_IN_MB) {
|
|
17
|
+
return `${(bytes / BYTES_IN_MB).toFixed(1)} MB`;
|
|
18
|
+
}
|
|
19
|
+
if (bytes >= BYTES_IN_KB) {
|
|
20
|
+
return `${(bytes / BYTES_IN_KB).toFixed(1)} KB`;
|
|
21
|
+
}
|
|
22
|
+
return `${bytes} B`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Truncates a path string to fit within `maxWidth` characters,
|
|
26
|
+
* always preserving the final path segment (the project folder name).
|
|
27
|
+
*/
|
|
28
|
+
export function truncatePath(p, maxWidth) {
|
|
29
|
+
// Normalize separators for display
|
|
30
|
+
const normalized = p.replaceAll('\\', '/');
|
|
31
|
+
if (normalized.length <= maxWidth) {
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
const segments = normalized.split('/');
|
|
35
|
+
const last = segments[segments.length - 1] ?? '';
|
|
36
|
+
const prefix = '~/…/';
|
|
37
|
+
const available = maxWidth - prefix.length - last.length;
|
|
38
|
+
if (available <= 0) {
|
|
39
|
+
return last;
|
|
40
|
+
}
|
|
41
|
+
const middle = [];
|
|
42
|
+
for (let i = segments.length - 2; i >= 0; i--) {
|
|
43
|
+
const segment = segments[i] ?? '';
|
|
44
|
+
const candidate = [segment, ...middle].join('/');
|
|
45
|
+
if (candidate.length > available)
|
|
46
|
+
break;
|
|
47
|
+
middle.unshift(segment);
|
|
48
|
+
}
|
|
49
|
+
return `${prefix}${middle.length > 0 ? middle.join('/') + '/' : ''}${last}`;
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "projclean",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive CLI to detect and clean Gradle build/ and Maven target/ folders",
|
|
5
|
+
"author": "alexjcm",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/alexjcm/projclean#readme",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"projclean": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24.0.0"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"registry": "https://registry.npmjs.org"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "tsx src/index.tsx",
|
|
20
|
+
"lint": "eslint .",
|
|
21
|
+
"prebuild": "rm -rf dist",
|
|
22
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@inkjs/ui": "2.0.0",
|
|
26
|
+
"fast-glob": "3.3.3",
|
|
27
|
+
"ink": "6.8.0",
|
|
28
|
+
"react": "19.2.4"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@eslint/js": "^10.0.1",
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"@types/react": "19.2.14",
|
|
34
|
+
"eslint": "^10.1.0",
|
|
35
|
+
"globals": "^17.4.0",
|
|
36
|
+
"tsx": "^4.21.0",
|
|
37
|
+
"typescript": "5.9.3",
|
|
38
|
+
"typescript-eslint": "^8.57.2"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"keywords": [
|
|
44
|
+
"cli",
|
|
45
|
+
"tui",
|
|
46
|
+
"jvm",
|
|
47
|
+
"java",
|
|
48
|
+
"maven",
|
|
49
|
+
"gradle",
|
|
50
|
+
"build",
|
|
51
|
+
"clean",
|
|
52
|
+
"disk-space"
|
|
53
|
+
],
|
|
54
|
+
"packageManager": "npm@11.4.2+sha512.f90c1ec8b207b625d6edb6693aef23dacb39c38e4217fe8c46a973f119cab392ac0de23fe3f07e583188dae9fd9108b3845ad6f525b598742bd060ebad60bff3"
|
|
55
|
+
}
|