next-prune 1.3.0 → 1.5.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/dist/app.js CHANGED
@@ -1,37 +1,27 @@
1
1
  import process from 'node:process';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
- import React, { useState, useEffect, useMemo } from 'react';
4
+ import React, { useState, useEffect, useMemo, useCallback } from 'react';
5
5
  import { Box, Text, useInput, useApp } from 'ink';
6
6
  import { scanArtifacts, getArtifactStats, human } from './scanner.js';
7
7
  import { findUnusedAssets } from './asset-scanner.js';
8
+ import { DEFAULT_CONFIG } from './config.js';
9
+ import { useTerminalSize } from './hooks/use-terminal-size.js';
8
10
  import { Header } from './ui/header.js';
9
11
  import { Dashboard } from './ui/dashboard.js';
10
12
  import { Footer } from './ui/footer.js';
11
13
  import { ArtifactList } from './ui/artifact-list.js';
12
14
  import { ConfirmModal } from './ui/confirm-modal.js';
13
- function useTerminalCols() {
14
- const [cols, setCols] = useState(process.stdout?.columns || 80);
15
- useEffect(() => {
16
- const onResize = () => setCols(process.stdout?.columns || 80);
17
- process.stdout?.on?.('resize', onResize);
18
- return () => process.stdout?.off?.('resize', onResize);
19
- }, []);
20
- return cols;
21
- }
22
- function useTerminalRows() {
23
- const [rows, setRows] = useState(process.stdout?.rows || 24);
24
- useEffect(() => {
25
- const onResize = () => setRows(process.stdout?.rows || 24);
26
- process.stdout?.on?.('resize', onResize);
27
- return () => process.stdout?.off?.('resize', onResize);
28
- }, []);
29
- return rows;
30
- }
31
- const DEFAULT_CONFIG = {
32
- alwaysDelete: [],
33
- neverDelete: [],
34
- checkUnusedAssets: false
15
+ const toPosixPath = value => value.split(path.sep).join('/');
16
+ const matchesConfigPath = (relPath, pattern) => {
17
+ const rel = toPosixPath(relPath);
18
+ const normalizedPattern = String(pattern).replaceAll('\\', '/');
19
+ return rel === normalizedPattern || rel.startsWith(`${normalizedPattern}/`);
20
+ };
21
+ const SORT_MODES = ['size', 'age', 'path'];
22
+ const clampIndex = (nextIndex, totalItems) => {
23
+ if (totalItems <= 0) return 0;
24
+ return Math.max(0, Math.min(totalItems - 1, nextIndex));
35
25
  };
36
26
  export default function App({
37
27
  cwd = process.cwd(),
@@ -45,17 +35,18 @@ export default function App({
45
35
  } = useApp();
46
36
  const [items, setItems] = useState([]);
47
37
  const [loading, setLoading] = useState(!testMode);
48
- // selected is now a Set of paths (strings)
38
+ // Selected paths are tracked as a set of absolute paths.
49
39
  const [selectedPaths, setSelectedPaths] = useState(new Set());
50
40
  const [index, setIndex] = useState(0);
51
41
  const [sortBy, setSortBy] = useState('size'); // 'size' | 'age' | 'path'
52
42
  const [confirm, setConfirm] = useState(false);
53
43
  const [error, setError] = useState('');
54
- // Removed unused showHelp
55
44
 
56
45
  // Terminal dimensions
57
- const cols = useTerminalCols();
58
- const rows = useTerminalRows();
46
+ const {
47
+ columns: cols,
48
+ rows
49
+ } = useTerminalSize();
59
50
 
60
51
  // Derived sorted items
61
52
  const sortedItems = useMemo(() => {
@@ -70,13 +61,47 @@ export default function App({
70
61
  return 0;
71
62
  });
72
63
  }, [items, sortBy, cwd]);
73
- const totalSize = useMemo(() => {
74
- return items.filter(it => it.status !== 'deleted').reduce((acc, it) => acc + (it.size || 0), 0);
75
- }, [items]);
76
- const selectedSize = useMemo(() => {
77
- return items.filter(it => selectedPaths.has(it.path) && it.status !== 'deleted').reduce((acc, it) => acc + (it.size || 0), 0);
64
+ const itemByPath = useMemo(() => new Map(items.map(it => [it.path, it])), [items]);
65
+ const {
66
+ foundCount,
67
+ totalSize,
68
+ selectedSize
69
+ } = useMemo(() => {
70
+ let found = 0;
71
+ let total = 0;
72
+ let selected = 0;
73
+ for (const item of items) {
74
+ if (item.status === 'deleted') continue;
75
+ found++;
76
+ const size = typeof item.size === 'number' ? item.size : 0;
77
+ total += size;
78
+ if (selectedPaths.has(item.path)) {
79
+ selected += size;
80
+ }
81
+ }
82
+ return {
83
+ foundCount: found,
84
+ totalSize: total,
85
+ selectedSize: selected
86
+ };
78
87
  }, [items, selectedPaths]);
79
- const foundCount = items.filter(it => it.status !== 'deleted').length;
88
+ const sortedIndexByPath = useMemo(() => {
89
+ const map = new Map();
90
+ for (const [sortedIndex, item] of sortedItems.entries()) {
91
+ map.set(item.path, sortedIndex);
92
+ }
93
+ return map;
94
+ }, [sortedItems]);
95
+ const selectedIds = useMemo(() => {
96
+ const indices = new Set();
97
+ for (const selectedPath of selectedPaths) {
98
+ const sortedIndex = sortedIndexByPath.get(selectedPath);
99
+ if (sortedIndex !== undefined) {
100
+ indices.add(sortedIndex);
101
+ }
102
+ }
103
+ return indices;
104
+ }, [selectedPaths, sortedIndexByPath]);
80
105
 
81
106
  // Viewport Logic
82
107
  const listBoxHeight = Math.max(5, (rows || 24) - 12); // Approx height for list
@@ -92,7 +117,7 @@ export default function App({
92
117
  const viewEnd = Math.min(viewStart + listBoxHeight, sortedItems.length);
93
118
 
94
119
  // Handlers
95
- const doScan = async () => {
120
+ const doScan = useCallback(async () => {
96
121
  setLoading(true);
97
122
  setError('');
98
123
  try {
@@ -112,7 +137,7 @@ export default function App({
112
137
  if (config.neverDelete?.length > 0) {
113
138
  nextItems = nextItems.filter(it => {
114
139
  const rel = path.relative(cwd, it.path);
115
- return !config.neverDelete.some(pattern => rel === pattern || rel.startsWith(pattern + path.sep));
140
+ return !config.neverDelete.some(pattern => matchesConfigPath(rel, pattern));
116
141
  });
117
142
  }
118
143
  setItems(nextItems);
@@ -122,19 +147,23 @@ export default function App({
122
147
  } finally {
123
148
  setLoading(false);
124
149
  }
125
- };
126
- const performDeletion = async () => {
150
+ }, [cwd, config.checkUnusedAssets, config.neverDelete]);
151
+ const performDeletion = useCallback(async () => {
127
152
  try {
128
- const pathsToDelete = [...selectedPaths].filter(p => {
129
- const it = items.find(x => x.path === p);
130
- return it && it.status !== 'deleted';
131
- });
153
+ const pathsToDelete = [];
154
+ for (const selectedPath of selectedPaths) {
155
+ const item = itemByPath.get(selectedPath);
156
+ if (item && item.status !== 'deleted') {
157
+ pathsToDelete.push(selectedPath);
158
+ }
159
+ }
132
160
  if (pathsToDelete.length === 0) {
133
161
  setConfirm(false);
134
162
  return;
135
163
  }
164
+ const pathsToDeleteSet = new Set(pathsToDelete);
136
165
  if (dryRun) {
137
- setItems(prev => prev.map(it => pathsToDelete.includes(it.path) ? {
166
+ setItems(prev => prev.map(it => pathsToDeleteSet.has(it.path) ? {
138
167
  ...it,
139
168
  status: 'dry-run'
140
169
  } : it));
@@ -145,99 +174,172 @@ export default function App({
145
174
  }
146
175
 
147
176
  // Mark deleting
148
- setItems(prev => prev.map(it => pathsToDelete.includes(it.path) ? {
177
+ setItems(prev => prev.map(it => pathsToDeleteSet.has(it.path) ? {
149
178
  ...it,
150
179
  status: 'deleting'
151
180
  } : it));
152
- let freed = 0;
153
- const successes = new Set();
154
- for (const p of pathsToDelete) {
181
+ const deletionResults = await Promise.all(pathsToDelete.map(async pathToDelete => {
155
182
  try {
156
- await fs.rm(p, {
183
+ await fs.rm(pathToDelete, {
157
184
  recursive: true,
158
185
  force: true
159
186
  });
160
- successes.add(p);
161
- const it = items.find(x => x.path === p);
162
- if (it) freed += it.size || 0;
187
+ return {
188
+ path: pathToDelete,
189
+ ok: true
190
+ };
163
191
  } catch {
164
- setItems(prev => prev.map(it => it.path === p ? {
165
- ...it,
166
- status: 'error'
167
- } : it));
168
- setError(`Failed to delete: ${p}`);
192
+ return {
193
+ path: pathToDelete,
194
+ ok: false
195
+ };
196
+ }
197
+ }));
198
+ const succeededPaths = new Set();
199
+ const failedPaths = [];
200
+ let freed = 0;
201
+ for (const result of deletionResults) {
202
+ if (result.ok) {
203
+ succeededPaths.add(result.path);
204
+ const item = itemByPath.get(result.path);
205
+ freed += typeof item?.size === 'number' ? item.size : 0;
206
+ } else {
207
+ failedPaths.push(result.path);
169
208
  }
170
209
  }
210
+ if (failedPaths.length > 0) {
211
+ const failedPathSet = new Set(failedPaths);
212
+ setItems(prev => prev.map(it => failedPathSet.has(it.path) ? {
213
+ ...it,
214
+ status: 'error'
215
+ } : it));
216
+ setError(`Failed to delete: ${failedPaths[0]}`);
217
+ }
171
218
 
172
219
  // Mark deleted
173
- if (successes.size > 0) {
174
- setItems(prev => prev.map(it => successes.has(it.path) ? {
220
+ if (succeededPaths.size > 0) {
221
+ setItems(prev => prev.map(it => succeededPaths.has(it.path) ? {
175
222
  ...it,
176
223
  status: 'deleted',
177
224
  size: 0
178
225
  } : it));
179
- setError(`✅ Deleted ${successes.size} items (freed ${human(freed)})`);
226
+ setError(`✅ Deleted ${succeededPaths.size} items (freed ${human(freed)})`);
180
227
  }
181
228
  } finally {
182
229
  setSelectedPaths(new Set());
183
230
  setConfirm(false);
184
231
  }
185
- };
232
+ }, [selectedPaths, itemByPath, dryRun, selectedSize]);
233
+ const moveFocusBy = useCallback(delta => {
234
+ setIndex(previous => clampIndex(previous + delta, sortedItems.length));
235
+ }, [sortedItems.length]);
236
+ const jumpFocusTo = useCallback(targetIndex => {
237
+ setIndex(clampIndex(targetIndex, sortedItems.length));
238
+ }, [sortedItems.length]);
239
+ const toggleFocusedItemSelection = useCallback(() => {
240
+ const focusedItem = sortedItems[index];
241
+ if (!focusedItem || focusedItem.status === 'deleted') return;
242
+ setSelectedPaths(previous => {
243
+ const next = new Set(previous);
244
+ if (next.has(focusedItem.path)) {
245
+ next.delete(focusedItem.path);
246
+ } else {
247
+ next.add(focusedItem.path);
248
+ }
249
+ return next;
250
+ });
251
+ }, [sortedItems, index]);
252
+ const selectAllItems = useCallback(() => {
253
+ const allPaths = sortedItems.filter(it => it.status !== 'deleted').map(it => it.path);
254
+ setSelectedPaths(new Set(allPaths));
255
+ }, [sortedItems]);
256
+ const cycleSortMode = useCallback(() => {
257
+ setSortBy(previous => {
258
+ const nextIndex = (SORT_MODES.indexOf(previous) + 1) % SORT_MODES.length;
259
+ return SORT_MODES[nextIndex];
260
+ });
261
+ }, []);
262
+ const openDeleteConfirmation = useCallback(() => {
263
+ if (selectedPaths.size > 0) {
264
+ setConfirm(true);
265
+ return;
266
+ }
267
+ const focusedItem = sortedItems[index];
268
+ if (!focusedItem || focusedItem.status === 'deleted') return;
269
+ setSelectedPaths(new Set([focusedItem.path]));
270
+ setConfirm(true);
271
+ }, [selectedPaths, sortedItems, index]);
272
+ const handleConfirmInput = useCallback((input, key) => {
273
+ if (!confirm) return false;
274
+ if (key.escape || input?.toLowerCase() === 'n') {
275
+ setConfirm(false);
276
+ return true;
277
+ }
278
+ if (input?.toLowerCase() === 'y' || key.return) {
279
+ performDeletion();
280
+ }
281
+ return true;
282
+ }, [confirm, performDeletion]);
186
283
  useInput((input, key) => {
187
- if (confirm) {
188
- if (key.escape || input?.toLowerCase() === 'n') setConfirm(false);
189
- if (input?.toLowerCase() === 'y' || key.return) performDeletion();
284
+ if (handleConfirmInput(input, key)) return;
285
+ if (key.escape || input?.toLowerCase() === 'q') {
286
+ exit();
287
+ return;
288
+ }
289
+ if (key.upArrow) {
290
+ moveFocusBy(-1);
291
+ return;
292
+ }
293
+ if (key.downArrow) {
294
+ moveFocusBy(1);
295
+ return;
296
+ }
297
+ if (key.pageUp) {
298
+ moveFocusBy(-listBoxHeight);
299
+ return;
300
+ }
301
+ if (key.pageDown) {
302
+ moveFocusBy(listBoxHeight);
303
+ return;
304
+ }
305
+ if (key.home) {
306
+ jumpFocusTo(0);
307
+ return;
308
+ }
309
+ if (key.end) {
310
+ jumpFocusTo(sortedItems.length - 1);
190
311
  return;
191
312
  }
192
- if (key.escape || input?.toLowerCase() === 'q') exit();
193
- if (key.upArrow) setIndex(Math.max(0, index - 1));
194
- if (key.downArrow) setIndex(Math.min(sortedItems.length - 1, index + 1));
195
-
196
- // Page navigation
197
- if (key.pageUp) setIndex(Math.max(0, index - listBoxHeight));
198
- if (key.pageDown) setIndex(Math.min(sortedItems.length - 1, index + listBoxHeight));
199
- if (key.home) setIndex(0);
200
- if (key.end) setIndex(sortedItems.length - 1);
201
313
  if (input === ' ') {
202
- const currentPath = sortedItems[index]?.path;
203
- if (currentPath && sortedItems[index].status !== 'deleted') {
204
- setSelectedPaths(prev => {
205
- const next = new Set(prev);
206
- if (next.has(currentPath)) next.delete(currentPath);else next.add(currentPath);
207
- return next;
208
- });
209
- }
314
+ toggleFocusedItemSelection();
315
+ return;
210
316
  }
211
- if (input?.toLowerCase() === 'a') {
212
- const allPaths = sortedItems.filter(it => it.status !== 'deleted').map(it => it.path);
213
- setSelectedPaths(new Set(allPaths));
317
+ const loweredInput = input?.toLowerCase();
318
+ if (loweredInput === 'a') {
319
+ selectAllItems();
320
+ return;
214
321
  }
215
- if (input?.toLowerCase() === 'c') setSelectedPaths(new Set());
216
- if (input?.toLowerCase() === 'r' && !loading) doScan();
217
- if (input?.toLowerCase() === 's') {
218
- const modes = ['size', 'age', 'path'];
219
- const next = modes[(modes.indexOf(sortBy) + 1) % modes.length];
220
- setSortBy(next);
322
+ if (loweredInput === 'c') {
323
+ setSelectedPaths(new Set());
324
+ return;
221
325
  }
222
- if (input?.toLowerCase() === 'd' || key.return) {
223
- if (selectedPaths.size > 0) {
224
- setConfirm(true);
225
- } else if (sortedItems[index]?.status !== 'deleted') {
226
- // Select current if none selected
227
- const p = sortedItems[index]?.path;
228
- if (p) {
229
- setSelectedPaths(new Set([p]));
230
- setConfirm(true);
231
- }
232
- }
326
+ if (loweredInput === 'r' && !loading) {
327
+ doScan();
328
+ return;
329
+ }
330
+ if (loweredInput === 's') {
331
+ cycleSortMode();
332
+ return;
333
+ }
334
+ if (loweredInput === 'd' || key.return) {
335
+ openDeleteConfirmation();
233
336
  }
234
337
  });
235
338
 
236
339
  // Initial scan
237
340
  useEffect(() => {
238
341
  if (!testMode) doScan();
239
- // eslint-disable-next-line react-hooks/exhaustive-deps
240
- }, [cwd, testMode]);
342
+ }, [testMode, doScan]);
241
343
 
242
344
  // Auto-select based on config
243
345
  useEffect(() => {
@@ -252,7 +354,7 @@ export default function App({
252
354
  const pre = new Set();
253
355
  for (const it of items) {
254
356
  const rel = path.relative(cwd, it.path);
255
- const hit = config.alwaysDelete.some(p => rel === p || rel.startsWith(p + path.sep));
357
+ const hit = config.alwaysDelete.some(p => matchesConfigPath(rel, p));
256
358
  if (hit && it.status !== 'deleted') pre.add(it.path);
257
359
  }
258
360
  if (pre.size > 0) setSelectedPaths(pre);
@@ -307,10 +409,7 @@ export default function App({
307
409
  }, error)), /*#__PURE__*/React.createElement(ArtifactList, {
308
410
  items: sortedItems,
309
411
  selectedIndex: index,
310
- selectedIds: new Set([...selectedPaths].map(p => {
311
- // Map path back to current sorted index for display
312
- return sortedItems.findIndex(it => it.path === p);
313
- }).filter(i => i !== -1)),
412
+ selectedIds: selectedIds,
314
413
  viewStart: viewStart,
315
414
  viewEnd: viewEnd,
316
415
  cwd: cwd,
@@ -23,6 +23,18 @@ async function* walkFiles(dir, extensions) {
23
23
  }
24
24
  }
25
25
  }
26
+ const collectSourceFilesInDir = async directoryPath => {
27
+ try {
28
+ await fs.access(directoryPath);
29
+ } catch {
30
+ return [];
31
+ }
32
+ const files = [];
33
+ for await (const filePath of walkFiles(directoryPath, SOURCE_EXTENSIONS)) {
34
+ files.push(filePath);
35
+ }
36
+ return files;
37
+ };
26
38
  export const findUnusedAssets = async cwd => {
27
39
  const publicDir = path.join(cwd, 'public');
28
40
  try {
@@ -32,61 +44,63 @@ export const findUnusedAssets = async cwd => {
32
44
  }
33
45
 
34
46
  // 1. Gather all assets in public/
35
- const assets = [];
47
+ const assets = []; // { fullPath, filename, relPath }
36
48
  for await (const p of walkFiles(publicDir, IMAGE_EXTENSIONS)) {
37
- assets.push(p);
49
+ assets.push({
50
+ fullPath: p,
51
+ filename: path.basename(p),
52
+ relPath: path.relative(publicDir, p).split(path.sep).join('/')
53
+ });
38
54
  }
39
55
  if (assets.length === 0) return [];
40
56
 
41
57
  // 2. Gather all source files
42
58
  const sourceFiles = [];
43
59
  const srcDirs = ['src', 'app', 'pages', 'components', 'lib', 'utils', 'hooks'];
44
- // Also check root files
45
- const rootFiles = await fs.readdir(cwd);
46
- for (const f of rootFiles) {
47
- if (SOURCE_EXTENSIONS.has(path.extname(f))) {
48
- sourceFiles.push(path.join(cwd, f));
49
- }
50
- }
51
- for (const dir of srcDirs) {
52
- const fullDir = path.join(cwd, dir);
53
- try {
54
- await fs.access(fullDir);
55
- for await (const p of walkFiles(fullDir, SOURCE_EXTENSIONS)) {
56
- sourceFiles.push(p);
57
- }
58
- } catch {
59
- // ignore missing dirs
60
- }
61
- }
62
60
 
63
- // 3. Read source files content
64
- const contents = await Promise.all(sourceFiles.map(async f => {
65
- try {
66
- return await fs.readFile(f, 'utf8');
67
- } catch {
68
- return '';
61
+ // Check root files
62
+ try {
63
+ const rootFiles = await fs.readdir(cwd);
64
+ for (const f of rootFiles) {
65
+ if (SOURCE_EXTENSIONS.has(path.extname(f).toLowerCase())) {
66
+ sourceFiles.push(path.join(cwd, f));
67
+ }
69
68
  }
70
- }));
71
- const fullSource = contents.join('\n');
69
+ } catch {}
70
+ const sourceFilesByDirectory = await Promise.all(srcDirs.map(async dirName => collectSourceFilesInDir(path.join(cwd, dirName))));
71
+ sourceFiles.push(...sourceFilesByDirectory.flat());
72
72
 
73
- // 4. Check for usage
74
- const unused = [];
75
- for (const asset of assets) {
76
- const filename = path.basename(asset);
77
- const relPath = path.relative(publicDir, asset); // e.g. "images/logo.png"
73
+ // 3. Check usage by streaming files
74
+ // We keep a set of indices of assets that are NOT yet found.
75
+ // Initially all assets are candidates.
76
+ const candidateIndices = new Set(assets.keys());
77
+ for (const file of sourceFiles) {
78
+ if (candidateIndices.size === 0) break; // All found
78
79
 
79
- // Naive check: does the filename appear?
80
- // Or the relative path?
81
- // We check both "logo.png" and "images/logo.png"
80
+ try {
81
+ // eslint-disable-next-line no-await-in-loop
82
+ const content = await fs.readFile(file, 'utf8');
83
+ for (const index of candidateIndices) {
84
+ const asset = assets[index];
85
+ // Naive check: filename or relative path
86
+ // We check "logo.png" or "images/logo.png"
87
+ // Also check with leading slash for paths like "/images/logo.png"
82
88
 
83
- const nameMatch = fullSource.includes(filename);
84
- // For relative path, we need to be careful about slashes.
85
- // In code it might be "/images/logo.png"
86
- const relMatch = fullSource.includes(relPath) || fullSource.includes('/' + relPath);
87
- if (!nameMatch && !relMatch) {
88
- unused.push(asset);
89
+ const nameMatch = content.includes(asset.filename);
90
+ if (nameMatch) {
91
+ candidateIndices.delete(index);
92
+ continue;
93
+ }
94
+ const relMatch = content.includes(asset.relPath) || content.includes('/' + asset.relPath);
95
+ if (relMatch) {
96
+ candidateIndices.delete(index);
97
+ }
98
+ }
99
+ } catch {
100
+ // ignore read errors
89
101
  }
90
102
  }
91
- return unused;
103
+
104
+ // 4. Return remaining candidates (unused)
105
+ return [...candidateIndices].map(i => assets[i].fullPath);
92
106
  };