Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/api/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import Clipboard from '../utils/clipboard.js';
* @property {ConfigManager} [config] - ConfigManager instance for isolated configuration.
* If not provided, an isolated instance will be created. This enables concurrent
* copy operations with different configurations.
* @property {Function} [onProgress] - Progress callback ({ percent, message }).
* Called periodically during copy with normalized progress updates (0-100%).
* Scan phase covers 0-80%, formatting 80-100%.
* @property {number} [progressThrottleMs=100] - Minimum ms between progress emissions.
*/

/**
Expand Down Expand Up @@ -110,6 +114,35 @@ export async function copy(basePath, options = {}) {
// This enables concurrent copy operations with different configurations
const configInstance = options.config || (await ConfigManager.create());

// Build progress wrapper: scan gets 0-80%, format gets 80-100%
const { onProgress, progressThrottleMs } = options;
let scanProgress = null;
let lastEmittedPercent = -1;

/**
* Emit progress with a monotonic guard so percent never decreases.
* Swallows exceptions so a buggy callback never breaks the operation.
*/
const emitProgress = onProgress
? (percent, message) => {
const clamped = Math.max(percent, lastEmittedPercent);
lastEmittedPercent = clamped;
try {
onProgress({ percent: clamped, message });
} catch {
// Swallow callback exceptions
}
}
: null;

if (emitProgress) {
emitProgress(0, 'Starting...');
scanProgress = (progress) => {
// Scale scan progress to 0-80%
emitProgress(Math.round(progress.percent * 0.8), progress.message);
};
}

// Handle dry run
if (options.dryRun) {
// For dry run, collect file list without content
Expand All @@ -119,13 +152,19 @@ export async function copy(basePath, options = {}) {
config: configInstance,
includeContent: false,
transform: false,
onProgress: scanProgress,
progressThrottleMs,
})) {
files.push(file);
}

const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
const manifest = files.map((file) => ({ path: file.path, size: file.size || 0 }));

if (emitProgress) {
emitProgress(100, 'Complete');
}

return {
output: '',
files: files,
Expand All @@ -144,7 +183,12 @@ export async function copy(basePath, options = {}) {
const scanErrors = [];

try {
for await (const file of scan(basePath, { ...options, config: configInstance })) {
for await (const file of scan(basePath, {
...options,
config: configInstance,
onProgress: scanProgress,
progressThrottleMs,
})) {
files.push(file);
}
} catch (error) {
Expand All @@ -158,6 +202,10 @@ export async function copy(basePath, options = {}) {
// Calculate stats
const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);

if (emitProgress) {
emitProgress(80, 'Formatting output...');
}

// Format output
const output = await format(files, {
format: options.format,
Expand All @@ -172,6 +220,10 @@ export async function copy(basePath, options = {}) {
// Build lightweight manifest (path + size only — no content)
const manifest = files.map((file) => ({ path: file.path, size: file.size || 0 }));

if (emitProgress) {
emitProgress(95, 'Finalizing...');
}

// Build result
const result = {
output,
Expand Down Expand Up @@ -216,6 +268,10 @@ export async function copy(basePath, options = {}) {
}
}

if (emitProgress) {
emitProgress(100, 'Complete');
}

return result;
}

Expand Down
14 changes: 14 additions & 0 deletions src/api/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Pipeline from '../pipeline/Pipeline.js';
import { ValidationError } from '../utils/errors.js';
import { ConfigManager } from '../config/ConfigManager.js';
import FolderProfileLoader from '../config/FolderProfileLoader.js';
import { ProgressTracker } from '../utils/ProgressTracker.js';
import path from 'path';
import fs from 'fs-extra';

Expand Down Expand Up @@ -42,6 +43,9 @@ import fs from 'fs-extra';
* @property {ConfigManager} [config] - ConfigManager instance for isolated configuration.
* If not provided, an isolated instance will be created. This enables concurrent
* scan operations with different configurations.
* @property {Function} [onProgress] - Progress callback ({ percent, message }).
* Called periodically during scanning with normalized progress updates (0-100%).
* @property {number} [progressThrottleMs=100] - Minimum ms between progress emissions.
*/

/**
Expand Down Expand Up @@ -291,6 +295,16 @@ export async function* scan(basePath, options = {}) {

pipeline.through(stages);

// Setup progress tracking
if (options.onProgress) {
const tracker = new ProgressTracker({
totalStages: stages.length,
onProgress: options.onProgress,
throttleMs: options.progressThrottleMs ?? 100,
});
tracker.attach(pipeline);
}

// Setup abort handler with cleanup
let abortHandler = null;
if (options.signal) {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { default as Pipeline } from './pipeline/Pipeline.js';
export { default as Stage } from './pipeline/Stage.js';
export { default as TransformerRegistry } from './transforms/TransformerRegistry.js';
export { default as BaseTransformer } from './transforms/BaseTransformer.js';
export { ProgressTracker } from './utils/ProgressTracker.js';

// Configuration utilities
export { config, configAsync, ConfigManager } from './config/ConfigManager.js';
Expand Down
181 changes: 181 additions & 0 deletions src/utils/ProgressTracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Normalizes pipeline events into simple progress updates.
*
* Translates detailed pipeline events (stage:start, stage:complete, file:batch,
* stage:progress) into a simple { percent, message } format for UI consumers.
*
* Progress guarantees:
* - Always starts at 0%
* - Always ends at 100% on success
* - Monotonically increasing (never goes backward)
* - Throttled to avoid overwhelming UI (default 100ms)
*/
export class ProgressTracker {
/**
* @param {Object} options
* @param {number} options.totalStages - Total number of pipeline stages
* @param {Function} [options.onProgress] - Progress callback ({ percent, message })
* @param {number} [options.throttleMs=100] - Minimum ms between emissions
*/
constructor({ totalStages, onProgress, throttleMs = 100 } = {}) {
this.totalStages = totalStages || 1;
this.onProgress = onProgress || (() => {});
this.throttleMs = throttleMs;

this.completedStages = 0;
this.currentStageIndex = -1;
this.currentStageProgress = 0;
this.lastPercent = -1;
this.lastEmitTime = 0;
this.started = false;
this.finished = false;
}

/**
* Attach event listeners to a pipeline instance.
* @param {import('../pipeline/Pipeline.js').default} pipeline
*/
attach(pipeline) {
pipeline.on('pipeline:start', () => {
this._emitForced({ percent: 0, message: 'Starting...' });
this.started = true;
});

pipeline.on('stage:start', (data) => {
this.currentStageIndex = data.index;
this.currentStageProgress = 0;

const percent = this._calculatePercent();
this._emit({ percent, message: `${this._formatStageName(data.stage)}...` });
});

pipeline.on('stage:progress', (data) => {
this.currentStageProgress = data.progress || 0;

const percent = this._calculatePercent();
const message = data.message || `${this._formatStageName(data.stage)}...`;
this._emit({ percent, message });
});

pipeline.on('file:batch', (data) => {
const percent = this._calculatePercent();
const message = data.lastFile
? `Processing ${data.lastFile}`
: `Processed ${data.count} files`;
this._emit({ percent, message });
});

pipeline.on('stage:complete', (data) => {
this.completedStages = data.index + 1;
this.currentStageProgress = 0;

const percent = this._calculatePercent();
this._emit({
percent,
message: `Completed ${this._formatStageName(data.stage)}`,
});
});

pipeline.on('pipeline:complete', () => {
this._emitForced({ percent: 100, message: 'Complete' });
this.finished = true;
});

pipeline.on('pipeline:error', () => {
// On error, emit final progress at whatever we reached
if (!this.finished) {
const percent = this._calculatePercent();
this._emitForced({ percent, message: 'Error occurred' });
this.finished = true;
}
});
}

/**
* Calculate current overall progress percentage.
* @returns {number} Progress 0-99 (100 is only emitted on pipeline:complete)
* @private
*/
_calculatePercent() {
const stagePercent = (this.completedStages / this.totalStages) * 100;
const withinStagePercent = (this.currentStageProgress / 100 / this.totalStages) * 100;
return Math.min(Math.round(stagePercent + withinStagePercent), 99);
}

/**
* Format a stage class name into a human-readable message.
* @param {string} stageName
* @returns {string}
* @private
*/
_formatStageName(stageName) {
// Convert "FileDiscoveryStage" -> "Discovering files"
// Convert "ProfileFilterStage" -> "Filtering by profile"
const stageMessages = {
FileDiscoveryStage: 'Discovering files',
AlwaysIncludeStage: 'Including required files',
GitFilterStage: 'Filtering by git status',
ProfileFilterStage: 'Applying filters',
DeduplicateFilesStage: 'Removing duplicates',
SortFilesStage: 'Sorting files',
FileLoadingStage: 'Loading file contents',
TransformStage: 'Transforming files',
CharLimitStage: 'Applying character limits',
SecretsGuardStage: 'Scanning for secrets',
InstructionsStage: 'Processing instructions',
LimitStage: 'Applying limits',
OutputFormattingStage: 'Formatting output',
StreamingOutputStage: 'Streaming output',
};

return stageMessages[stageName] || stageName;
}

/**
* Emit progress if throttle window has passed and percent has increased.
* @param {{ percent: number, message: string }} progress
* @private
*/
_emit(progress) {
// Enforce monotonic progress
if (progress.percent <= this.lastPercent) {
progress = { ...progress, percent: this.lastPercent };
}

const now = Date.now();
if (now - this.lastEmitTime < this.throttleMs) {
return;
}

this.lastPercent = progress.percent;
this.lastEmitTime = now;
try {
this.onProgress(progress);
} catch {
// Swallow callback exceptions — progress tracking must not fail the operation
}
}

/**
* Emit progress unconditionally (bypasses throttle).
* Used for start (0%) and complete (100%) events.
* @param {{ percent: number, message: string }} progress
* @private
*/
_emitForced(progress) {
// Enforce monotonic progress
if (progress.percent < this.lastPercent) {
progress = { ...progress, percent: this.lastPercent };
}

this.lastPercent = progress.percent;
this.lastEmitTime = Date.now();
try {
this.onProgress(progress);
} catch {
// Swallow callback exceptions — progress tracking must not fail the operation
}
}
}

export default ProgressTracker;
Loading
Loading