@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 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/header-img.png" height="300px">
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
- const mainLine = ` ${cursor} ${check} ${branchPadded}${sha} ${pr}${tagStr}`
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 selectedInfo =
227
+ const suffix =
207
228
  state.selected.size > 0
208
229
  ? ` ${c.lime(`${state.selected.size} selected`)}`
209
- : ` ${c.dim(c.italic("worktree cli"))}`
210
- lines.push(` ${c.dim("▐▘ █▌ ▐ ▐")}${selectedInfo}`)
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.dim("▐▘ █▌ ▐ ▐")} ${c.dim("--list")}`)
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((i) => i.prStatus.type === "loading")
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 + file status checks
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.dim("▐▘ █▌ ▐ ▐")} ${c.dim("Interactive git worktree manager")}`)
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccy/fell",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Interactive git worktree manager. Navigate, inspect, delete, and prune worktrees with async PR status fetching.",
5
5
  "license": "MIT",
6
6
  "bin": {