diff --git a/.changeset/tui-progress-bars.md b/.changeset/tui-progress-bars.md new file mode 100644 index 00000000..3afc0be5 --- /dev/null +++ b/.changeset/tui-progress-bars.md @@ -0,0 +1,5 @@ +--- +'@noormdev/cli': patch +--- + +Make build, file, change, and transfer progress bars advance with completed work instead of remaining visually empty until completion. diff --git a/src/tui/hooks/useChangeProgress.ts b/src/tui/hooks/useChangeProgress.ts index ad28a91e..47e12cd8 100644 --- a/src/tui/hooks/useChangeProgress.ts +++ b/src/tui/hooks/useChangeProgress.ts @@ -92,7 +92,7 @@ export function useChangeProgress(): ChangeProgressState { useOnEvent('change:file', (data) => { setCurrentFile(data.filepath); - setFileProgress({ current: data.index, total: data.total }); + setFileProgress({ current: data.index + 1, total: data.total }); }, []); diff --git a/src/tui/hooks/useRunProgress.ts b/src/tui/hooks/useRunProgress.ts index 1cb9b8c4..e1ea5083 100644 --- a/src/tui/hooks/useRunProgress.ts +++ b/src/tui/hooks/useRunProgress.ts @@ -17,7 +17,7 @@ * return ( * * Running: {state.currentFile} - * + * * * {state.filesRun}/{state.filesTotal} files * ({state.filesSkipped} skipped, {state.filesFailed} failed) @@ -118,6 +118,7 @@ const INITIAL_STATE: RunProgressState = { type UseRunProgressReturn = { state: RunProgressState; + /** Reset all counters before starting a run with the given file count. */ reset: (totalFiles: number) => void; }; diff --git a/src/tui/hooks/useTransferProgress.ts b/src/tui/hooks/useTransferProgress.ts index 95d261fc..31527571 100644 --- a/src/tui/hooks/useTransferProgress.ts +++ b/src/tui/hooks/useTransferProgress.ts @@ -17,7 +17,7 @@ * return ( * * Transferring: {state.currentTable} - * + * * * {state.rowsTransferred} rows transferred * diff --git a/src/tui/screens/change/ChangeFFScreen.tsx b/src/tui/screens/change/ChangeFFScreen.tsx index 35c661d7..f0dbb241 100644 --- a/src/tui/screens/change/ChangeFFScreen.tsx +++ b/src/tui/screens/change/ChangeFFScreen.tsx @@ -32,7 +32,14 @@ import { } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; -import { getErrorMessage, loadChangesWithStatus, buildPendingChangeList, createChangeManager, isConfigGuarded } from '../../utils/index.js'; +import { + getErrorMessage, + loadChangesWithStatus, + buildPendingChangeList, + createChangeManager, + isConfigGuarded, + progressPercentage, +} from '../../utils/index.js'; import { validateChangeContent } from '../../../core/change/validation.js'; import { createConnection } from '../../../core/connection/factory.js'; @@ -288,7 +295,7 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { // Running if (step === 'running') { - const progressValue = progress.total > 0 ? progress.current / progress.total : 0; + const progressValue = progressPercentage(progress.current, progress.total); return ( diff --git a/src/tui/screens/change/ChangeNextScreen.tsx b/src/tui/screens/change/ChangeNextScreen.tsx index 044fcbd6..4ad70ebb 100644 --- a/src/tui/screens/change/ChangeNextScreen.tsx +++ b/src/tui/screens/change/ChangeNextScreen.tsx @@ -34,7 +34,14 @@ import { } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; -import { getErrorMessage, loadChangesWithStatus, buildPendingChangeList, createChangeManager, isConfigGuarded } from '../../utils/index.js'; +import { + getErrorMessage, + loadChangesWithStatus, + buildPendingChangeList, + createChangeManager, + isConfigGuarded, + progressPercentage, +} from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; /** @@ -343,7 +350,7 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { // Running if (step === 'running') { - const progressValue = progress.total > 0 ? progress.current / progress.total : 0; + const progressValue = progressPercentage(progress.current, progress.total); return ( diff --git a/src/tui/screens/change/ChangeRevertScreen.tsx b/src/tui/screens/change/ChangeRevertScreen.tsx index e2f04eb8..57fa262e 100644 --- a/src/tui/screens/change/ChangeRevertScreen.tsx +++ b/src/tui/screens/change/ChangeRevertScreen.tsx @@ -37,6 +37,7 @@ import { getErrorMessage, loadChangesWithStatus, createChangeManager, isConfigGuarded, + progressPercentage, } from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; @@ -276,7 +277,7 @@ export function ChangeRevertScreen({ params }: ScreenProps): ReactElement { // Reverting if (step === 'reverting') { - const progressValue = fileProgress.total > 0 ? fileProgress.current / fileProgress.total : 0; + const progressValue = progressPercentage(fileProgress.current, fileProgress.total); return ( diff --git a/src/tui/screens/change/ChangeRewindScreen.tsx b/src/tui/screens/change/ChangeRewindScreen.tsx index 990f1aa0..7aa4cad6 100644 --- a/src/tui/screens/change/ChangeRewindScreen.tsx +++ b/src/tui/screens/change/ChangeRewindScreen.tsx @@ -35,7 +35,14 @@ import { } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; -import { getErrorMessage, loadChangesWithStatus, buildAppliedChangeList, createChangeManager, isConfigGuarded } from '../../utils/index.js'; +import { + getErrorMessage, + loadChangesWithStatus, + buildAppliedChangeList, + createChangeManager, + isConfigGuarded, + progressPercentage, +} from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; /** @@ -411,7 +418,7 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { // Running if (step === 'running') { - const progressValue = progress.total > 0 ? progress.current / progress.total : 0; + const progressValue = progressPercentage(progress.current, progress.total); return ( diff --git a/src/tui/screens/change/ChangeRunScreen.tsx b/src/tui/screens/change/ChangeRunScreen.tsx index ed21f54b..90aaf5bf 100644 --- a/src/tui/screens/change/ChangeRunScreen.tsx +++ b/src/tui/screens/change/ChangeRunScreen.tsx @@ -37,6 +37,7 @@ import { getErrorMessage, loadChangesWithStatus, createChangeManager, isConfigGuarded, + progressPercentage, } from '../../utils/index.js'; import { validateChangeContent } from '../../../core/change/validation.js'; import { createConnection } from '../../../core/connection/factory.js'; @@ -272,7 +273,7 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { // Running if (step === 'running') { - const progressValue = fileProgress.total > 0 ? fileProgress.current / fileProgress.total : 0; + const progressValue = progressPercentage(fileProgress.current, fileProgress.total); return ( diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index a0643d06..0071d51f 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -34,7 +34,7 @@ import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; -import { getErrorMessage } from '../../utils/index.js'; +import { getErrorMessage, progressPercentage } from '../../utils/index.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; @@ -1258,13 +1258,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement const modeLabel = transferMode === 'export' ? 'Exporting' : transferMode === 'import' ? 'Importing' : 'Transferring'; const titleLabel = transferMode === 'export' ? 'Export' : transferMode === 'import' ? 'Import' : 'Data Transfer'; - const tableProgress = progress.tableCount > 0 - ? progress.tablesCompleted / progress.tableCount - : 0; - - const rowProgress = progress.currentRowsTotal > 0 - ? progress.currentRowsTransferred / progress.currentRowsTotal - : 0; + const tableProgress = progressPercentage(progress.tablesCompleted, progress.tableCount); + const rowProgress = progressPercentage(progress.currentRowsTransferred, progress.currentRowsTotal); return ( diff --git a/src/tui/screens/run/RunBuildScreen.tsx b/src/tui/screens/run/RunBuildScreen.tsx index fb6ebf71..07c2b2f2 100644 --- a/src/tui/screens/run/RunBuildScreen.tsx +++ b/src/tui/screens/run/RunBuildScreen.tsx @@ -31,7 +31,7 @@ import { getEffectiveBuildPaths } from '../../../core/settings/rules.js'; import { discoverFiles, runBuild } from '../../../core/runner/index.js'; import { filterFilesByPaths, findUnmatchedIncludePatterns } from '../../../core/shared/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection, progressPercentage } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; type Phase = 'loading' | 'confirm' | 'running' | 'complete' | 'error'; @@ -353,7 +353,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { if (phase === 'running') { const processed = progress.filesRun + progress.filesSkipped + progress.filesFailed + progress.filesDryRun; - const progressValue = files.length > 0 ? processed / files.length : 0; + const progressValue = progressPercentage(processed, files.length); return ( diff --git a/src/tui/screens/run/RunDirScreen.tsx b/src/tui/screens/run/RunDirScreen.tsx index ff4ba830..5a271f13 100644 --- a/src/tui/screens/run/RunDirScreen.tsx +++ b/src/tui/screens/run/RunDirScreen.tsx @@ -23,7 +23,7 @@ import { Panel, Spinner, Confirm, SelectList, FilePicker, KeyHandler, useToast } import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; import { discoverFiles, runFiles, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection, progressPercentage } from '../../utils/index.js'; import { useConnection } from '../../hooks/index.js'; import { attempt } from '@logosdx/utils'; @@ -688,7 +688,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { if (phase === 'running') { const processed = progress.filesRun + progress.filesSkipped + progress.filesFailed + progress.filesDryRun; - const progressValue = fileCount > 0 ? processed / fileCount : 0; + const progressValue = progressPercentage(processed, fileCount); return ( diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index 73942359..835f8b2a 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -28,7 +28,7 @@ import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js import { Panel, Spinner, SelectList, type SelectListItem, Confirm, KeyHandler, useToast } from '../../components/index.js'; import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; import { discoverFiles, runFiles } from '../../../core/runner/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection, progressPercentage } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; type Phase = 'loading' | 'picker' | 'confirm' | 'running' | 'complete' | 'error'; @@ -316,7 +316,7 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { if (phase === 'running') { const processed = progress.filesRun + progress.filesSkipped + progress.filesFailed + progress.filesDryRun; - const progressValue = selectedFiles.size > 0 ? processed / selectedFiles.size : 0; + const progressValue = progressPercentage(processed, selectedFiles.size); return ( diff --git a/src/tui/utils/index.ts b/src/tui/utils/index.ts index 278642c8..128cb7ff 100644 --- a/src/tui/utils/index.ts +++ b/src/tui/utils/index.ts @@ -8,6 +8,7 @@ export { resolveScreenIdentity } from './identity.js'; export { createChangeManager, type CreateChangeManagerOptions } from './change-context.js'; export { buildRunContext, type BuildRunContextOptions } from './run-context.js'; export { withScreenConnection, STOPPED_WAITING_MESSAGE } from './connection.js'; +export { progressPercentage } from './progress.js'; export { loadChangesWithStatus, buildPendingChangeList, diff --git a/src/tui/utils/progress.ts b/src/tui/utils/progress.ts new file mode 100644 index 00000000..a3a1e28e --- /dev/null +++ b/src/tui/utils/progress.ts @@ -0,0 +1,16 @@ +/** + * Convert completed work into the 0–100 value expected by Ink's ProgressBar. + * + * Returns zero until a positive total is known and clamps over-counted event + * streams so rendering remains within the component contract. + * + * @example + * const value = progressPercentage(3, 4); // 75 + */ +export function progressPercentage(completed: number, total: number): number { + + if (total <= 0) return 0; + + return Math.min(100, Math.max(0, (completed / total) * 100)); + +} diff --git a/tests/cli/utils/progress.test.ts b/tests/cli/utils/progress.test.ts new file mode 100644 index 00000000..3edd39e3 --- /dev/null +++ b/tests/cli/utils/progress.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; + +import { progressPercentage } from '../../../src/tui/utils/progress.js'; + +describe('tui progress: progressPercentage', () => { + + it('should convert completed work to the ProgressBar percentage scale', () => { + + expect(progressPercentage(1, 2)).toBe(50); + + }); + + it('should keep unknown and over-counted progress within component bounds', () => { + + expect(progressPercentage(4, 0)).toBe(0); + expect(progressPercentage(-1, 4)).toBe(0); + expect(progressPercentage(5, 4)).toBe(100); + + }); + +});