@doccy/fell 0.1.0 → 0.1.2
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/README.md +6 -7
- package/cli.ts +65 -9
- package/lib/git.ts +39 -0
- package/lib/tui.ts +29 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
```
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
█ ▀█▄▄▀ ▀▄▄ ▀▄▄
|
|
2
|
+
▗▞▀▀▘▗▞▀▚▖█ █
|
|
3
|
+
▐▌ ▐▛▀▀▘█ █
|
|
4
|
+
▐▛▀▘ ▝▚▄▄▖█ █
|
|
5
|
+
▐▌ █ █
|
|
7
6
|
```
|
|
8
7
|
|
|
9
|
-
*"To fell a tree."*
|
|
8
|
+
> *"To fell a tree."*
|
|
10
9
|
|
|
11
10
|
`fell` is a CLI tool that help's you actively manage, prune and delete worktrees.
|
|
12
11
|
|
|
@@ -64,7 +63,7 @@ q / ctrl+c Quit
|
|
|
64
63
|
|
|
65
64
|
### Preview
|
|
66
65
|
|
|
67
|
-
<img src="./docs/
|
|
66
|
+
<img src="./docs/fell-demo.gif" alt="fell demo" height="400px">
|
|
68
67
|
|
|
69
68
|
## License
|
|
70
69
|
|
package/cli.ts
CHANGED
|
@@ -22,12 +22,15 @@ import {
|
|
|
22
22
|
fetchPrForBranch,
|
|
23
23
|
fetchWorktreeFileStatus,
|
|
24
24
|
fetchWorktreeFileList,
|
|
25
|
+
fetchDirectorySize,
|
|
26
|
+
formatBytes,
|
|
25
27
|
openDirectory,
|
|
26
28
|
checkGhStatus,
|
|
27
29
|
type Worktree,
|
|
28
30
|
type PrStatus,
|
|
29
31
|
type FileStatusResult,
|
|
30
32
|
type FileEntry,
|
|
33
|
+
type DirSize,
|
|
31
34
|
type GhDiagnostic,
|
|
32
35
|
} from "./lib/git"
|
|
33
36
|
import {
|
|
@@ -41,6 +44,7 @@ import {
|
|
|
41
44
|
shortenPath,
|
|
42
45
|
formatPrStatus,
|
|
43
46
|
formatFileStatus,
|
|
47
|
+
fellLogo,
|
|
44
48
|
renderHelpLines,
|
|
45
49
|
printCliHelp,
|
|
46
50
|
type Key,
|
|
@@ -54,6 +58,7 @@ interface WorktreeItem {
|
|
|
54
58
|
worktree: Worktree
|
|
55
59
|
prStatus: PrStatus
|
|
56
60
|
fileStatus: FileStatusResult
|
|
61
|
+
dirSize: DirSize
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** Per-item progress entry for the deleting status line. */
|
|
@@ -157,7 +162,23 @@ function renderRow(
|
|
|
157
162
|
// Pad branch column for alignment
|
|
158
163
|
const branchPadded = pad(branchStr, BRANCH_COL + 2)
|
|
159
164
|
|
|
160
|
-
|
|
165
|
+
// Directory size column
|
|
166
|
+
let sizeStr: string
|
|
167
|
+
switch (item.dirSize.type) {
|
|
168
|
+
case "loading": {
|
|
169
|
+
const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
|
|
170
|
+
sizeStr = c.dim(`${frame} estimating`)
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
case "done":
|
|
174
|
+
sizeStr = c.dim(formatBytes(item.dirSize.bytes))
|
|
175
|
+
break
|
|
176
|
+
case "error":
|
|
177
|
+
sizeStr = ""
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const mainLine = ` ${cursor} ${check} ${branchPadded}${sha} ${pad(pr, 18)}${sizeStr}${tagStr}`
|
|
161
182
|
const lines = [mainLine]
|
|
162
183
|
|
|
163
184
|
// Sub-line: dirty file status with warning icon, indented under the branch name
|
|
@@ -203,12 +224,11 @@ function render(state: State): void {
|
|
|
203
224
|
|
|
204
225
|
// Title bar
|
|
205
226
|
lines.push("")
|
|
206
|
-
const
|
|
227
|
+
const suffix =
|
|
207
228
|
state.selected.size > 0
|
|
208
229
|
? ` ${c.lime(`${state.selected.size} selected`)}`
|
|
209
|
-
:
|
|
210
|
-
lines.push(` ${c.dim("
|
|
211
|
-
lines.push(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
|
|
230
|
+
: ""
|
|
231
|
+
lines.push(` ${c.bold(fellLogo())} ${c.dim("Interactive Worktree Cleanup")}${suffix}`)
|
|
212
232
|
lines.push("")
|
|
213
233
|
|
|
214
234
|
// Main worktree (always visible, non-interactive)
|
|
@@ -471,6 +491,7 @@ function startDelete(
|
|
|
471
491
|
await refreshWorktrees(state)
|
|
472
492
|
startPrFetching(state, rerender)
|
|
473
493
|
startFileStatusFetching(state, rerender)
|
|
494
|
+
startSizeFetching(state, rerender)
|
|
474
495
|
|
|
475
496
|
if (needsForce) {
|
|
476
497
|
state.mode = {
|
|
@@ -503,6 +524,7 @@ async function refreshWorktrees(state: State): Promise<void> {
|
|
|
503
524
|
worktree: w,
|
|
504
525
|
prStatus: { type: "loading" as const },
|
|
505
526
|
fileStatus: { type: "loading" as const },
|
|
527
|
+
dirSize: { type: "loading" as const },
|
|
506
528
|
}))
|
|
507
529
|
|
|
508
530
|
state.selected.clear()
|
|
@@ -642,6 +664,35 @@ function startFileStatusFetching(state: State, rerender: () => void): void {
|
|
|
642
664
|
})()
|
|
643
665
|
}
|
|
644
666
|
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
// Async directory size fetching (background, sequential)
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Fetch directory sizes for all non-main worktrees in background.
|
|
673
|
+
* Runs sequentially (du is I/O heavy, parallel would thrash disk).
|
|
674
|
+
*/
|
|
675
|
+
function startSizeFetching(state: State, rerender: () => void): void {
|
|
676
|
+
;(async () => {
|
|
677
|
+
for (let i = 0; i < state.items.length; i++) {
|
|
678
|
+
const item = state.items[i]
|
|
679
|
+
const path = item.worktree.path
|
|
680
|
+
try {
|
|
681
|
+
const result = await fetchDirectorySize(path)
|
|
682
|
+
// Guard against stale index
|
|
683
|
+
if (state.items[i]?.worktree.path === path) {
|
|
684
|
+
state.items[i].dirSize = result
|
|
685
|
+
}
|
|
686
|
+
} catch {
|
|
687
|
+
if (state.items[i]?.worktree.path === path) {
|
|
688
|
+
state.items[i].dirSize = { type: "error" }
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
rerender()
|
|
692
|
+
}
|
|
693
|
+
})()
|
|
694
|
+
}
|
|
695
|
+
|
|
645
696
|
// ---------------------------------------------------------------------------
|
|
646
697
|
// Key handling
|
|
647
698
|
// ---------------------------------------------------------------------------
|
|
@@ -742,6 +793,7 @@ async function handleBrowseKey(state: State, key: Key): Promise<void> {
|
|
|
742
793
|
await refreshWorktrees(state)
|
|
743
794
|
startPrFetching(state, () => render(state))
|
|
744
795
|
startFileStatusFetching(state, () => render(state))
|
|
796
|
+
startSizeFetching(state, () => render(state))
|
|
745
797
|
state.message = { text: "Refreshed.", kind: "success" }
|
|
746
798
|
return
|
|
747
799
|
}
|
|
@@ -848,6 +900,7 @@ async function handleConfirmPruneKey(state: State, key: Key): Promise<void> {
|
|
|
848
900
|
await refreshWorktrees(state)
|
|
849
901
|
startPrFetching(state, () => render(state))
|
|
850
902
|
startFileStatusFetching(state, () => render(state))
|
|
903
|
+
startSizeFetching(state, () => render(state))
|
|
851
904
|
state.mode = {
|
|
852
905
|
type: "result",
|
|
853
906
|
lines: [`${c.green("\u2713")} Stale references pruned.`],
|
|
@@ -877,8 +930,7 @@ async function printListAndExit(): Promise<void> {
|
|
|
877
930
|
const ghDiagnostic = await checkGhStatus()
|
|
878
931
|
|
|
879
932
|
console.log()
|
|
880
|
-
console.log(` ${c.
|
|
881
|
-
console.log(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
|
|
933
|
+
console.log(` ${c.bold(fellLogo())} ${c.dim("--list")}`)
|
|
882
934
|
console.log()
|
|
883
935
|
|
|
884
936
|
// Fetch file statuses for all worktrees concurrently
|
|
@@ -972,7 +1024,9 @@ function startSpinnerTimer(
|
|
|
972
1024
|
): ReturnType<typeof setInterval> {
|
|
973
1025
|
return setInterval(() => {
|
|
974
1026
|
// Animate when there are loading statuses or an active delete in progress
|
|
975
|
-
const hasLoading = state.items.some(
|
|
1027
|
+
const hasLoading = state.items.some(
|
|
1028
|
+
(i) => i.prStatus.type === "loading" || i.dirSize.type === "loading",
|
|
1029
|
+
)
|
|
976
1030
|
const isDeleting = state.mode.type === "deleting"
|
|
977
1031
|
if (hasLoading || isDeleting) {
|
|
978
1032
|
state.spinnerFrame =
|
|
@@ -1030,6 +1084,7 @@ async function main() {
|
|
|
1030
1084
|
worktree: w,
|
|
1031
1085
|
prStatus: { type: "loading" },
|
|
1032
1086
|
fileStatus: { type: "loading" },
|
|
1087
|
+
dirSize: { type: "loading" },
|
|
1033
1088
|
})),
|
|
1034
1089
|
mainWorktree,
|
|
1035
1090
|
cursor: 0,
|
|
@@ -1079,9 +1134,10 @@ async function main() {
|
|
|
1079
1134
|
// Initial render
|
|
1080
1135
|
render(state)
|
|
1081
1136
|
|
|
1082
|
-
// Start background PR fetching
|
|
1137
|
+
// Start background PR fetching, file status checks, and size estimation
|
|
1083
1138
|
startPrFetching(state, () => render(state))
|
|
1084
1139
|
startFileStatusFetching(state, () => render(state))
|
|
1140
|
+
startSizeFetching(state, () => render(state))
|
|
1085
1141
|
|
|
1086
1142
|
// Start spinner animation timer
|
|
1087
1143
|
const spinnerTimer = startSpinnerTimer(state, () => render(state))
|
package/lib/git.ts
CHANGED
|
@@ -238,6 +238,45 @@ export async function fetchWorktreeFileStatus(
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Directory size
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
export type DirSize =
|
|
246
|
+
| { type: "loading" }
|
|
247
|
+
| { type: "done"; bytes: number }
|
|
248
|
+
| { type: "error" }
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get the total size of a directory using `du -sk`.
|
|
252
|
+
* Returns size in bytes. Uses -sk (kilobytes, no follow symlinks)
|
|
253
|
+
* to keep it fast -- avoids traversing linked node_modules twice.
|
|
254
|
+
*/
|
|
255
|
+
export async function fetchDirectorySize(
|
|
256
|
+
dirPath: string,
|
|
257
|
+
): Promise<DirSize> {
|
|
258
|
+
const result = await $`du -sk ${dirPath}`.nothrow().quiet()
|
|
259
|
+
if (result.exitCode !== 0) return { type: "error" }
|
|
260
|
+
|
|
261
|
+
const kb = parseInt(result.stdout.toString().split("\t")[0], 10)
|
|
262
|
+
if (isNaN(kb)) return { type: "error" }
|
|
263
|
+
|
|
264
|
+
return { type: "done", bytes: kb * 1024 }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Format bytes into a human-readable string (e.g. "9.4 GB", "29 MB"). */
|
|
268
|
+
export function formatBytes(bytes: number): string {
|
|
269
|
+
if (bytes < 1024) return `${bytes} B`
|
|
270
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
271
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
272
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
273
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// File list (per-worktree)
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
241
280
|
/** A single file entry from git status with its change type. */
|
|
242
281
|
export interface FileEntry {
|
|
243
282
|
path: string
|
package/lib/tui.ts
CHANGED
|
@@ -29,6 +29,34 @@ export const c = {
|
|
|
29
29
|
lime: (s: string) => `${CSI}38;5;154m${s}${CSI}0m`,
|
|
30
30
|
} as const
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Apply a linear gradient across a string using 256-colour ANSI.
|
|
34
|
+
* Each character gets an interpolated colour between the start and end RGB values.
|
|
35
|
+
*/
|
|
36
|
+
export function gradient(
|
|
37
|
+
s: string,
|
|
38
|
+
from: [number, number, number],
|
|
39
|
+
to: [number, number, number],
|
|
40
|
+
): string {
|
|
41
|
+
const len = s.length
|
|
42
|
+
if (len === 0) return s
|
|
43
|
+
return s
|
|
44
|
+
.split("")
|
|
45
|
+
.map((ch, i) => {
|
|
46
|
+
const t = len === 1 ? 0 : i / (len - 1)
|
|
47
|
+
const r = Math.round(from[0] + (to[0] - from[0]) * t)
|
|
48
|
+
const g = Math.round(from[1] + (to[1] - from[1]) * t)
|
|
49
|
+
const b = Math.round(from[2] + (to[2] - from[2]) * t)
|
|
50
|
+
return `${CSI}38;2;${r};${g};${b}m${ch}`
|
|
51
|
+
})
|
|
52
|
+
.join("") + `${CSI}0m`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pre-built gradient for the "fell" brand text. Cyan -> lime. */
|
|
56
|
+
export function fellLogo(): string {
|
|
57
|
+
return gradient("fell", [80, 200, 255], [160, 230, 80])
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
export const SPINNER_FRAMES = [
|
|
33
61
|
"\u28CB", "\u28D9", "\u28F9", "\u28F8", "\u28FC", "\u28F4",
|
|
34
62
|
"\u28E6", "\u28E7", "\u28C7", "\u28CF",
|
|
@@ -251,8 +279,7 @@ export function renderHelpLines(): string[] {
|
|
|
251
279
|
|
|
252
280
|
export function printCliHelp(): void {
|
|
253
281
|
console.log()
|
|
254
|
-
console.log(` ${c.
|
|
255
|
-
console.log(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
|
|
282
|
+
console.log(` ${c.bold(fellLogo())} ${c.dim("Interactive Worktree Cleanup")}`)
|
|
256
283
|
console.log()
|
|
257
284
|
console.log(c.yellow(" USAGE"))
|
|
258
285
|
console.log()
|