Skip to content

Commit b1870e9

Browse files
committed
fix: restore per-step progress display in TUI components
The refactored TUI components showed a single flat status line instead of the original per-operation stepper. Added an onProgress callback to cloneRepo, installPackages, and cleanupFiles so the TUI renders each step with its own status: green checkmark for completed, dimmed circle with Working... for in-progress, red cross for errors. Non-interactive path omits the callback with zero overhead.
1 parent bcb2d44 commit b1870e9

10 files changed

Lines changed: 188 additions & 24 deletions

File tree

architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ When adding a new feature, add it here. All other code (validation, cleanup, inf
7878

7979
### Operations Layer (`source/operations/`)
8080

81-
Plain async functions with no UI dependencies. Each operation receives explicit arguments (project folder, mode, features) and performs file system or shell work.
81+
Plain async functions with no UI dependencies. Each operation receives explicit arguments (project folder, mode, features) and performs file system or shell work. Multi-step operations accept an optional `onProgress` callback that the TUI uses to render per-step progress; the non-interactive path omits it.
8282

8383
| Function | What it does |
8484
|---|---|

source/__tests__/operations/cleanupFiles.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,43 @@ describe('cleanupFiles', () => {
242242
const commands = getExecFileCommands()
243243
expect(commands.at(-1)).toBe('rm -rf .install-files')
244244
})
245+
246+
describe('onProgress callback', () => {
247+
it('reports only Install script for full mode', async () => {
248+
const steps: string[] = []
249+
await cleanupFiles('/project/my_app', 'full', [], (step) => steps.push(step))
250+
251+
expect(steps).toEqual(['Install script'])
252+
})
253+
254+
it('reports all feature cleanups when no features selected', async () => {
255+
const steps: string[] = []
256+
await cleanupFiles('/project/my_app', 'custom', [], (step) => steps.push(step))
257+
258+
expect(steps).toEqual([
259+
'Component demos',
260+
'Subgraph',
261+
'Typedoc',
262+
'Vocs',
263+
'Husky',
264+
'Install script',
265+
])
266+
})
267+
268+
it('skips steps for selected features', async () => {
269+
const steps: string[] = []
270+
await cleanupFiles('/project/my_app', 'custom', ['demo', 'subgraph'], (step) =>
271+
steps.push(step),
272+
)
273+
274+
expect(steps).not.toContain('Component demos')
275+
expect(steps).not.toContain('Subgraph')
276+
expect(steps).toContain('Typedoc')
277+
expect(steps).toContain('Install script')
278+
})
279+
280+
it('works without a callback', async () => {
281+
await expect(cleanupFiles('/project/my_app', 'full')).resolves.toBeUndefined()
282+
})
283+
})
245284
})

source/__tests__/operations/cloneRepo.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,23 @@ describe('cloneRepo', () => {
9090
expect(call[0]).not.toContain('my_app')
9191
}
9292
})
93+
94+
describe('onProgress callback', () => {
95+
it('reports all 5 steps in order', async () => {
96+
const steps: string[] = []
97+
await cloneRepo('my_app', (step) => steps.push(step))
98+
99+
expect(steps).toEqual([
100+
'Cloning dAppBooster in my_app',
101+
'Fetching tags',
102+
'Checking out latest tag',
103+
'Removing .git folder',
104+
'Initializing Git repository',
105+
])
106+
})
107+
108+
it('works without a callback', async () => {
109+
await expect(cloneRepo('my_app')).resolves.toBeUndefined()
110+
})
111+
})
93112
})

source/__tests__/operations/installPackages.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,32 @@ describe('installPackages', () => {
133133

134134
expect(exec).not.toHaveBeenCalled()
135135
})
136+
137+
describe('onProgress callback', () => {
138+
it('reports one step for full mode', async () => {
139+
const steps: string[] = []
140+
await installPackages('/project/my_app', 'full', [], (step) => steps.push(step))
141+
142+
expect(steps).toEqual(['Installing packages'])
143+
})
144+
145+
it('reports two steps for custom mode with packages to remove', async () => {
146+
const steps: string[] = []
147+
await installPackages('/project/my_app', 'custom', ['demo'], (step) => steps.push(step))
148+
149+
expect(steps).toEqual(['Installing packages', 'Executing post-install scripts'])
150+
})
151+
152+
it('reports one step for custom mode with all features selected', async () => {
153+
const allFeatures = Object.keys(featureDefinitions) as Array<keyof typeof featureDefinitions>
154+
const steps: string[] = []
155+
await installPackages('/project/my_app', 'custom', allFeatures, (step) => steps.push(step))
156+
157+
expect(steps).toEqual(['Installing packages'])
158+
})
159+
160+
it('works without a callback', async () => {
161+
await expect(installPackages('/project/my_app', 'full')).resolves.toBeUndefined()
162+
})
163+
})
136164
})

source/components/steps/CloneRepo/CloneRepo.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Text } from 'ink'
2-
import { type FC, useEffect, useState } from 'react'
2+
import { type FC, useCallback, useEffect, useState } from 'react'
33
import { cloneRepo } from '../../../operations/index.js'
44
import Divider from '../../Divider.js'
55

@@ -9,11 +9,16 @@ interface Props {
99
}
1010

1111
const CloneRepo: FC<Props> = ({ projectName, onCompletion }) => {
12+
const [steps, setSteps] = useState<string[]>([])
1213
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
1314
const [errorMessage, setErrorMessage] = useState('')
1415

16+
const handleProgress = useCallback((step: string) => {
17+
setSteps((prev) => [...prev, step])
18+
}, [])
19+
1520
useEffect(() => {
16-
cloneRepo(projectName)
21+
cloneRepo(projectName, handleProgress)
1722
.then(() => {
1823
setStatus('done')
1924
onCompletion()
@@ -22,22 +27,29 @@ const CloneRepo: FC<Props> = ({ projectName, onCompletion }) => {
2227
setStatus('error')
2328
setErrorMessage(error instanceof Error ? error.message : String(error))
2429
})
25-
}, [projectName, onCompletion])
30+
}, [projectName, onCompletion, handleProgress])
31+
32+
const completedSteps = status === 'done' ? steps : steps.slice(0, -1)
33+
const currentStep = status === 'running' ? steps.at(-1) : undefined
2634

2735
return (
2836
<>
2937
<Divider title={'Git tasks'} />
30-
{status === 'running' && (
31-
<Text color={'whiteBright'}>
32-
Cloning dAppBooster in <Text italic>{projectName}</Text>... Working...
38+
{completedSteps.map((step) => (
39+
<Text key={step}>
40+
<Text color={'green'}>{'\u2714'}</Text> {step}
41+
</Text>
42+
))}
43+
{currentStep && (
44+
<Text>
45+
<Text dimColor>{'\u25CB'}</Text> {currentStep} <Text dimColor>Working...</Text>
3346
</Text>
3447
)}
35-
{status === 'done' && (
36-
<Text color={'green'}>
37-
Cloned dAppBooster in <Text italic>{projectName}</Text>. Done!
48+
{status === 'error' && (
49+
<Text>
50+
<Text color={'red'}>{'\u2717'}</Text> Failed to clone: {errorMessage}
3851
</Text>
3952
)}
40-
{status === 'error' && <Text color={'red'}>Failed to clone: {errorMessage}</Text>}
4153
</>
4254
)
4355
}

source/components/steps/FileCleanup.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Text } from 'ink'
2-
import { type FC, useEffect, useMemo, useState } from 'react'
2+
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
33
import type { FeatureName } from '../../constants/config.js'
44
import { cleanupFiles } from '../../operations/index.js'
55
import type { InstallationType, MultiSelectItem } from '../../types/types.js'
@@ -18,13 +18,18 @@ interface Props {
1818
const FileCleanup: FC<Props> = ({ onCompletion, installationConfig, projectName }) => {
1919
const { installationType, selectedFeatures } = installationConfig
2020
const projectFolder = useMemo(() => getProjectFolder(projectName), [projectName])
21+
const [steps, setSteps] = useState<string[]>([])
2122
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
2223
const [errorMessage, setErrorMessage] = useState('')
2324

25+
const handleProgress = useCallback((step: string) => {
26+
setSteps((prev) => [...prev, step])
27+
}, [])
28+
2429
useEffect(() => {
2530
const features = selectedFeatures?.map((f) => f.value as FeatureName) ?? []
2631

27-
cleanupFiles(projectFolder, installationType ?? 'full', features)
32+
cleanupFiles(projectFolder, installationType ?? 'full', features, handleProgress)
2833
.then(() => {
2934
setStatus('done')
3035
onCompletion()
@@ -33,14 +38,29 @@ const FileCleanup: FC<Props> = ({ onCompletion, installationConfig, projectName
3338
setStatus('error')
3439
setErrorMessage(error instanceof Error ? error.message : String(error))
3540
})
36-
}, [projectFolder, installationType, selectedFeatures, onCompletion])
41+
}, [projectFolder, installationType, selectedFeatures, onCompletion, handleProgress])
42+
43+
const completedSteps = status === 'done' ? steps : steps.slice(0, -1)
44+
const currentStep = status === 'running' ? steps.at(-1) : undefined
3745

3846
return (
3947
<>
4048
<Divider title={'File cleanup'} />
41-
{status === 'running' && <Text color={'whiteBright'}>Cleaning up files... Working...</Text>}
42-
{status === 'done' && <Text color={'green'}>File cleanup complete. Done!</Text>}
43-
{status === 'error' && <Text color={'red'}>Cleanup failed: {errorMessage}</Text>}
49+
{completedSteps.map((step) => (
50+
<Text key={step}>
51+
<Text color={'green'}>{'\u2714'}</Text> {step}
52+
</Text>
53+
))}
54+
{currentStep && (
55+
<Text>
56+
<Text dimColor>{'\u25CB'}</Text> {currentStep} <Text dimColor>Working...</Text>
57+
</Text>
58+
)}
59+
{status === 'error' && (
60+
<Text>
61+
<Text color={'red'}>{'\u2717'}</Text> Cleanup failed: {errorMessage}
62+
</Text>
63+
)}
4464
</>
4565
)
4666
}

source/components/steps/Install/Install.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Text } from 'ink'
2-
import { type FC, useEffect, useMemo, useState } from 'react'
2+
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
33
import type { FeatureName } from '../../../constants/config.js'
44
import { createEnvFile } from '../../../operations/createEnvFile.js'
55
import { installPackages } from '../../../operations/installPackages.js'
@@ -19,19 +19,25 @@ interface Props {
1919
const Install: FC<Props> = ({ projectName, onCompletion, installationConfig }) => {
2020
const { installationType, selectedFeatures } = installationConfig
2121
const projectFolder = useMemo(() => getProjectFolder(projectName), [projectName])
22+
const [steps, setSteps] = useState<string[]>([])
2223
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
2324
const [errorMessage, setErrorMessage] = useState('')
2425

2526
const title = installationType
2627
? installationType[0]?.toUpperCase() + installationType.slice(1)
2728
: ''
2829

30+
const handleProgress = useCallback((step: string) => {
31+
setSteps((prev) => [...prev, step])
32+
}, [])
33+
2934
useEffect(() => {
3035
const features = selectedFeatures?.map((f) => f.value as FeatureName) ?? []
3136

3237
const run = async () => {
38+
handleProgress('Creating .env.local file')
3339
await createEnvFile(projectFolder)
34-
await installPackages(projectFolder, installationType ?? 'full', features)
40+
await installPackages(projectFolder, installationType ?? 'full', features, handleProgress)
3541
}
3642

3743
run()
@@ -43,14 +49,29 @@ const Install: FC<Props> = ({ projectName, onCompletion, installationConfig }) =
4349
setStatus('error')
4450
setErrorMessage(error instanceof Error ? error.message : String(error))
4551
})
46-
}, [projectFolder, installationType, selectedFeatures, onCompletion])
52+
}, [projectFolder, installationType, selectedFeatures, onCompletion, handleProgress])
53+
54+
const completedSteps = status === 'done' ? steps : steps.slice(0, -1)
55+
const currentStep = status === 'running' ? steps.at(-1) : undefined
4756

4857
return (
4958
<>
5059
<Divider title={`${title ?? 'Full'} installation`} />
51-
{status === 'running' && <Text color={'whiteBright'}>Installing packages... Working...</Text>}
52-
{status === 'done' && <Text color={'green'}>Installation complete. Done!</Text>}
53-
{status === 'error' && <Text color={'red'}>Installation failed: {errorMessage}</Text>}
60+
{completedSteps.map((step) => (
61+
<Text key={step}>
62+
<Text color={'green'}>{'\u2714'}</Text> {step}
63+
</Text>
64+
))}
65+
{currentStep && (
66+
<Text>
67+
<Text dimColor>{'\u25CB'}</Text> {currentStep} <Text dimColor>Working...</Text>
68+
</Text>
69+
)}
70+
{status === 'error' && (
71+
<Text>
72+
<Text color={'red'}>{'\u2717'}</Text> Installation failed: {errorMessage}
73+
</Text>
74+
)}
5475
</>
5576
)
5677
}

source/operations/cleanupFiles.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,30 +75,37 @@ export async function cleanupFiles(
7575
projectFolder: string,
7676
mode: InstallationType,
7777
features: FeatureName[] = [],
78+
onProgress?: (step: string) => void,
7879
): Promise<void> {
7980
if (mode === 'custom') {
8081
if (!isFeatureSelected('demo', features)) {
82+
onProgress?.('Component demos')
8183
await cleanupDemo(projectFolder)
8284
}
8385

8486
if (!isFeatureSelected('subgraph', features)) {
87+
onProgress?.('Subgraph')
8588
await cleanupSubgraph(projectFolder, features)
8689
}
8790

8891
if (!isFeatureSelected('typedoc', features)) {
92+
onProgress?.('Typedoc')
8993
await cleanupTypedoc(projectFolder)
9094
}
9195

9296
if (!isFeatureSelected('vocs', features)) {
97+
onProgress?.('Vocs')
9398
await cleanupVocs(projectFolder)
9499
}
95100

96101
if (!isFeatureSelected('husky', features)) {
102+
onProgress?.('Husky')
97103
await cleanupHusky(projectFolder)
98104
}
99105

100106
patchPackageJson(projectFolder, features)
101107
}
102108

109+
onProgress?.('Install script')
103110
await execFile('rm', ['-rf', '.install-files'], { cwd: projectFolder })
104111
}

source/operations/cloneRepo.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,27 @@ import { repoUrl } from '../constants/config.js'
22
import { getProjectFolder } from '../utils/utils.js'
33
import { exec, execFile } from './exec.js'
44

5-
export async function cloneRepo(projectName: string): Promise<void> {
5+
export async function cloneRepo(
6+
projectName: string,
7+
onProgress?: (step: string) => void,
8+
): Promise<void> {
69
const projectFolder = getProjectFolder(projectName)
710

11+
onProgress?.(`Cloning dAppBooster in ${projectName}`)
812
await execFile('git', ['clone', '--depth', '1', '--no-checkout', repoUrl, projectName])
13+
14+
onProgress?.('Fetching tags')
915
await execFile('git', ['fetch', '--tags'], { cwd: projectFolder })
16+
17+
onProgress?.('Checking out latest tag')
1018
// Shell required for $() command substitution
1119
await exec('git checkout $(git describe --tags $(git rev-list --tags --max-count=1))', {
1220
cwd: projectFolder,
1321
})
22+
23+
onProgress?.('Removing .git folder')
1424
await execFile('rm', ['-rf', '.git'], { cwd: projectFolder })
25+
26+
onProgress?.('Initializing Git repository')
1527
await execFile('git', ['init'], { cwd: projectFolder })
1628
}

source/operations/installPackages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@ export async function installPackages(
77
projectFolder: string,
88
mode: InstallationType,
99
features: FeatureName[] = [],
10+
onProgress?: (step: string) => void,
1011
): Promise<void> {
1112
if (mode === 'full') {
13+
onProgress?.('Installing packages')
1214
await execFile('pnpm', ['i'], { cwd: projectFolder })
1315
return
1416
}
1517

1618
const packagesToRemove = getPackagesToRemove(features)
1719

1820
if (packagesToRemove.length === 0) {
21+
onProgress?.('Installing packages')
1922
await execFile('pnpm', ['i'], { cwd: projectFolder })
2023
return
2124
}
2225

26+
onProgress?.('Installing packages')
2327
await execFile('pnpm', ['remove', ...packagesToRemove], { cwd: projectFolder })
28+
29+
onProgress?.('Executing post-install scripts')
2430
await execFile('pnpm', ['run', 'postinstall'], { cwd: projectFolder })
2531
}

0 commit comments

Comments
 (0)