chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
import { hash } from 'crypto';
import fs, { existsSync, readFileSync } from 'fs';
import { dirname, join, relative } from 'path';
import { globSync } from 'tinyglobby';
import { ProgressDisplay } from './progress';
export const WORKSPACE_ROOT = join(__dirname, '../..');
export function copyBuiltPackage(
pkg: { pkgRoot: string; npmName: string },
outputWorkspace: string
) {
const pathInDist = join(WORKSPACE_ROOT, 'dist', pkg.pkgRoot);
const copyRoot = existsSync(pathInDist) ? pathInDist : pkg.pkgRoot;
if (copyRoot === pkg.pkgRoot) {
console.warn(`No build artifacts in "${pathInDist}, checking project root`);
}
// Copy all files from dist/packages/<pkg> to the output package root
const files = globSync(`**/*.js`, {
cwd: copyRoot,
dot: true,
ignore: ['**/node_modules/**'],
});
if (!files.length) {
throw new Error(
`Unable to find built artifacts for ${pkg.npmName}. Did you forget to build?`
);
}
const outputPkgRoot = join(outputWorkspace, 'node_modules', pkg.npmName);
fs.mkdirSync(outputPkgRoot, { recursive: true });
// Get native files count for accurate total
const nativeFiles = getNativeFiles(pkg.pkgRoot);
const totalFiles = files.length + nativeFiles.length;
const progress = new ProgressDisplay(totalFiles);
files.forEach((file) => {
const destFile = join(outputPkgRoot, file);
const destDir = dirname(destFile);
const copySrc = join(copyRoot, file);
const relativeFilePath = relative(copyRoot, copySrc);
progress.update(relativeFilePath);
fs.mkdirSync(destDir, { recursive: true });
safeCopyFileSync(copySrc, destFile, progress);
});
// Copy native files
copyNativeFiles(pkg.pkgRoot, outputPkgRoot, nativeFiles, progress);
progress.finish();
}
function getNativeFiles(pkgRoot: string): string[] {
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
return globSync(`**/*.{node,wasm,js,mjs,cjs}`, {
ignore: [
'**/node_modules/**',
'src/command-line/migrate/run-migration-process.js',
],
cwd: copyRoot,
});
}
function copyNativeFiles(
pkgRoot: string,
outputPkgRoot: string,
nativeFiles: string[],
progress: ProgressDisplay
) {
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
nativeFiles.forEach((file) => {
const destFile = join(outputPkgRoot, file);
const destDir = dirname(destFile);
const copySrc = join(copyRoot, file);
const relativeFilePath = relative(copyRoot, copySrc);
progress.update(relativeFilePath);
fs.mkdirSync(destDir, { recursive: true });
safeCopyFileSync(copySrc, destFile, progress);
});
}
function safeCopyFileSync(
src: string,
dest: string,
progress: ProgressDisplay
) {
const sha = hash('sha256', readFileSync(src));
for (let i = 0; i < 3; i++) {
try {
fs.copyFileSync(src, dest);
const destSha = hash('sha256', readFileSync(dest));
if (sha === destSha) {
return;
} else {
progress.insertBefore(
`Hash mismatch when copying ${src} to ${dest}, retrying...`
);
}
} catch (e) {
progress.insertBefore(
`Error copying file ${src} to ${dest}: ${(e as Error).message}, retrying...`
);
}
}
throw new Error(`Failed to copy ${src} to ${dest} after 3 attempts.`);
}
@@ -0,0 +1,563 @@
import fs, { Dirent, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'path';
import pc from 'picocolors';
import { termSize, truncateEnd } from './ui';
const SEARCH_DEPTH = 3;
// Directories to never recurse into. We still detect node_modules
// *presence* (marks a workspace root) via a stat, just don't list inside it.
const SKIP_RECURSE = new Set([
// VCS
'.git',
'.svn',
'.hg',
// Package managers (contents, not the marker)
'node_modules',
'.yarn',
'.pnpm',
// Build / framework caches
'.cache',
'.next',
'.nuxt',
'.output',
'.turbo',
'.angular',
// Editor / IDE
'.vscode',
'.idea',
// OS
'.Trash',
]);
// ── Keypress parsing ────────────────────────────────────────────────
interface KeyPress {
name: string; // e.g. 'a', 'enter', 'backspace', 'up', 'tab'
ctrl: boolean;
meta: boolean; // Alt / Option
char: string; // printable character, or '' for special keys
}
function parseKeypress(data: Buffer): KeyPress {
const raw = data.toString();
// Meta (Alt/Option) + key: ESC followed by a single character
if (raw.length === 2 && raw[0] === '\x1b' && raw[1] !== '[') {
const ch = raw[1];
if (ch === '\x7f')
return { name: 'backspace', ctrl: false, meta: true, char: '' };
return { name: ch, ctrl: false, meta: true, char: ch };
}
// CSI sequences: ESC [ <letter>
if (raw.startsWith('\x1b[')) {
const suffix = raw.slice(2);
const csiMap: Record<string, string> = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
H: 'home',
F: 'end',
};
const name = csiMap[suffix] ?? `unknown(${suffix})`;
return { name, ctrl: false, meta: false, char: '' };
}
// Single-byte control characters
if (raw.length === 1 && raw.charCodeAt(0) < 0x20) {
const code = raw.charCodeAt(0);
const ctrlMap: Record<number, string> = {
0x03: 'c', // Ctrl+C
0x08: 'backspace', // Ctrl+H (legacy backspace)
0x09: 'tab',
0x0d: 'enter',
0x15: 'u', // Ctrl+U
0x17: 'w', // Ctrl+W
};
const name = ctrlMap[code] ?? String.fromCharCode(code + 0x60);
const isCtrl = code !== 0x09 && code !== 0x0d; // tab/enter aren't "ctrl" keys
return { name, ctrl: isCtrl, meta: false, char: '' };
}
// DEL (0x7f) — Backspace on most terminals
if (raw === '\x7f') {
return { name: 'backspace', ctrl: false, meta: false, char: '' };
}
// Printable characters (including pasted multi-char text)
return { name: raw, ctrl: false, meta: false, char: raw };
}
// ── Input parsing ────────────────────────────────────────────────────
interface ParsedInput {
resolvedSearchDir: string;
filter: string;
prefix: string;
selfDisplay: string | null;
}
function parseInput(input: string): ParsedInput {
let searchDir: string;
let filter: string;
let prefix: string;
const expanded = input.startsWith('~/')
? (process.env.HOME || '') + input.slice(1)
: input;
if (expanded === '' || expanded.endsWith('/')) {
searchDir = expanded || '.';
filter = '';
prefix = input;
} else {
const d = dirname(expanded);
searchDir = d;
filter = basename(expanded).toLowerCase();
const inputDir = dirname(input);
prefix = inputDir === '.' ? '' : inputDir + '/';
}
const resolvedSearchDir = resolve(searchDir);
// Self-directory: show the search dir itself when there's no child filter
// (empty input, trailing slash, or the literal "." / ".." inputs).
let selfDisplay: string | null = null;
const showSelf = filter === '' || input === '.' || input === '..';
if (showSelf && existsSync(join(resolvedSearchDir, 'node_modules'))) {
selfDisplay =
input === ''
? '.'
: input.endsWith('/')
? input.replace(/\/+$/, '')
: input;
}
return { resolvedSearchDir, filter, prefix, selfDisplay };
}
// ── Parallel directory walker ────────────────────────────────────────
/**
* Walk directories in parallel, invoking `onFound` for each workspace root
* (directory containing `node_modules`). All entries within a directory
* level are explored concurrently via Promise.all, which dramatically
* reduces wall-clock time compared to a sequential async generator.
*/
async function walkWorkspaceDirs(
dir: string,
maxDepth: number,
relPrefix: string,
onFound: (relPath: string) => void,
isCancelled: () => boolean
): Promise<void> {
if (maxDepth < 0 || isCancelled()) return;
let entries: Dirent[];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
if (isCancelled()) return;
await Promise.all(
entries.map(async (e) => {
if (isCancelled()) return;
if (!e.isDirectory() || SKIP_RECURSE.has(e.name)) return;
const fullPath = join(dir, e.name);
const relPath = relPrefix ? relPrefix + '/' + e.name : e.name;
// Check for node_modules with a cheap stat rather than readdir
try {
await fs.promises.access(join(fullPath, 'node_modules'));
if (!isCancelled()) onFound(relPath);
} catch {
// Not a workspace root — keep looking deeper
}
if (maxDepth > 0 && !isCancelled()) {
await walkWorkspaceDirs(
fullPath,
maxDepth - 1,
relPath,
onFound,
isCancelled
);
}
})
);
}
// ── Interactive picker ───────────────────────────────────────────────
export class DirectoryPicker {
private input: string = '';
private cursorPos: number = 0;
private selectedIndex: number = -1; // -1 = on input line
private suggestions: string[] = [];
private lastRenderLines = 0;
private resolvePromise!: (value: string) => void;
// ── Scan cache ─────────────────────────────────────────────────────
// Key: resolved search directory path
// Value: all workspace-root relative paths found under that directory
private scanCache = new Map<string, string[]>();
private completedScans = new Set<string>();
private activeScanKey: string | null = null;
private cancelActiveScan: (() => void) | null = null;
/** Visible suggestion count adapts to the terminal height */
private get maxSuggestions(): number {
const { rows } = termSize();
return Math.max(3, Math.min(Math.floor(rows * 0.3), rows - 4));
}
run(): Promise<string> {
return new Promise<string>((res) => {
this.resolvePromise = res;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', this.onKeypress);
this.updateSuggestions();
this.render();
});
}
private onKeypress = (data: Buffer) => {
const key = parseKeypress(data);
// ── Action keys ───────────────────────────────────────────────
if (key.ctrl && key.name === 'c') {
this.cleanup();
process.exit(0);
}
if (key.name === 'enter') {
const value =
this.selectedIndex >= 0
? this.suggestions[this.selectedIndex]
: this.input;
this.cleanup();
this.resolvePromise(value);
return;
}
if (key.name === 'tab') {
if (this.suggestions.length > 0) {
const idx = Math.max(this.selectedIndex, 0);
this.input = this.suggestions[idx];
this.cursorPos = this.input.length;
this.selectedIndex = -1;
this.updateSuggestions();
}
this.render();
return;
}
// ── Navigation ────────────────────────────────────────────────
if (key.name === 'up') {
this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : -1;
this.render();
return;
}
if (key.name === 'down') {
if (this.selectedIndex < this.suggestions.length - 1) {
this.selectedIndex++;
}
this.render();
return;
}
if (key.name === 'right') {
if (this.cursorPos < this.input.length) this.cursorPos++;
this.render();
return;
}
if (key.name === 'left') {
if (this.cursorPos > 0) this.cursorPos--;
this.render();
return;
}
// ── Deletion ──────────────────────────────────────────────────
if (key.ctrl && key.name === 'u') {
this.input = '';
this.cursorPos = 0;
this.selectedIndex = -1;
this.updateSuggestions();
this.render();
return;
}
// Ctrl+W or Meta+Backspace — delete word backward
if (
(key.ctrl && key.name === 'w') ||
(key.meta && key.name === 'backspace')
) {
this.deleteWordBackward();
this.render();
return;
}
if (key.name === 'backspace') {
if (this.cursorPos > 0) {
this.input =
this.input.slice(0, this.cursorPos - 1) +
this.input.slice(this.cursorPos);
this.cursorPos--;
this.selectedIndex = -1;
this.updateSuggestions();
}
this.render();
return;
}
// ── Printable text ────────────────────────────────────────────
if (key.char) {
this.input =
this.input.slice(0, this.cursorPos) +
key.char +
this.input.slice(this.cursorPos);
this.cursorPos += key.char.length;
this.selectedIndex = -1;
this.updateSuggestions();
this.render();
}
};
private deleteWordBackward() {
if (this.cursorPos <= 0) return;
const before = this.input.slice(0, this.cursorPos);
// Delete trailing slashes, then back to the previous slash or start
const trimmed = before.replace(/\/+$/, '');
const lastSlash = trimmed.lastIndexOf('/');
const newEnd = lastSlash === -1 ? 0 : lastSlash + 1;
this.input = this.input.slice(0, newEnd) + this.input.slice(this.cursorPos);
this.cursorPos = newEnd;
this.selectedIndex = -1;
this.updateSuggestions();
}
// ── Suggestion management ──────────────────────────────────────────
/**
* Recompute `this.suggestions` from the cache using the current input.
* Pure synchronous filter — no I/O.
*/
private refreshSuggestions() {
const parsed = parseInput(this.input);
this.suggestions = parsed.selfDisplay ? [parsed.selfDisplay] : [];
const cached = this.scanCache.get(parsed.resolvedSearchDir);
if (!cached) return;
for (const relPath of cached) {
if (parsed.filter) {
const firstSegment = relPath.split('/')[0].toLowerCase();
if (!firstSegment.startsWith(parsed.filter)) continue;
}
const displayPath = parsed.prefix + relPath;
// Insert in sorted position
const insertIdx = this.suggestions.findIndex((s) => s > displayPath);
if (insertIdx === -1) {
this.suggestions.push(displayPath);
} else {
this.suggestions.splice(insertIdx, 0, displayPath);
}
}
}
/**
* Called on every input change. Refreshes suggestions from cache instantly,
* then starts an async scan if the search directory hasn't been fully scanned.
*/
private updateSuggestions() {
const parsed = parseInput(this.input);
const cacheKey = parsed.resolvedSearchDir;
// Always refresh from whatever's in the cache (instant)
this.refreshSuggestions();
if (!existsSync(cacheKey)) return;
// If this directory is fully scanned, we're done — cache has everything
if (this.completedScans.has(cacheKey)) return;
// If there's already an active scan for this same directory, let it run
if (this.activeScanKey === cacheKey) return;
// Different directory (or first time) — cancel any old scan, start new
this.cancelActiveScan?.();
this.activeScanKey = cacheKey;
if (!this.scanCache.has(cacheKey)) {
this.scanCache.set(cacheKey, []);
}
this.startScan(cacheKey);
}
private async startScan(cacheKey: string) {
let cancelled = false;
this.cancelActiveScan = () => {
cancelled = true;
};
const cache = this.scanCache.get(cacheKey)!;
// Schedule suggestion refresh + render as a macrotask so that
// keypress events (also macrotasks) get interleaved rather than
// starved behind a flood of Promise.all microtask continuations.
let renderPending = false;
const scheduleRender = () => {
if (renderPending) return;
renderPending = true;
setImmediate(() => {
renderPending = false;
if (!cancelled) {
this.refreshSuggestions();
this.render();
}
});
};
await walkWorkspaceDirs(
cacheKey,
SEARCH_DEPTH,
'',
(relPath) => {
cache.push(relPath);
scheduleRender();
},
() => cancelled
);
// Final render to pick up any results from the last batch
if (!cancelled) {
this.refreshSuggestions();
this.render();
this.completedScans.add(cacheKey);
this.activeScanKey = null;
this.cancelActiveScan = null;
}
}
// ── Rendering ──────────────────────────────────────────────────────
private render() {
const CLR = '\x1b[K'; // clear to end of line (terminal control, not a color)
// On subsequent renders the cursor is on the input line (from prior
// positioning). Just return the cursor to column 0 so we overwrite in
// place. On the first render lastRenderLines is 0, so this is a no-op.
if (this.lastRenderLines > 0) {
process.stdout.write('\r');
}
const { cols } = termSize();
// ── Input line ──────────────────────────────────────────────────
const promptLabel = ` ${pc.cyan('>')} Repo path: `;
const promptVisualLen = ' > Repo path: '.length; // without ANSI
const maxInputDisplay = cols - promptVisualLen - 1;
const displayInput =
this.input.length > maxInputDisplay
? '…' + this.input.slice(-(maxInputDisplay - 1))
: this.input;
process.stdout.write(`${promptLabel}${displayInput}${CLR}\n`);
// Blank separator between input and suggestions
process.stdout.write(`${CLR}\n`);
// ── Suggestion lines ────────────────────────────────────────────
const visible = this.suggestions.slice(0, this.maxSuggestions);
const shownSuggestions = visible.length;
const pointer = ' ';
const noPointer = ' ';
const indent = ' ';
const maxSuggestionLen = cols - indent.length - pointer.length - 1;
for (let i = 0; i < shownSuggestions; i++) {
const text = truncateEnd(visible[i], maxSuggestionLen);
if (i === this.selectedIndex) {
process.stdout.write(
`${indent}${pc.cyan(pointer)}${pc.inverse(` ${text} `)}${CLR}\n`
);
} else {
process.stdout.write(
`${indent}${pc.dim(`${noPointer}${text}`)}${CLR}\n`
);
}
}
// ── Hint line ───────────────────────────────────────────────────
process.stdout.write(
`${pc.dim(' ↑↓ navigate tab complete enter select')}${CLR}\n`
);
// ── Clear leftover lines from previous render ───────────────────
const totalLines = 1 + 1 + shownSuggestions + 1; // input + blank + suggestions + hint
for (let i = totalLines; i < this.lastRenderLines; i++) {
process.stdout.write(`${CLR}\n`);
}
// Track how many physical lines we wrote (including clears)
const linesWritten = Math.max(totalLines, this.lastRenderLines);
this.lastRenderLines = totalLines;
// Every line we wrote ended with \n, so the cursor is now
// `linesWritten` rows below the input line. Move back up.
if (linesWritten > 0) {
process.stdout.write(`\x1b[${linesWritten}A`);
}
// Position cursor within the input (account for truncation)
const inputOffset =
this.input.length > maxInputDisplay
? this.cursorPos - (this.input.length - maxInputDisplay) + 1
: this.cursorPos;
const cursorCol = Math.max(
promptVisualLen + 1,
promptVisualLen + inputOffset + 1
);
process.stdout.write(`\x1b[${Math.min(cursorCol, cols)}G`);
}
private cleanup() {
// Cancel any in-flight scan
this.cancelActiveScan?.();
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener('data', this.onKeypress);
// Cursor is on the input line (from render positioning).
// Go to column 0, then overwrite with the final value.
const finalValue =
this.selectedIndex >= 0
? this.suggestions[this.selectedIndex]
: this.input;
process.stdout.write(`\r Repo path: ${finalValue}\x1b[K\n`);
// Clear all the suggestion / hint lines below
for (let i = 1; i < this.lastRenderLines; i++) {
process.stdout.write('\x1b[K\n');
}
// Move back up so subsequent output starts right after the input line
if (this.lastRenderLines > 1) {
process.stdout.write(`\x1b[${this.lastRenderLines - 1}A`);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import { readJsonFile } from '@nx/devkit';
import { execSync } from 'child_process';
import { readJsonSync } from 'fs-extra';
import { dirname, join } from 'path';
import { globSync } from 'tinyglobby';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { copyBuiltPackage, WORKSPACE_ROOT } from './copy';
import { DirectoryPicker } from './directory-picker';
import { asciiBlock, centerStringInWidth, termSize } from './ui';
const packages: { npmName: string; nxName: string; pkgRoot: string }[] =
globSync(`packages/*/package.json`).map((p) => {
const pkgJson = readJsonSync(p);
const pkgRoot = dirname(p);
const projectJson = (() => {
try {
return readJsonFile(join(WORKSPACE_ROOT, pkgRoot, 'project.json'));
} catch {}
})();
return {
npmName: pkgJson.name,
nxName: projectJson?.name ?? pkgJson.nx?.name ?? pkgJson.name,
pkgRoot,
};
});
/**
* Score a package name against the user's input for ranking.
* Lower score = better match. Returns Infinity for no match.
*
* Matching is done against the "short name" — the part after the scope
* (e.g. "react" for "@nx/react") as well as the full name.
*
* 0 exact match on short name ("nx" → "nx")
* 1 exact match on full name
* 2 short name starts with input ("react" matches "@nx/react-native")
* 3 full name starts with input
* 4 short name contains input
* 5 full name contains input
*/
function packageMatchScore(input: string, name: string): number {
const lower = input.toLowerCase();
const fullLower = name.toLowerCase();
const shortName = fullLower.includes('/')
? fullLower.split('/').pop()!
: fullLower;
if (shortName === lower) return 0;
if (fullLower === lower) return 1;
if (shortName.startsWith(lower)) return 2;
if (fullLower.startsWith(lower)) return 3;
if (shortName.includes(lower)) return 4;
if (fullLower.includes(lower)) return 5;
return Infinity;
}
async function promptPackages(): Promise<typeof packages> {
const { prompt } = require('enquirer');
// Reserve lines for the prompt header, input, footer hint, and breathing room
const visibleChoices = Math.max(5, termSize().rows - 4);
const result: { packages: string[] } = await prompt({
type: 'autocomplete',
name: 'packages',
message: 'Select packages to copy',
choices: packages.map((p) => p.nxName),
multiple: true,
limit: visibleChoices,
suggest(input: string, choices: { name: string; message: string }[]) {
if (!input) return choices;
return choices
.map((ch) => ({ ch, score: packageMatchScore(input, ch.message) }))
.filter(({ score }) => score < Infinity)
.sort((a, b) => a.score - b.score)
.map(({ ch }) => ch);
},
});
if (result.packages.length === 0) {
console.error('No packages selected.');
process.exit(1);
}
return result.packages.map(
(nxName) => packages.find((p) => p.nxName === nxName)!
);
}
yargs(hideBin(process.argv))
.command({
command: '$0',
builder: (yargs) =>
yargs
.option('package', {
type: 'string',
choices: packages.flatMap((p) => [p.npmName, p.nxName]),
description: 'The package to copy the build outputs from',
})
.option('repo', {
type: 'string',
description:
'The root path of the repo to copy the built packages to',
})
.option('build', {
type: 'boolean',
description: 'Set to false to skip prebuild step.',
default: true,
}),
handler: async (argv) => {
const argvPackage =
argv.package &&
packages.find(
(p) => p.nxName === argv.package || p.npmName === argv.package
);
const selectedPackages = argvPackage
? [argvPackage]
: await promptPackages();
const selectedNxProjects = selectedPackages.map((p) => p.nxName);
const repo = argv.repo || (await new DirectoryPicker().run());
if (argv.build) {
execSync(
'nx run-many --tuiAutoExit=0 -t build -p ' +
selectedNxProjects.join(' '),
{
stdio: 'inherit',
}
);
}
const termWidth = process.stdout.columns || 80;
const renderWidth = termWidth - termWidth * 0.2;
for (const pkg of selectedPackages) {
console.log();
console.log(
asciiBlock(
renderWidth,
2,
1,
`Copying ${pkg.npmName} to ${repo}/node_modules/${pkg.npmName}`
)
.map((line) => centerStringInWidth(line, termWidth))
.join('\n')
);
copyBuiltPackage(pkg, repo);
}
},
})
.parseAsync();
+88
View File
@@ -0,0 +1,88 @@
import { centerStringInWidth } from './ui';
function getProgressBar(current: number, total: number, width = 40): string {
const percentage = total > 0 ? current / total : 0;
const filled = Math.round(width * percentage);
const empty = width - filled;
return '█'.repeat(filled) + '░'.repeat(empty);
}
function truncateStart(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return '...' + str.slice(-(maxLen - 3));
}
export class ProgressDisplay {
private current = 0;
private total: number;
private lastFile = '';
private initialized: boolean = false;
constructor(total: number) {
this.total = total;
}
insertBefore(str: string) {
process.stdout.write('\x1b[J');
console.log(str);
}
row(maxWidth: number, minGap: number, ...parts: string[]): string {
const totalPartsLength = parts.reduce((sum, part) => sum + part.length, 0);
const calculatedGap = (maxWidth - totalPartsLength) / (parts.length - 1);
if (calculatedGap < minGap) {
const neededReduction = (minGap - calculatedGap) * (parts.length - 1);
parts[0] = truncateStart(parts[0], parts[0].length - neededReduction);
}
return parts.join(' '.repeat(Math.max(calculatedGap, minGap)));
}
update(file: string, message?: string): void {
this.current++;
this.lastFile = file;
// Move cursor up 3 lines and clear each line
if (this.initialized) {
process.stdout.write('\x1b[2F');
} else {
this.initialized = true;
}
if (message) {
this.insertBefore(message);
}
this.renderCurrentStatus();
}
private renderCurrentStatus() {
const termWidth = process.stdout.columns || 80;
const adjustedTermWidth = termWidth - Math.floor(0.2 * termWidth);
const progressBar = getProgressBar(
this.current,
this.total,
adjustedTermWidth
);
// Line 1: Most recently copied file
process.stdout.write(
centerStringInWidth(
this.row(
adjustedTermWidth,
4,
this.lastFile,
`[${this.current} / ${this.total}]`
),
termWidth
) + '\x1b[K\n'
);
// Line 2: Progress bar
process.stdout.write(
centerStringInWidth(progressBar, termWidth) + '\x1b[K\n'
);
}
finish(): void {
// Add a newline after completion to separate from next output
process.stdout.write('\n');
}
}
+44
View File
@@ -0,0 +1,44 @@
export function termSize(): { cols: number; rows: number } {
return {
cols: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
};
}
export function truncateEnd(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
export const centerStringInWidth = (str: string, width: number): string => {
const padding = Math.max(0, width - str.length);
const padStart = Math.floor(padding / 2);
const padEnd = padding - padStart;
return ' '.repeat(padStart) + str + ' '.repeat(padEnd);
};
export function asciiBlock(
w: number,
px: number,
py: number,
text: string
): string[] {
const lines = text.split('\n');
const contentWidth = w - px * 2 - 2;
const paddedLines = lines.map((line) => {
const truncatedLine =
line.length > contentWidth
? line.slice(0, contentWidth - 3) + '...'
: line;
return centerStringInWidth(truncatedLine, w - 2);
});
const emptyLine = ' '.repeat(w - 2);
const blockLines = [
'┌' + '─'.repeat(w - 2) + '┐',
...Array(py).fill('│' + emptyLine + '│'),
...paddedLines.map((line) => '│' + line + '│'),
...Array(py).fill('│' + emptyLine + '│'),
'└' + '─'.repeat(w - 2) + '┘',
];
return blockLines;
}