Fix EXT-553: Remove percentage-based progress tracking for worktree file copying (#10905)

* Fix EXT-553: Remove percentage-based progress tracking for worktree file copying

- Removed totalBytes from CopyProgress interface
- Removed Math.min() clamping that caused stuck-at-100% issue
- Changed UI from progress bar to spinner with activity indicator
- Shows 'item — X MB copied' instead of percentage
- Updated all 18 locale files
- Uses native cp with polling (no new dependencies)

* fix: translate copyingProgress text in all 17 non-English locale files

---------

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Daniel 2026-01-22 21:17:16 -05:00 committed by GitHub
parent 9d65772d24
commit 3ab1d08159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 48 additions and 77 deletions

View file

@ -265,7 +265,7 @@ describe("WorktreeIncludeService", () => {
expect(result).toContain("node_modules")
})
it("should call progress callback with size-based progress", async () => {
it("should call progress callback with bytesCopied progress", async () => {
// Set up files to copy
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules\n.env.local")
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules\n.env.local")
@ -273,22 +273,20 @@ describe("WorktreeIncludeService", () => {
await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test")
await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value")
const progressCalls: Array<{ bytesCopied: number; totalBytes: number; itemName: string }> = []
const onProgress = vi.fn((progress: { bytesCopied: number; totalBytes: number; itemName: string }) => {
const progressCalls: Array<{ bytesCopied: number; itemName: string }> = []
const onProgress = vi.fn((progress: { bytesCopied: number; itemName: string }) => {
progressCalls.push({ ...progress })
})
await service.copyWorktreeIncludeFiles(sourceDir, targetDir, onProgress)
// Should be called multiple times (initial + after each copy + during polling)
// Should be called multiple times (initial + after each copy)
expect(onProgress).toHaveBeenCalled()
// All calls should have totalBytes > 0 (since we have files)
expect(progressCalls.every((p) => p.totalBytes > 0)).toBe(true)
// Final call should have bytesCopied === totalBytes (complete)
// bytesCopied should increase over time
expect(progressCalls.length).toBeGreaterThan(0)
const finalCall = progressCalls[progressCalls.length - 1]
expect(finalCall?.bytesCopied).toBe(finalCall?.totalBytes)
expect(finalCall?.bytesCopied).toBeGreaterThan(0)
// Each call should have an item name
expect(progressCalls.every((p) => typeof p.itemName === "string")).toBe(true)

View file

@ -15,13 +15,12 @@ import ignore, { type Ignore } from "ignore"
import type { WorktreeIncludeStatus } from "./types.js"
/**
* Progress info for size-based copy tracking.
* Progress info for copy tracking.
* Shows activity without trying to predict total size (which is inaccurate).
*/
export interface CopyProgress {
/** Current bytes copied */
bytesCopied: number
/** Total bytes to copy */
totalBytes: number
/** Name of current item being copied */
itemName: string
}
@ -164,26 +163,16 @@ export class WorktreeIncludeService {
return []
}
// Calculate total size of all items to copy (for accurate progress)
const itemSizes = await Promise.all(
itemsToCopy.map(async (item) => {
const sourcePath = path.join(sourceDir, item)
const size = await this.getPathSize(sourcePath)
return { item, size }
}),
)
const totalBytes = itemSizes.reduce((sum, { size }) => sum + size, 0)
let bytesCopied = 0
// Report initial progress
if (onProgress && totalBytes > 0) {
onProgress({ bytesCopied: 0, totalBytes, itemName: itemsToCopy[0]! })
if (onProgress && itemsToCopy.length > 0) {
onProgress({ bytesCopied: 0, itemName: itemsToCopy[0]! })
}
// Copy the items with size-based progress tracking
// Copy the items with progress tracking (no total size calculation)
const copiedItems: string[] = []
for (const { item, size } of itemSizes) {
for (const item of itemsToCopy) {
const sourcePath = path.join(sourceDir, item)
const targetPath = path.join(targetDir, item)
@ -191,34 +180,33 @@ export class WorktreeIncludeService {
const stats = await fs.stat(sourcePath)
if (stats.isDirectory()) {
// Use native cp for directories with progress polling
await this.copyDirectoryWithProgress(
// Copy directory with progress tracking
bytesCopied = await this.copyDirectoryWithProgress(
sourcePath,
targetPath,
item,
bytesCopied,
totalBytes,
onProgress,
)
} else {
// Report progress before copying
onProgress?.({ bytesCopied, totalBytes, itemName: item })
onProgress?.({ bytesCopied, itemName: item })
// Ensure parent directory exists
await fs.mkdir(path.dirname(targetPath), { recursive: true })
await fs.copyFile(sourcePath, targetPath)
// Update bytes copied
bytesCopied += this.getSizeOnDisk(stats)
}
bytesCopied += size
copiedItems.push(item)
// Report progress after copying
onProgress?.({ bytesCopied, totalBytes, itemName: item })
onProgress?.({ bytesCopied, itemName: item })
} catch (error) {
// Log but don't fail on individual copy errors
console.error(`Failed to copy ${item}:`, error)
// Still count the size as "processed" to avoid progress getting stuck
bytesCopied += size
}
}
@ -302,22 +290,21 @@ export class WorktreeIncludeService {
}
/**
* Copy directory with progress polling.
* Copy directory with progress polling using native cp command.
* Starts native copy and polls target directory size to report progress.
* Returns the updated bytesCopied count.
*/
private async copyDirectoryWithProgress(
source: string,
target: string,
itemName: string,
bytesCopiedBefore: number,
totalBytes: number,
onProgress?: CopyProgressCallback,
): Promise<void> {
): Promise<number> {
// Ensure parent directory exists
await fs.mkdir(path.dirname(target), { recursive: true })
const isWindows = process.platform === "win32"
const expectedSize = await this.getPathSize(source)
// Start the copy process
const copyPromise = new Promise<void>((resolve, reject) => {
@ -361,8 +348,7 @@ export class WorktreeIncludeService {
const totalCopied = bytesCopiedBefore + currentSize
onProgress?.({
bytesCopied: Math.min(totalCopied, bytesCopiedBefore + expectedSize),
totalBytes,
bytesCopied: totalCopied,
itemName,
})
@ -380,6 +366,10 @@ export class WorktreeIncludeService {
// Wait for final poll iteration to complete
await pollPromise.catch(() => {})
}
// Get the final size of the copied directory
const finalSize = await this.getPathSize(target)
return bytesCopiedBefore + finalSize
}
/**

View file

@ -3387,7 +3387,6 @@ export const webviewMessageHandler = async (
provider.postMessageToWebview({
type: "worktreeCopyProgress",
copyProgressBytesCopied: progress.bytesCopied,
copyProgressTotalBytes: progress.totalBytes,
copyProgressItemName: progress.itemName,
})
},

View file

@ -47,7 +47,6 @@ export const CreateWorktreeModal = ({
const [error, setError] = useState<string | null>(null)
const [copyProgress, setCopyProgress] = useState<{
bytesCopied: number
totalBytes: number
itemName: string
} | null>(null)
@ -85,7 +84,6 @@ export const CreateWorktreeModal = ({
case "worktreeCopyProgress": {
setCopyProgress({
bytesCopied: message.copyProgressBytesCopied ?? 0,
totalBytes: message.copyProgressTotalBytes ?? 0,
itemName: message.copyProgressItemName ?? "",
})
break
@ -226,30 +224,16 @@ export const CreateWorktreeModal = ({
{/* Progress section - appears during file copying */}
{copyProgress && (
<div className="flex flex-col gap-2 px-3 py-3 rounded-lg bg-vscode-editor-background border border-vscode-panel-border">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-sm">
<span className="codicon codicon-loading codicon-modifier-spin text-vscode-button-background" />
<span className="text-vscode-foreground font-medium">
{t("worktrees:copyingFiles")}
</span>
<span className="text-vscode-descriptionForeground">
{copyProgress.totalBytes > 0
? Math.round((copyProgress.bytesCopied / copyProgress.totalBytes) * 100)
: 0}
%
</span>
</div>
<div className="w-full h-2 bg-vscode-input-background rounded-full overflow-hidden">
<div
className="h-full bg-vscode-button-background rounded-full transition-all duration-200"
style={{
width: `${copyProgress.totalBytes > 0 ? (copyProgress.bytesCopied / copyProgress.totalBytes) * 100 : 0}%`,
}}
/>
</div>
<div className="text-xs text-vscode-descriptionForeground truncate">
{t("worktrees:copyingProgress", {
item: copyProgress.itemName,
copied: prettyBytes(copyProgress.bytesCopied),
total: prettyBytes(copyProgress.totalBytes),
})}
</div>
</div>

View file

@ -43,7 +43,7 @@
"create": "Crea",
"creating": "S'està creant...",
"copyingFiles": "S'estan copiant fitxers...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copiat",
"cancel": "Cancel·la",
"deleteWorktree": "Suprimeix worktree",

View file

@ -43,7 +43,7 @@
"create": "Erstellen",
"creating": "Wird erstellt...",
"copyingFiles": "Dateien werden kopiert...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} kopiert",
"cancel": "Abbrechen",
"deleteWorktree": "Worktree löschen",

View file

@ -43,7 +43,7 @@
"create": "Create",
"creating": "Creating...",
"copyingFiles": "Copying files...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copied",
"cancel": "Cancel",
"deleteWorktree": "Delete Worktree",

View file

@ -43,7 +43,7 @@
"create": "Crear",
"creating": "Creando...",
"copyingFiles": "Copiando archivos...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copiado",
"cancel": "Cancelar",
"deleteWorktree": "Eliminar Worktree",

View file

@ -43,7 +43,7 @@
"create": "Créer",
"creating": "Création...",
"copyingFiles": "Copie des fichiers...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copié",
"cancel": "Annuler",
"deleteWorktree": "Supprimer le worktree",

View file

@ -43,7 +43,7 @@
"create": "बनाएँ",
"creating": "बनाया जा रहा है...",
"copyingFiles": "फ़ाइलें कॉपी की जा रही हैं...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} कॉपी किया गया",
"cancel": "रद्द करें",
"deleteWorktree": "Worktree हटाएँ",

View file

@ -43,7 +43,7 @@
"create": "Buat",
"creating": "Membuat...",
"copyingFiles": "Menyalin file...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} disalin",
"cancel": "Batal",
"deleteWorktree": "Hapus Worktree",

View file

@ -43,7 +43,7 @@
"create": "Crea",
"creating": "Creazione...",
"copyingFiles": "Copia dei file...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copiato",
"cancel": "Annulla",
"deleteWorktree": "Elimina Worktree",

View file

@ -43,7 +43,7 @@
"create": "作成",
"creating": "作成中...",
"copyingFiles": "ファイルをコピー中...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} コピー済み",
"cancel": "キャンセル",
"deleteWorktree": "Worktree を削除",

View file

@ -43,7 +43,7 @@
"create": "만들기",
"creating": "만드는 중...",
"copyingFiles": "파일 복사 중...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} 복사됨",
"cancel": "취소",
"deleteWorktree": "Worktree 삭제",

View file

@ -43,7 +43,7 @@
"create": "Aanmaken",
"creating": "Bezig met aanmaken...",
"copyingFiles": "Bestanden kopiëren...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} gekopieerd",
"cancel": "Annuleren",
"deleteWorktree": "Worktree verwijderen",

View file

@ -43,7 +43,7 @@
"create": "Utwórz",
"creating": "Tworzenie...",
"copyingFiles": "Kopiowanie plików...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} skopiowano",
"cancel": "Anuluj",
"deleteWorktree": "Usuń Worktree",

View file

@ -43,7 +43,7 @@
"create": "Criar",
"creating": "Criando...",
"copyingFiles": "Copiando arquivos...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} copiado",
"cancel": "Cancelar",
"deleteWorktree": "Excluir Worktree",

View file

@ -43,7 +43,7 @@
"create": "Создать",
"creating": "Создание...",
"copyingFiles": "Копирование файлов...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} скопировано",
"cancel": "Отмена",
"deleteWorktree": "Удалить Worktree",

View file

@ -43,7 +43,7 @@
"create": "Oluştur",
"creating": "Oluşturuluyor...",
"copyingFiles": "Dosyalar kopyalanıyor...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} kopyalandı",
"cancel": "İptal",
"deleteWorktree": "Worktree'yi sil",

View file

@ -43,7 +43,7 @@
"create": "Tạo",
"creating": "Đang tạo...",
"copyingFiles": "Đang sao chép tệp...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} đã sao chép",
"cancel": "Hủy",
"deleteWorktree": "Xóa Worktree",

View file

@ -43,7 +43,7 @@
"create": "创建",
"creating": "正在创建...",
"copyingFiles": "正在复制文件...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} 已复制",
"cancel": "取消",
"deleteWorktree": "删除 Worktree",

View file

@ -43,7 +43,7 @@
"create": "建立",
"creating": "正在建立...",
"copyingFiles": "正在複製檔案...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"copyingProgress": "{{item}} — {{copied}} 已複製",
"cancel": "取消",
"deleteWorktree": "刪除 Worktree",