feat: add size-based progress tracking for worktree file copying (#10871)

This commit is contained in:
Daniel 2026-01-21 22:53:12 -05:00 committed by GitHub
parent d87abe8efc
commit 971885d8e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 379 additions and 53 deletions

View file

@ -264,5 +264,45 @@ describe("WorktreeIncludeService", () => {
expect(result).toContain("node_modules")
})
it("should call progress callback with size-based 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")
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
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 }) => {
progressCalls.push({ ...progress })
})
await service.copyWorktreeIncludeFiles(sourceDir, targetDir, onProgress)
// Should be called multiple times (initial + after each copy + during polling)
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)
const finalCall = progressCalls[progressCalls.length - 1]
expect(finalCall?.bytesCopied).toBe(finalCall?.totalBytes)
// Each call should have an item name
expect(progressCalls.every((p) => typeof p.itemName === "string")).toBe(true)
})
it("should not fail when progress callback is not provided", async () => {
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
// Should not throw when no callback is provided
const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
expect(result).toContain("node_modules")
})
})
})

View file

@ -10,4 +10,4 @@ export * from "./types.js"
// Services
export { WorktreeService, worktreeService } from "./worktree-service.js"
export { WorktreeIncludeService, worktreeIncludeService } from "./worktree-include.js"
export { WorktreeIncludeService, worktreeIncludeService, type CopyProgressCallback } from "./worktree-include.js"

View file

@ -5,7 +5,7 @@
* Used to copy untracked files (like node_modules) when creating worktrees.
*/
import { execFile } from "child_process"
import { execFile, spawn } from "child_process"
import * as fs from "fs/promises"
import * as path from "path"
import { promisify } from "util"
@ -14,6 +14,23 @@ import ignore, { type Ignore } from "ignore"
import type { WorktreeIncludeStatus } from "./types.js"
/**
* Progress info for size-based copy tracking.
*/
export interface CopyProgress {
/** Current bytes copied */
bytesCopied: number
/** Total bytes to copy */
totalBytes: number
/** Name of current item being copied */
itemName: string
}
/**
* Callback for reporting copy progress during worktree file copying.
*/
export type CopyProgressCallback = (progress: CopyProgress) => void
const execFileAsync = promisify(execFile)
/**
@ -93,9 +110,16 @@ export class WorktreeIncludeService {
* Copy files matching .worktreeinclude patterns from source to target.
* Only copies files that are ALSO in .gitignore (to avoid copying tracked files).
*
* @param sourceDir - The source directory containing the files to copy
* @param targetDir - The target directory where files will be copied
* @param onProgress - Optional callback to report copy progress (size-based)
* @returns Array of copied file/directory paths
*/
async copyWorktreeIncludeFiles(sourceDir: string, targetDir: string): Promise<string[]> {
async copyWorktreeIncludeFiles(
sourceDir: string,
targetDir: string,
onProgress?: CopyProgressCallback,
): Promise<string[]> {
const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude")
const gitignorePath = path.join(sourceDir, ".gitignore")
@ -136,9 +160,30 @@ export class WorktreeIncludeService {
// Find items that match BOTH patterns (intersection)
const itemsToCopy = await this.findMatchingItems(sourceDir, worktreeIncludeMatcher, gitignoreMatcher)
// Copy the items
if (itemsToCopy.length === 0) {
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]! })
}
// Copy the items with size-based progress tracking
const copiedItems: string[] = []
for (const item of itemsToCopy) {
for (const { item, size } of itemSizes) {
const sourcePath = path.join(sourceDir, item)
const targetPath = path.join(targetDir, item)
@ -146,23 +191,197 @@ export class WorktreeIncludeService {
const stats = await fs.stat(sourcePath)
if (stats.isDirectory()) {
// Use native cp for directories (much faster)
await this.copyDirectoryNative(sourcePath, targetPath)
// Use native cp for directories with progress polling
await this.copyDirectoryWithProgress(
sourcePath,
targetPath,
item,
bytesCopied,
totalBytes,
onProgress,
)
} else {
// Report progress before copying
onProgress?.({ bytesCopied, totalBytes, itemName: item })
// Ensure parent directory exists
await fs.mkdir(path.dirname(targetPath), { recursive: true })
await fs.copyFile(sourcePath, targetPath)
}
bytesCopied += size
copiedItems.push(item)
// Report progress after copying
onProgress?.({ bytesCopied, totalBytes, 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
}
}
return copiedItems
}
/**
* Get the size on disk of a file (accounts for filesystem block allocation).
* Uses blksize to calculate actual disk usage including block overhead.
*/
private getSizeOnDisk(stats: { size: number; blksize?: number }): number {
// Calculate size on disk using filesystem block size
if (stats.blksize !== undefined && stats.blksize > 0) {
return stats.blksize * Math.ceil(stats.size / stats.blksize)
}
// Fallback to logical size when blksize not available
return stats.size
}
/**
* Get the total size on disk of a file or directory (recursively).
* Uses native Node.js fs operations for cross-platform compatibility.
*/
private async getPathSize(targetPath: string): Promise<number> {
try {
const stats = await fs.stat(targetPath)
if (stats.isFile()) {
return this.getSizeOnDisk(stats)
}
if (stats.isDirectory()) {
return await this.getDirectorySizeRecursive(targetPath)
}
return 0
} catch {
return 0
}
}
/**
* Recursively calculate directory size on disk using Node.js fs.
* Uses parallel processing for better performance on large directories.
*/
private async getDirectorySizeRecursive(dirPath: string): Promise<number> {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
const sizes = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dirPath, entry.name)
try {
if (entry.isFile()) {
const stats = await fs.stat(entryPath)
return this.getSizeOnDisk(stats)
} else if (entry.isDirectory()) {
return await this.getDirectorySizeRecursive(entryPath)
}
return 0
} catch {
return 0 // Skip inaccessible files
}
}),
)
return sizes.reduce((sum, size) => sum + size, 0)
} catch {
return 0
}
}
/**
* Get the current size of a directory (for progress tracking).
*/
private async getCurrentDirectorySize(dirPath: string): Promise<number> {
try {
await fs.access(dirPath)
return await this.getDirectorySizeRecursive(dirPath)
} catch {
return 0
}
}
/**
* Copy directory with progress polling.
* Starts native copy and polls target directory size to report progress.
*/
private async copyDirectoryWithProgress(
source: string,
target: string,
itemName: string,
bytesCopiedBefore: number,
totalBytes: number,
onProgress?: CopyProgressCallback,
): Promise<void> {
// 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) => {
let proc: ReturnType<typeof spawn>
if (isWindows) {
proc = spawn("robocopy", [source, target, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP"], {
windowsHide: true,
})
} else {
proc = spawn("cp", ["-r", "--", source, target])
}
proc.on("close", (code) => {
if (isWindows) {
// robocopy returns non-zero for success (values < 8)
if (code !== null && code < 8) {
resolve()
} else {
reject(new Error(`robocopy failed with code ${code}`))
}
} else {
if (code === 0) {
resolve()
} else {
reject(new Error(`cp failed with code ${code}`))
}
}
})
proc.on("error", reject)
})
// Poll progress while copying
const pollInterval = 500 // Poll every 500ms
let polling = true
const pollProgress = async () => {
while (polling) {
const currentSize = await this.getCurrentDirectorySize(target)
const totalCopied = bytesCopiedBefore + currentSize
onProgress?.({
bytesCopied: Math.min(totalCopied, bytesCopiedBefore + expectedSize),
totalBytes,
itemName,
})
await new Promise((resolve) => setTimeout(resolve, pollInterval))
}
}
// Start polling and wait for copy to complete
const pollPromise = pollProgress()
try {
await copyPromise
} finally {
polling = false
// Wait for final poll iteration to complete
await pollPromise.catch(() => {})
}
}
/**
* Parse a .gitignore-style file and return the patterns
*/
@ -213,43 +432,6 @@ export class WorktreeIncludeService {
return matchingItems
}
/**
* Copy directory using native cp command for performance.
* This is 10-20x faster than Node.js fs.cp for large directories like node_modules.
*/
private async copyDirectoryNative(source: string, target: string): Promise<void> {
// Ensure parent directory exists
await fs.mkdir(path.dirname(target), { recursive: true })
// Use platform-appropriate copy command
const isWindows = process.platform === "win32"
if (isWindows) {
// Use robocopy on Windows (more reliable than xcopy)
// robocopy returns non-zero for success, so we check the exit code
try {
await execFileAsync(
"robocopy",
[source, target, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/nc", "/ns", "/np"],
{ windowsHide: true },
)
} catch (error) {
// robocopy returns non-zero for success (values < 8)
const exitCode =
typeof (error as { code?: unknown }).code === "number"
? (error as { code: number }).code
: undefined
if (exitCode !== undefined && exitCode < 8) {
return // Success
}
throw error
}
} else {
// Use cp -r on Unix-like systems
await execFileAsync("cp", ["-r", "--", source, target])
}
}
}
// Export singleton instance for convenience

View file

@ -102,6 +102,7 @@ export interface ExtensionMessage {
// Worktree response types
| "worktreeList"
| "worktreeResult"
| "worktreeCopyProgress"
| "branchList"
| "worktreeDefaults"
| "worktreeIncludeStatus"
@ -257,6 +258,10 @@ export interface ExtensionMessage {
// branchWorktreeIncludeResult
branch?: string
hasWorktreeInclude?: boolean
// worktreeCopyProgress (size-based)
copyProgressBytesCopied?: number
copyProgressTotalBytes?: number
copyProgressItemName?: string
}
export interface OpenAiCodexRateLimitsMessage {

View file

@ -3375,12 +3375,23 @@ export const webviewMessageHandler = async (
case "createWorktree": {
try {
const { success, message: text } = await handleCreateWorktree(provider, {
path: message.worktreePath!,
branch: message.worktreeBranch,
baseBranch: message.worktreeBaseBranch,
createNewBranch: message.worktreeCreateNewBranch,
})
const { success, message: text } = await handleCreateWorktree(
provider,
{
path: message.worktreePath!,
branch: message.worktreeBranch,
baseBranch: message.worktreeBaseBranch,
createNewBranch: message.worktreeCreateNewBranch,
},
(progress) => {
provider.postMessageToWebview({
type: "worktreeCopyProgress",
copyProgressBytesCopied: progress.bytesCopied,
copyProgressTotalBytes: progress.totalBytes,
copyProgressItemName: progress.itemName,
})
},
)
await provider.postMessageToWebview({ type: "worktreeResult", success, text })
} catch (error) {

View file

@ -17,7 +17,7 @@ import type {
WorktreeListResponse,
WorktreeDefaultsResponse,
} from "@roo-code/types"
import { worktreeService, worktreeIncludeService } from "@roo-code/core"
import { worktreeService, worktreeIncludeService, type CopyProgressCallback } from "@roo-code/core"
import type { ClineProvider } from "../ClineProvider"
@ -137,6 +137,7 @@ export async function handleCreateWorktree(
baseBranch?: string
createNewBranch?: boolean
},
onCopyProgress?: CopyProgressCallback,
): Promise<WorktreeResult> {
const cwd = provider.cwd
@ -154,7 +155,11 @@ export async function handleCreateWorktree(
// If successful and .worktreeinclude exists, copy the files.
if (result.success && result.worktree) {
try {
const copiedItems = await worktreeIncludeService.copyWorktreeIncludeFiles(cwd, result.worktree.path)
const copiedItems = await worktreeIncludeService.copyWorktreeIncludeFiles(
cwd,
result.worktree.path,
onCopyProgress,
)
if (copiedItems.length > 0) {
result.message += ` (copied ${copiedItems.length} item(s) from .worktreeinclude)`
}

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react"
import prettyBytes from "pretty-bytes"
import type { WorktreeDefaultsResponse, BranchInfo, WorktreeIncludeStatus } from "@roo-code/types"
@ -44,6 +45,11 @@ export const CreateWorktreeModal = ({
// UI state
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copyProgress, setCopyProgress] = useState<{
bytesCopied: number
totalBytes: number
itemName: string
} | null>(null)
// Fetch defaults and branches on open
useEffect(() => {
@ -76,8 +82,17 @@ export const CreateWorktreeModal = ({
setIncludeStatus(message.worktreeIncludeStatus)
break
}
case "worktreeCopyProgress": {
setCopyProgress({
bytesCopied: message.copyProgressBytesCopied ?? 0,
totalBytes: message.copyProgressTotalBytes ?? 0,
itemName: message.copyProgressItemName ?? "",
})
break
}
case "worktreeResult": {
setIsCreating(false)
setCopyProgress(null)
if (message.success) {
if (openAfterCreate) {
vscode.postMessage({
@ -207,10 +222,42 @@ export const CreateWorktreeModal = ({
<p className="text-vscode-errorForeground">{error}</p>
</div>
)}
{/* 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">
<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>
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={onClose} disabled={isCreating}>
{t("worktrees:cancel")}
</Button>
<Button onClick={handleCreate} disabled={!isValid || isCreating}>

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Sense un fitxer .worktreeinclude, fitxers com node_modules no es copiaran al worktree nou. Potser hauràs d'executar npm install després de crear-lo.",
"create": "Crea",
"creating": "S'està creant...",
"copyingFiles": "S'estan copiant fitxers...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Cancel·la",
"deleteWorktree": "Suprimeix worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Ohne eine .worktreeinclude-Datei werden Dateien wie node_modules nicht in den neuen Worktree kopiert. Möglicherweise musst du nach dem Erstellen npm install ausführen.",
"create": "Erstellen",
"creating": "Wird erstellt...",
"copyingFiles": "Dateien werden kopiert...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Abbrechen",
"deleteWorktree": "Worktree löschen",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Without a .worktreeinclude file, files like node_modules won't be copied to the new worktree. You may need to run npm install after creating it.",
"create": "Create",
"creating": "Creating...",
"copyingFiles": "Copying files...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Cancel",
"deleteWorktree": "Delete Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Sin un archivo .worktreeinclude, archivos como node_modules no se copiarán al worktree nuevo. Puede que tengas que ejecutar npm install después de crearlo.",
"create": "Crear",
"creating": "Creando...",
"copyingFiles": "Copiando archivos...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Cancelar",
"deleteWorktree": "Eliminar Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Sans fichier .worktreeinclude, des fichiers comme node_modules ne seront pas copiés dans le nouveau worktree. Tu devras peut-être exécuter npm install après l'avoir créé.",
"create": "Créer",
"creating": "Création...",
"copyingFiles": "Copie des fichiers...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Annuler",
"deleteWorktree": "Supprimer le worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": ".worktreeinclude फ़ाइल के बिना, node_modules जैसी फ़ाइलें नए worktree में कॉपी नहीं होंगी। इसे बनाने के बाद आपको npm install चलाना पड़ सकता है।",
"create": "बनाएँ",
"creating": "बनाया जा रहा है...",
"copyingFiles": "फ़ाइलें कॉपी की जा रही हैं...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "रद्द करें",
"deleteWorktree": "Worktree हटाएँ",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Tanpa file .worktreeinclude, file seperti node_modules tidak akan disalin ke worktree baru. Kamu mungkin perlu menjalankan npm install setelah membuatnya.",
"create": "Buat",
"creating": "Membuat...",
"copyingFiles": "Menyalin file...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Batal",
"deleteWorktree": "Hapus Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Senza un file .worktreeinclude, file come node_modules non verranno copiati nel nuovo worktree. Potresti dover eseguire npm install dopo averlo creato.",
"create": "Crea",
"creating": "Creazione...",
"copyingFiles": "Copia dei file...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Annulla",
"deleteWorktree": "Elimina Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": ".worktreeinclude ファイルがない場合、node_modules などのファイルは新しい worktree にコピーされません。作成後に npm install を実行する必要があるかもしれません。",
"create": "作成",
"creating": "作成中...",
"copyingFiles": "ファイルをコピー中...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "キャンセル",
"deleteWorktree": "Worktree を削除",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": ".worktreeinclude 파일이 없으면 node_modules 같은 파일이 새 worktree로 복사되지 않습니다. 생성 후 npm install을 실행해야 할 수도 있습니다.",
"create": "만들기",
"creating": "만드는 중...",
"copyingFiles": "파일 복사 중...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "취소",
"deleteWorktree": "Worktree 삭제",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Zonder een .worktreeinclude-bestand worden bestanden zoals node_modules niet naar de nieuwe worktree gekopieerd. Mogelijk moet je na het aanmaken npm install uitvoeren.",
"create": "Aanmaken",
"creating": "Bezig met aanmaken...",
"copyingFiles": "Bestanden kopiëren...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Annuleren",
"deleteWorktree": "Worktree verwijderen",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Bez pliku .worktreeinclude pliki takie jak node_modules nie zostaną skopiowane do nowego worktree. Po utworzeniu może być konieczne uruchomienie npm install.",
"create": "Utwórz",
"creating": "Tworzenie...",
"copyingFiles": "Kopiowanie plików...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Anuluj",
"deleteWorktree": "Usuń Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Sem um arquivo .worktreeinclude, arquivos como node_modules não serão copiados para o novo worktree. Você pode precisar executar npm install depois de criá-lo.",
"create": "Criar",
"creating": "Criando...",
"copyingFiles": "Copiando arquivos...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Cancelar",
"deleteWorktree": "Excluir Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Без файла .worktreeinclude файлы вроде node_modules не будут скопированы в новый worktree. Возможно, после создания нужно будет выполнить npm install.",
"create": "Создать",
"creating": "Создание...",
"copyingFiles": "Копирование файлов...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Отмена",
"deleteWorktree": "Удалить Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": ".worktreeinclude dosyası olmadan node_modules gibi dosyalar yeni worktree'ye kopyalanmaz. Oluşturduktan sonra npm install çalıştırman gerekebilir.",
"create": "Oluştur",
"creating": "Oluşturuluyor...",
"copyingFiles": "Dosyalar kopyalanıyor...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "İptal",
"deleteWorktree": "Worktree'yi sil",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "Không có tệp .worktreeinclude thì các tệp như node_modules sẽ không được sao chép sang worktree mới. Bạn có thể cần chạy npm install sau khi tạo.",
"create": "Tạo",
"creating": "Đang tạo...",
"copyingFiles": "Đang sao chép tệp...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "Hủy",
"deleteWorktree": "Xóa Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "没有 .worktreeinclude 文件时node_modules 等文件不会复制到新 worktree。创建后你可能需要运行 npm install。",
"create": "创建",
"creating": "正在创建...",
"copyingFiles": "正在复制文件...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "取消",
"deleteWorktree": "删除 Worktree",

View file

@ -42,6 +42,8 @@
"noIncludeFileHint": "沒有 .worktreeinclude 檔案時node_modules 等檔案不會複製到新的 worktree。建立後你可能需要執行 npm install。",
"create": "建立",
"creating": "正在建立...",
"copyingFiles": "正在複製檔案...",
"copyingProgress": "{{item}} — {{copied}} / {{total}}",
"cancel": "取消",
"deleteWorktree": "刪除 Worktree",