git-clone-resume 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1718 @@
1
+ #Requires -Version 5.1
2
+ # Terminal UI for git-clone-resume. Dot-sourced by git-clone-resume.ps1.
3
+ # PowerShell 5.1 compatible. Interactive host -> full-screen dashboard;
4
+ # redirected/CI -> caller keeps the original CLI.
5
+
6
+ Set-StrictMode -Version Latest
7
+
8
+ $script:GcrTui = $null
9
+ $script:GcrEsc = [char]27
10
+ $script:GcrReset = "$([char]27)[0m"
11
+ $script:GcrCurrentProc = $null
12
+ $script:GcrOrigOutMode = $null
13
+ $script:GcrOrigInMode = $null
14
+ $script:GcrOrigTitle = $null
15
+ $script:GcrOrigCursor = $true
16
+ $script:GcrOrigTreatCtrlC = $false
17
+ $script:GcrNativeReady = $false
18
+
19
+ function Get-GcrCharWidth {
20
+ param([char]$Ch)
21
+ $code = [int]$Ch
22
+ if ($code -le 31 -or $code -eq 127) { return 0 }
23
+ if ($code -lt 127) { return 1 }
24
+ if ($code -ge 0x1100 -and (
25
+ $code -le 0x115F -or
26
+ $code -eq 0x2329 -or $code -eq 0x232A -or
27
+ ($code -ge 0x2E80 -and $code -le 0xA4CF -and $code -ne 0x303F) -or
28
+ ($code -ge 0xAC00 -and $code -le 0xD7A3) -or
29
+ ($code -ge 0xF900 -and $code -le 0xFAFF) -or
30
+ ($code -ge 0xFE10 -and $code -le 0xFE19) -or
31
+ ($code -ge 0xFE30 -and $code -le 0xFE6F) -or
32
+ ($code -ge 0xFF00 -and $code -le 0xFF60) -or
33
+ ($code -ge 0xFFE0 -and $code -le 0xFFE6)
34
+ )) { return 2 }
35
+ return 1
36
+ }
37
+
38
+ function Get-GcrDisplayWidth {
39
+ param([string]$Text)
40
+ if ([string]::IsNullOrEmpty($Text)) { return 0 }
41
+ $plain = [regex]::Replace($Text, [char]27 + '\[[0-9;?]*[ -/]*[@-~]', "")
42
+ $w = 0
43
+ foreach ($ch in $plain.ToCharArray()) { $w += Get-GcrCharWidth $ch }
44
+ return $w
45
+ }
46
+
47
+ function Truncate-GcrDisplay {
48
+ param([string]$Text, [int]$Width)
49
+ if ($Width -le 0) { return "" }
50
+ if ([string]::IsNullOrEmpty($Text)) { return "" }
51
+ if ((Get-GcrDisplayWidth $Text) -le $Width) { return $Text }
52
+ $ellipsis = "..."
53
+ $budget = $Width - 3
54
+ if ($budget -lt 1) { return Truncate-GcrDisplay -Text "." -Width $Width }
55
+ $sb = New-Object System.Text.StringBuilder
56
+ $w = 0
57
+ foreach ($ch in $Text.ToCharArray()) {
58
+ $cw = Get-GcrCharWidth $ch
59
+ if ($w + $cw -gt $budget) { break }
60
+ [void]$sb.Append($ch)
61
+ $w += $cw
62
+ }
63
+ return $sb.ToString() + $ellipsis
64
+ }
65
+
66
+ function Format-GcrCell {
67
+ param([string]$Text, [int]$Width)
68
+ if ($Width -le 0) { return "" }
69
+ $t = Truncate-GcrDisplay -Text ([string]$Text) -Width $Width
70
+ $w = Get-GcrDisplayWidth $t
71
+ if ($w -lt $Width) { $t = $t + (" " * ($Width - $w)) }
72
+ return $t
73
+ }
74
+
75
+ function Get-GcrColor {
76
+ param([string]$Name)
77
+ $e = $script:GcrEsc
78
+ switch ($Name) {
79
+ "reset" { return "$e[0m" }
80
+ "bold" { return "$e[1m" }
81
+ "dim" { return "$e[2m" }
82
+ "rev" { return "$e[7m" }
83
+ "cyan" { return "$e[96m" }
84
+ "blue" { return "$e[94m" }
85
+ "green" { return "$e[92m" }
86
+ "yellow" { return "$e[93m" }
87
+ "red" { return "$e[91m" }
88
+ "white" { return "$e[97m" }
89
+ "gray" { return "$e[90m" }
90
+ "teal" { return "$e[38;2;94;234;212m" }
91
+ default { return "$e[0m" }
92
+ }
93
+ }
94
+
95
+ function Get-GcrLevelColor {
96
+ param([string]$Level)
97
+ switch ($Level) {
98
+ "OK" { return Get-GcrColor "green" }
99
+ "STEP" { return Get-GcrColor "cyan" }
100
+ "WARN" { return Get-GcrColor "yellow" }
101
+ "ERROR" { return Get-GcrColor "red" }
102
+ default { return Get-GcrColor "dim" }
103
+ }
104
+ }
105
+
106
+ function Test-GcrTuiAvailable {
107
+ if ($Host.Name -match "ISE") { return $false }
108
+ try {
109
+ if ([Console]::IsOutputRedirected) { return $false }
110
+ if ([Console]::IsInputRedirected) { return $false }
111
+ } catch { return $false }
112
+ try {
113
+ $w = [Console]::WindowWidth
114
+ $h = [Console]::WindowHeight
115
+ if ($w -lt 40 -or $h -lt 10) { return $false }
116
+ } catch { return $false }
117
+ try {
118
+ $null = [Console]::KeyAvailable
119
+ } catch { return $false }
120
+ return $true
121
+ }
122
+
123
+ function Test-GcrTuiActive {
124
+ return ($null -ne $script:GcrTui -and [bool]$script:GcrTui.Active)
125
+ }
126
+
127
+ function Test-GcrTuiQuit {
128
+ return (Test-GcrTuiActive) -and [bool]$script:GcrTui.QuitRequested
129
+ }
130
+
131
+ function Test-GcrTuiForceQuit {
132
+ return (Test-GcrTuiActive) -and [bool]$script:GcrTui.ForceQuit
133
+ }
134
+
135
+ function Get-GcrNativeType {
136
+ if (-not ("GitCloneResume.Native" -as [type])) {
137
+ Add-Type -Namespace GitCloneResume -Name Native -MemberDefinition @"
138
+ [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError=true)]
139
+ public static extern System.IntPtr GetStdHandle(int nStdHandle);
140
+ [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError=true)]
141
+ public static extern bool GetConsoleMode(System.IntPtr hConsoleHandle, out uint lpMode);
142
+ [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError=true)]
143
+ public static extern bool SetConsoleMode(System.IntPtr hConsoleHandle, uint dwMode);
144
+ "@
145
+ }
146
+ return [GitCloneResume.Native]
147
+ }
148
+
149
+ function Enable-GcrVt {
150
+ try {
151
+ $native = Get-GcrNativeType
152
+ $hOut = $native::GetStdHandle(-11)
153
+ $hIn = $native::GetStdHandle(-10)
154
+ $outMode = [uint32]0
155
+ $inMode = [uint32]0
156
+ if ($native::GetConsoleMode($hOut, [ref]$outMode)) {
157
+ $script:GcrOrigOutMode = $outMode
158
+ $vt = [uint32]0x0004
159
+ $processed = [uint32]0x0001
160
+ $wrap = [uint32]0x0002
161
+ $disableNl = [uint32]0x0008
162
+ $newOut = ([uint32]($outMode -bor $processed -bor $vt -bor $disableNl)) -band (-bnot $wrap)
163
+ [void]$native::SetConsoleMode($hOut, $newOut)
164
+ }
165
+ if ($native::GetConsoleMode($hIn, [ref]$inMode)) {
166
+ $script:GcrOrigInMode = $inMode
167
+ $extended = [uint32]0x0080
168
+ $quickEdit = [uint32]0x0040
169
+ $newIn = ($inMode -bor $extended) -band (-bnot $quickEdit)
170
+ [void]$native::SetConsoleMode($hIn, $newIn)
171
+ }
172
+ $script:GcrNativeReady = $true
173
+ return $true
174
+ } catch {
175
+ return $false
176
+ }
177
+ }
178
+
179
+ function Restore-GcrVt {
180
+ if (-not $script:GcrNativeReady) { return }
181
+ try {
182
+ $native = Get-GcrNativeType
183
+ if ($null -ne $script:GcrOrigOutMode) {
184
+ [void]$native::SetConsoleMode($native::GetStdHandle(-11), [uint32]$script:GcrOrigOutMode)
185
+ }
186
+ if ($null -ne $script:GcrOrigInMode) {
187
+ [void]$native::SetConsoleMode($native::GetStdHandle(-10), [uint32]$script:GcrOrigInMode)
188
+ }
189
+ } catch { }
190
+ }
191
+
192
+ function Sync-GcrConsoleBuffer {
193
+ try {
194
+ $w = [int][Console]::WindowWidth
195
+ $h = [int][Console]::WindowHeight
196
+ if ($w -lt 1 -or $h -lt 1) { return }
197
+ if ([Console]::BufferWidth -ne $w -or [Console]::BufferHeight -ne $h) {
198
+ [Console]::SetBufferSize($w, $h)
199
+ }
200
+ } catch { }
201
+ }
202
+
203
+ function Get-GcrTuiSize {
204
+ Sync-GcrConsoleBuffer
205
+ $w = 80
206
+ $h = 24
207
+ try {
208
+ $w = [int][Console]::WindowWidth
209
+ $h = [int][Console]::WindowHeight
210
+ } catch {
211
+ try {
212
+ $w = [int]$Host.UI.RawUI.WindowSize.Width
213
+ $h = [int]$Host.UI.RawUI.WindowSize.Height
214
+ } catch { }
215
+ }
216
+ if ($w -lt 40) { $w = 40 }
217
+ if ($h -lt 10) { $h = 10 }
218
+ # Never paint the last column: a full-width write wraps and scrolls the buffer.
219
+ $size = @{ W = $w; H = $h; DrawW = [Math]::Max(20, $w - 1) }
220
+ return $size
221
+ }
222
+
223
+ function Get-GcrAsciiBox {
224
+ return @{
225
+ H = "-"
226
+ V = "|"
227
+ TL = "+"
228
+ TR = "+"
229
+ BL = "+"
230
+ BR = "+"
231
+ L = "+"
232
+ R = "+"
233
+ BarF = "#"
234
+ BarE = "-"
235
+ Pointer = ">"
236
+ Dot = "."
237
+ }
238
+ }
239
+
240
+ function Get-GcrUnicodeBox {
241
+ return @{
242
+ H = [string][char]0x2500
243
+ V = [string][char]0x2502
244
+ TL = [string][char]0x256D
245
+ TR = [string][char]0x256E
246
+ BL = [string][char]0x2570
247
+ BR = [string][char]0x256F
248
+ L = [string][char]0x251C
249
+ R = [string][char]0x2524
250
+ BarF = [string][char]0x2588
251
+ BarE = [string][char]0x2591
252
+ Pointer = ">"
253
+ Dot = [string][char]0x00B7
254
+ }
255
+ }
256
+
257
+ function Measure-GcrCellAdvance {
258
+ param([char]$Ch)
259
+ try {
260
+ $left = [Console]::CursorLeft
261
+ $top = [Console]::CursorTop
262
+ [Console]::SetCursorPosition(0, 0)
263
+ [Console]::Write([string]$Ch)
264
+ $adv = [int][Console]::CursorLeft
265
+ [Console]::SetCursorPosition(0, 0)
266
+ [Console]::Write(" ")
267
+ [Console]::SetCursorPosition($left, $top)
268
+ if ($adv -lt 1) { return 1 }
269
+ return $adv
270
+ } catch {
271
+ return 1
272
+ }
273
+ }
274
+
275
+ function Resolve-GcrBox {
276
+ if ($env:GCR_ASCII -eq "1") { return Get-GcrAsciiBox }
277
+ if ($env:GCR_UNICODE -eq "1") { return Get-GcrUnicodeBox }
278
+ $probe = @(
279
+ [char]0x2500
280
+ [char]0x256D
281
+ [char]0x2588
282
+ [char]0x2591
283
+ )
284
+ foreach ($ch in $probe) {
285
+ if ((Measure-GcrCellAdvance $ch) -ge 2) { return Get-GcrAsciiBox }
286
+ }
287
+ return Get-GcrUnicodeBox
288
+ }
289
+
290
+ function Initialize-GcrTuiState {
291
+ $box = Get-GcrAsciiBox
292
+ $script:GcrTui = @{
293
+ Active = $false
294
+ UseUnicode = $false
295
+ Box = $box
296
+ Width = 80
297
+ Height = 24
298
+ LastFrame = @()
299
+ LastFrameW = 0
300
+ LastFrameH = 0
301
+ Logs = New-Object System.Collections.ArrayList
302
+ LogOffset = 0
303
+ Phase = "idle"
304
+ PhaseDetail = ""
305
+ RepoUrl = ""
306
+ OutDir = ""
307
+ Ref = "HEAD"
308
+ Commit = ""
309
+ Resume = $false
310
+ Ok = 0
311
+ Total = 0
312
+ Fail = 0
313
+ Bytes = [int64]0
314
+ Rate = 0.0
315
+ Eta = "--:--:--"
316
+ CurrentFile = ""
317
+ GitPercent = -1
318
+ Paused = $false
319
+ QuitRequested = $false
320
+ ForceQuit = $false
321
+ Dirty = $true
322
+ LastDraw = [datetime]::MinValue
323
+ Help = $false
324
+ FailView = $false
325
+ Failures = New-Object System.Collections.ArrayList
326
+ StartedAt = $null
327
+ Status = "run"
328
+ ResultTitle = ""
329
+ ResultBody = @()
330
+ Tick = 0
331
+ Screen = "dash"
332
+ ErrorFlash = ""
333
+ }
334
+ }
335
+
336
+ function Initialize-GcrTui {
337
+ if (Test-GcrTuiActive) { return $true }
338
+ if (-not (Test-GcrTuiAvailable)) { return $false }
339
+ Initialize-GcrTuiState
340
+ try { $script:GcrOrigTitle = [Console]::Title } catch { }
341
+ try { $script:GcrOrigCursor = [Console]::CursorVisible } catch { }
342
+ try { $script:GcrOrigTreatCtrlC = [Console]::TreatControlCAsInput } catch { }
343
+ [void](Enable-GcrVt)
344
+ try { [Console]::TreatControlCAsInput = $true } catch { }
345
+ try { [Console]::CursorVisible = $false } catch { }
346
+ $e = $script:GcrEsc
347
+ try {
348
+ [Console]::Write("{0}[?1049h{0}[?25l{0}[2J{0}[H" -f $e)
349
+ } catch { }
350
+ Sync-GcrConsoleBuffer
351
+ $box = Resolve-GcrBox
352
+ $script:GcrTui.Box = $box
353
+ $script:GcrTui.UseUnicode = ($box.H -ne "-")
354
+ try { [Console]::Title = "git-clone-resume" } catch { }
355
+ $script:GcrTui.Active = $true
356
+ $script:GcrTui.Dirty = $true
357
+ $script:GcrTui.LastFrame = @()
358
+ return $true
359
+ }
360
+
361
+ function Close-GcrTui {
362
+ if ($null -eq $script:GcrTui -or -not $script:GcrTui.Active) {
363
+ $script:GcrTui = $null
364
+ return
365
+ }
366
+ $e = $script:GcrEsc
367
+ try { [Console]::Write("{0}]9;4;0;0{1}" -f $e, [char]7) } catch { }
368
+ try { [Console]::Write("{0}[?25h{0}[?1049l{0}[0m" -f $e) } catch { }
369
+ try { [Console]::CursorVisible = $script:GcrOrigCursor } catch {
370
+ try { [Console]::CursorVisible = $true } catch { }
371
+ }
372
+ try { [Console]::TreatControlCAsInput = $script:GcrOrigTreatCtrlC } catch {
373
+ try { [Console]::TreatControlCAsInput = $false } catch { }
374
+ }
375
+ try {
376
+ if ($script:GcrOrigTitle) { [Console]::Title = $script:GcrOrigTitle }
377
+ } catch { }
378
+ Restore-GcrVt
379
+ $script:GcrTui.Active = $false
380
+ $script:GcrTui = $null
381
+ }
382
+
383
+ function Set-GcrTuiTabProgress {
384
+ param([int]$Percent, [int]$State = 1)
385
+ if (-not (Test-GcrTuiActive)) { return }
386
+ if ($Percent -lt 0) { $Percent = 0 }
387
+ if ($Percent -gt 100) { $Percent = 100 }
388
+ $e = $script:GcrEsc
389
+ try { [Console]::Write("{0}]9;4;{1};{2}{3}" -f $e, $State, $Percent, [char]7) } catch { }
390
+ }
391
+
392
+ function Set-GcrTuiRepo {
393
+ param(
394
+ [string]$Url,
395
+ [string]$OutDir,
396
+ [string]$Ref,
397
+ [string]$Commit,
398
+ [switch]$Resume
399
+ )
400
+ if (-not (Test-GcrTuiActive)) { return }
401
+ if ($PSBoundParameters.ContainsKey("Url")) { $script:GcrTui.RepoUrl = $Url }
402
+ if ($PSBoundParameters.ContainsKey("OutDir")) { $script:GcrTui.OutDir = $OutDir }
403
+ if ($PSBoundParameters.ContainsKey("Ref")) { $script:GcrTui.Ref = $Ref }
404
+ if ($PSBoundParameters.ContainsKey("Commit")) { $script:GcrTui.Commit = $Commit }
405
+ if ($Resume) { $script:GcrTui.Resume = $true }
406
+ $script:GcrTui.Dirty = $true
407
+ }
408
+
409
+ function Set-GcrTuiPhase {
410
+ param([string]$Name, [string]$Detail = "")
411
+ if (-not (Test-GcrTuiActive)) { return }
412
+ if ($Name) { $script:GcrTui.Phase = $Name }
413
+ if ($PSBoundParameters.ContainsKey("Detail")) { $script:GcrTui.PhaseDetail = $Detail }
414
+ $script:GcrTui.Dirty = $true
415
+ }
416
+
417
+ function Add-GcrTuiLog {
418
+ param(
419
+ [Parameter(Mandatory = $true)][string]$Message,
420
+ [string]$Level = "INFO"
421
+ )
422
+ if (-not (Test-GcrTuiActive)) { return }
423
+ $msg = [string]$Message
424
+ $msg = $msg -replace "[\r\n]+", " "
425
+ if ($msg.Length -gt 400) { $msg = $msg.Substring(0, 397) + "..." }
426
+ $entry = @{
427
+ T = Get-Date
428
+ L = [string]$Level
429
+ M = $msg
430
+ }
431
+ [void]$script:GcrTui.Logs.Add($entry)
432
+ while ($script:GcrTui.Logs.Count -gt 400) {
433
+ $script:GcrTui.Logs.RemoveAt(0)
434
+ }
435
+ if ($script:GcrTui.LogOffset -eq 0) { $script:GcrTui.Dirty = $true }
436
+ else { $script:GcrTui.Dirty = $true }
437
+ }
438
+
439
+ function Add-GcrTuiFailure {
440
+ param([string]$Path)
441
+ if (-not (Test-GcrTuiActive)) { return }
442
+ if ([string]::IsNullOrWhiteSpace($Path)) { return }
443
+ [void]$script:GcrTui.Failures.Add($Path)
444
+ while ($script:GcrTui.Failures.Count -gt 80) {
445
+ $script:GcrTui.Failures.RemoveAt(0)
446
+ }
447
+ $script:GcrTui.Dirty = $true
448
+ }
449
+
450
+ function Add-GcrGitOutput {
451
+ param([string]$Text, [switch]$Progress)
452
+ if (-not (Test-GcrTuiActive)) { return }
453
+ if ([string]::IsNullOrWhiteSpace($Text)) { return }
454
+ $t = $Text.Trim()
455
+ if ($t.Length -eq 0) { return }
456
+ if ($t -match "(\d+)\s*%") {
457
+ $script:GcrTui.GitPercent = [int]$Matches[1]
458
+ $script:GcrTui.PhaseDetail = $t
459
+ $script:GcrTui.Dirty = $true
460
+ if ($Progress) { return }
461
+ if ($t -match "Receiving objects|Resolving deltas|Counting objects|Compressing objects|Enumerating") {
462
+ return
463
+ }
464
+ }
465
+ if ($Progress) {
466
+ $script:GcrTui.PhaseDetail = $t
467
+ $script:GcrTui.Dirty = $true
468
+ return
469
+ }
470
+ Add-GcrTuiLog -Level "INFO" -Message $t
471
+ }
472
+
473
+ function Receive-GcrGitBytes {
474
+ param(
475
+ [byte[]]$Buffer,
476
+ [int]$Count,
477
+ [System.Text.StringBuilder]$Carry,
478
+ [switch]$IsStdErr
479
+ )
480
+ if ($Count -le 0 -or $null -eq $Carry) { return }
481
+ $text = [System.Text.Encoding]::UTF8.GetString($Buffer, 0, $Count)
482
+ [void]$Carry.Append($text)
483
+ $s = $Carry.ToString()
484
+ $Carry.Length = 0
485
+ $acc = New-Object System.Text.StringBuilder
486
+ foreach ($ch in $s.ToCharArray()) {
487
+ if ($ch -eq [char]13) {
488
+ if ($IsStdErr) { Add-GcrGitOutput -Text $acc.ToString() -Progress }
489
+ [void]$acc.Clear()
490
+ } elseif ($ch -eq [char]10) {
491
+ if ($IsStdErr) { Add-GcrGitOutput -Text $acc.ToString() }
492
+ [void]$acc.Clear()
493
+ } else {
494
+ [void]$acc.Append($ch)
495
+ }
496
+ }
497
+ if ($acc.Length -gt 0) { [void]$Carry.Append($acc.ToString()) }
498
+ }
499
+
500
+ function Update-GcrTuiProgress {
501
+ param(
502
+ [int]$OkCount,
503
+ [int]$TotalCount,
504
+ [int]$FailCount,
505
+ [int64]$DoneBytes,
506
+ [double]$Rate,
507
+ [string]$Eta,
508
+ [string]$CurrentFile
509
+ )
510
+ if (-not (Test-GcrTuiActive)) { return }
511
+ $script:GcrTui.Ok = $OkCount
512
+ $script:GcrTui.Total = $TotalCount
513
+ $script:GcrTui.Fail = $FailCount
514
+ $script:GcrTui.Bytes = $DoneBytes
515
+ $script:GcrTui.Rate = $Rate
516
+ if ($Eta) { $script:GcrTui.Eta = $Eta }
517
+ if ($PSBoundParameters.ContainsKey("CurrentFile")) { $script:GcrTui.CurrentFile = $CurrentFile }
518
+ $script:GcrTui.Dirty = $true
519
+ $pct = 0
520
+ if ($TotalCount -gt 0) { $pct = [int][Math]::Round(100.0 * $OkCount / $TotalCount) }
521
+ $state = 1
522
+ if ($FailCount -gt 0) { $state = 2 }
523
+ if ($script:GcrTui.Phase -eq "fetch" -and $script:GcrTui.GitPercent -ge 0) {
524
+ $pct = $script:GcrTui.GitPercent
525
+ $state = 1
526
+ }
527
+ Set-GcrTuiTabProgress -Percent $pct -State $state
528
+ try {
529
+ $short = Truncate-GcrDisplay -Text $script:GcrTui.RepoUrl -Width 40
530
+ [Console]::Title = ("git-clone-resume {0}% {1}" -f $pct, $short)
531
+ } catch { }
532
+ }
533
+
534
+ function Get-GcrHistoryPath {
535
+ $root = $env:LOCALAPPDATA
536
+ if ([string]::IsNullOrWhiteSpace($root)) { $root = $env:USERPROFILE }
537
+ if ([string]::IsNullOrWhiteSpace($root)) { $root = $env:HOME }
538
+ if ([string]::IsNullOrWhiteSpace($root)) { $root = [Environment]::GetFolderPath("ApplicationData") }
539
+ return (Join-Path (Join-Path $root "git-clone-resume") "history.json")
540
+ }
541
+
542
+ function Get-GcrHistory {
543
+ $path = Get-GcrHistoryPath
544
+ if (-not (Test-Path -LiteralPath $path)) { return @() }
545
+ try {
546
+ $raw = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
547
+ if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
548
+ $parsed = $raw | ConvertFrom-Json
549
+ return @($parsed)
550
+ } catch {
551
+ return @()
552
+ }
553
+ }
554
+
555
+ function Get-GcrHistoryLast {
556
+ $items = @(Get-GcrHistory)
557
+ if ($items.Count -eq 0) { return $null }
558
+ $partial = @($items | Where-Object { $_.status -eq "partial" -or $_.status -eq "running" -or $_.status -eq "failed" })
559
+ if ($partial.Count -gt 0) { return $partial[0] }
560
+ return $items[0]
561
+ }
562
+
563
+ function Save-GcrHistory {
564
+ param(
565
+ [string]$Url,
566
+ [string]$OutDir,
567
+ [string]$Ref,
568
+ [string]$Commit,
569
+ [string]$Status,
570
+ [int]$Ok = 0,
571
+ [int]$Total = 0,
572
+ [int]$Fail = 0
573
+ )
574
+ if ([string]::IsNullOrWhiteSpace($OutDir) -and [string]::IsNullOrWhiteSpace($Url)) { return }
575
+ $items = New-Object System.Collections.ArrayList
576
+ foreach ($it in @(Get-GcrHistory)) {
577
+ $sameDir = ($it.outDir -and $OutDir -and ([string]$it.outDir -eq $OutDir))
578
+ $sameUrl = ($it.url -and $Url -and ([string]$it.url -eq $Url) -and -not $OutDir)
579
+ if (-not $sameDir -and -not $sameUrl) { [void]$items.Add($it) }
580
+ }
581
+ $entry = @{
582
+ url = $Url
583
+ outDir = $OutDir
584
+ ref = $Ref
585
+ commit = $Commit
586
+ status = $Status
587
+ ok = $Ok
588
+ total = $Total
589
+ fail = $Fail
590
+ updated = (Get-Date).ToString("o")
591
+ }
592
+ [void]$items.Insert(0, $entry)
593
+ while ($items.Count -gt 25) { [void]$items.RemoveAt($items.Count - 1) }
594
+ $path = Get-GcrHistoryPath
595
+ $dir = Split-Path -Parent $path
596
+ try {
597
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
598
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
599
+ }
600
+ $json = ConvertTo-Json -InputObject @($items.ToArray()) -Depth 5
601
+ [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding $false))
602
+ } catch { }
603
+ }
604
+
605
+ function Format-GcrAgo {
606
+ param($When)
607
+ if ($null -eq $When) { return "" }
608
+ try {
609
+ $dt = [datetime]$When
610
+ } catch { return "" }
611
+ $d = (Get-Date) - $dt
612
+ if ($d.TotalSeconds -lt 60) { return "刚刚" }
613
+ if ($d.TotalMinutes -lt 60) { return ("{0} 分钟前" -f [int]$d.TotalMinutes) }
614
+ if ($d.TotalHours -lt 24) { return ("{0} 小时前" -f [int]$d.TotalHours) }
615
+ return ("{0} 天前" -f [int]$d.TotalDays)
616
+ }
617
+
618
+ function Format-GcrPhaseLabel {
619
+ param([string]$Phase)
620
+ switch ($Phase) {
621
+ "idle" { return "就绪" }
622
+ "wizard" { return "设置" }
623
+ "init" { return "初始化仓库" }
624
+ "fetch" { return "拉取元数据" }
625
+ "list" { return "枚举文件树" }
626
+ "scan" { return "扫描已有文件" }
627
+ "download"{ return "下载文件" }
628
+ "repair" { return "修复 git index" }
629
+ "done" { return "完成" }
630
+ "error" { return "出错" }
631
+ default { return $Phase }
632
+ }
633
+ }
634
+
635
+ function New-GcrBar {
636
+ param([int]$Width, [double]$Ratio, [switch]$Indeterminate, [int]$Tick)
637
+ $box = $script:GcrTui.Box
638
+ if ($Width -lt 3) { return "" }
639
+ if ($Indeterminate) {
640
+ $pos = 0
641
+ if ($Width -gt 0) { $pos = [Math]::Abs($Tick) % $Width }
642
+ $sb = New-Object System.Text.StringBuilder
643
+ for ($i = 0; $i -lt $Width; $i++) {
644
+ $d = [Math]::Abs($i - $pos)
645
+ if ($d -le 2) { [void]$sb.Append($box.BarF) } else { [void]$sb.Append($box.BarE) }
646
+ }
647
+ return $sb.ToString()
648
+ }
649
+ if ($Ratio -lt 0) { $Ratio = 0 }
650
+ if ($Ratio -gt 1) { $Ratio = 1 }
651
+ $filled = [int][Math]::Round($Width * $Ratio)
652
+ if ($filled -gt $Width) { $filled = $Width }
653
+ return (($box.BarF * $filled) + ($box.BarE * ($Width - $filled)))
654
+ }
655
+
656
+ function Out-GcrFrame {
657
+ param([string[]]$Lines)
658
+ if (-not (Test-GcrTuiActive)) { return }
659
+ $e = $script:GcrEsc
660
+ $size = Get-GcrTuiSize
661
+ $h = [int]$size.H
662
+ if ($h -lt 1) { return }
663
+ $rows = New-Object System.Collections.Generic.List[string]
664
+ $nIn = 0
665
+ if ($null -ne $Lines) { $nIn = @($Lines).Count }
666
+ for ($i = 0; $i -lt $h; $i++) {
667
+ if ($i -lt $nIn -and $null -ne $Lines[$i]) { [void]$rows.Add([string]$Lines[$i]) }
668
+ else { [void]$rows.Add("") }
669
+ }
670
+ $prev = @()
671
+ if ($null -ne $script:GcrTui.LastFrame) { $prev = @($script:GcrTui.LastFrame) }
672
+ $full = $true
673
+ if ($prev.Count -eq $h -and [int]$script:GcrTui.LastFrameW -eq [int]$size.W -and [int]$script:GcrTui.LastFrameH -eq $h) {
674
+ $full = $false
675
+ }
676
+ $sb = New-Object System.Text.StringBuilder
677
+ [void]$sb.Append($e).Append("[?25l")
678
+ for ($i = 0; $i -lt $h; $i++) {
679
+ $ln = $rows[$i]
680
+ if (-not $full -and $i -lt $prev.Count -and $ln -eq $prev[$i]) { continue }
681
+ [void]$sb.Append($e).Append("[").Append($i + 1).Append(";1H")
682
+ [void]$sb.Append($ln)
683
+ [void]$sb.Append($e).Append("[K")
684
+ }
685
+ try {
686
+ [Console]::Out.Write($sb.ToString())
687
+ [Console]::Out.Flush()
688
+ } catch { }
689
+ try { [Console]::CursorVisible = $false } catch { }
690
+ $script:GcrTui.LastFrame = $rows.ToArray()
691
+ $script:GcrTui.LastFrameW = [int]$size.W
692
+ $script:GcrTui.LastFrameH = $h
693
+ }
694
+
695
+ function Read-GcrTuiKey {
696
+ param([int]$TimeoutMs = 0)
697
+ try {
698
+ if ($TimeoutMs -le 0) {
699
+ if ([Console]::KeyAvailable) { return [Console]::ReadKey($true) }
700
+ return $null
701
+ }
702
+ $end = [Environment]::TickCount + $TimeoutMs
703
+ while ([Environment]::TickCount -lt $end) {
704
+ if ([Console]::KeyAvailable) { return [Console]::ReadKey($true) }
705
+ Start-Sleep -Milliseconds 20
706
+ }
707
+ } catch { }
708
+ return $null
709
+ }
710
+
711
+ function Test-GcrCtrlKey {
712
+ param($Key, [string]$Code)
713
+ if ($null -eq $Key) { return $false }
714
+ $ctrl = [int][ConsoleModifiers]::Control
715
+ if (([int]$Key.Modifiers -band $ctrl) -eq 0) { return $false }
716
+ return ($Key.Key.ToString() -eq $Code)
717
+ }
718
+
719
+ function Invoke-GcrTuiKey {
720
+ param($Key)
721
+ if ($null -eq $Key -or -not (Test-GcrTuiActive)) { return }
722
+ if ($script:GcrTui.Screen -eq "result") {
723
+ if ($Key.Key -eq "Enter" -or $Key.Key -eq "Q" -or $Key.Key -eq "Escape") {
724
+ $script:GcrTui.Status = "close"
725
+ }
726
+ return
727
+ }
728
+ if ($script:GcrTui.Help) {
729
+ if ($Key.Key -ne "LeftArrow" -and $Key.Key -ne "RightArrow") {
730
+ $script:GcrTui.Help = $false
731
+ $script:GcrTui.Dirty = $true
732
+ }
733
+ return
734
+ }
735
+ if (Test-GcrCtrlKey -Key $Key -Code "C") {
736
+ Request-GcrTuiQuit
737
+ return
738
+ }
739
+ switch ($Key.Key.ToString()) {
740
+ "Q" { Request-GcrTuiQuit }
741
+ "P" {
742
+ $script:GcrTui.Paused = -not $script:GcrTui.Paused
743
+ $script:GcrTui.Dirty = $true
744
+ }
745
+ "Escape" {
746
+ if ($script:GcrTui.FailView) { $script:GcrTui.FailView = $false }
747
+ else { $script:GcrTui.Paused = -not $script:GcrTui.Paused }
748
+ $script:GcrTui.Dirty = $true
749
+ }
750
+ "Spacebar" {
751
+ if ($script:GcrTui.Paused) { $script:GcrTui.Paused = $false }
752
+ $script:GcrTui.Dirty = $true
753
+ }
754
+ "H" { $script:GcrTui.Help = $true; $script:GcrTui.Dirty = $true }
755
+ "F" { $script:GcrTui.FailView = -not $script:GcrTui.FailView; $script:GcrTui.Dirty = $true }
756
+ "UpArrow" {
757
+ $script:GcrTui.LogOffset = [Math]::Min($script:GcrTui.Logs.Count, $script:GcrTui.LogOffset + 1)
758
+ $script:GcrTui.Dirty = $true
759
+ }
760
+ "DownArrow" {
761
+ $script:GcrTui.LogOffset = [Math]::Max(0, $script:GcrTui.LogOffset - 1)
762
+ $script:GcrTui.Dirty = $true
763
+ }
764
+ "End" { $script:GcrTui.LogOffset = 0; $script:GcrTui.Dirty = $true }
765
+ "Home" {
766
+ $script:GcrTui.LogOffset = [Math]::Max(0, $script:GcrTui.Logs.Count - 1)
767
+ $script:GcrTui.Dirty = $true
768
+ }
769
+ "J" {
770
+ if ($Key.KeyChar -eq "j") {
771
+ $script:GcrTui.LogOffset = [Math]::Max(0, $script:GcrTui.LogOffset - 1)
772
+ $script:GcrTui.Dirty = $true
773
+ }
774
+ }
775
+ "K" {
776
+ if ($Key.KeyChar -eq "k") {
777
+ $script:GcrTui.LogOffset = [Math]::Min($script:GcrTui.Logs.Count, $script:GcrTui.LogOffset + 1)
778
+ $script:GcrTui.Dirty = $true
779
+ }
780
+ }
781
+ default {
782
+ if ($Key.KeyChar -eq "?") { $script:GcrTui.Help = $true; $script:GcrTui.Dirty = $true }
783
+ }
784
+ }
785
+ }
786
+
787
+ function Request-GcrTuiQuit {
788
+ if (-not (Test-GcrTuiActive)) { return }
789
+ if ($script:GcrTui.QuitRequested) {
790
+ $script:GcrTui.ForceQuit = $true
791
+ if ($null -ne $script:GcrCurrentProc) {
792
+ try { $script:GcrCurrentProc.Kill() } catch { }
793
+ }
794
+ } else {
795
+ $script:GcrTui.QuitRequested = $true
796
+ $script:GcrTui.Paused = $false
797
+ }
798
+ $script:GcrTui.Dirty = $true
799
+ }
800
+
801
+ function Invoke-GcrTuiTick {
802
+ param([switch]$Force)
803
+ if (-not (Test-GcrTuiActive)) { return }
804
+ $size = Get-GcrTuiSize
805
+ if ($size.W -ne $script:GcrTui.Width -or $size.H -ne $script:GcrTui.Height) {
806
+ $script:GcrTui.Width = $size.W
807
+ $script:GcrTui.Height = $size.H
808
+ $script:GcrTui.LastFrame = @()
809
+ $script:GcrTui.Dirty = $true
810
+ }
811
+ $guard = 0
812
+ while ($guard -lt 8) {
813
+ $k = Read-GcrTuiKey -TimeoutMs 0
814
+ if ($null -eq $k) { break }
815
+ Invoke-GcrTuiKey -Key $k
816
+ $guard++
817
+ }
818
+ $now = Get-Date
819
+ $ms = 1000
820
+ try { $ms = ($now - $script:GcrTui.LastDraw).TotalMilliseconds } catch { $ms = 1000 }
821
+ $anim = ($script:GcrTui.Phase -in @("fetch", "init", "list", "scan"))
822
+ $need = [bool]$Force -or [bool]$script:GcrTui.Dirty -or ($anim -and $ms -ge 250)
823
+ if ($need -and $ms -ge 50) {
824
+ Render-GcrTui
825
+ $script:GcrTui.Dirty = $false
826
+ $script:GcrTui.LastDraw = $now
827
+ $script:GcrTui.Tick++
828
+ }
829
+ }
830
+
831
+ function Wait-GcrTuiPaused {
832
+ if (-not (Test-GcrTuiActive)) { return }
833
+ while ($script:GcrTui.Paused -and -not $script:GcrTui.QuitRequested) {
834
+ Invoke-GcrTuiTick
835
+ Start-Sleep -Milliseconds 80
836
+ }
837
+ }
838
+
839
+ function Get-GcrDashGuide {
840
+ if ($script:GcrTui.Help) { return "快捷键说明。任意键关闭此帮助。" }
841
+ if ($script:GcrTui.FailView) { return "失败文件列表。F 返回活动日志,再次运行同一命令会重试。" }
842
+ if ($script:GcrTui.Screen -eq "result") { return "本次运行已结束。Enter 关闭界面,进度保留在 .git/partial-resume/。" }
843
+ if ($script:GcrTui.QuitRequested) { return "将在当前 git 命令结束后停止。Ctrl+C 再按一次立即结束。" }
844
+ if ($script:GcrTui.Paused) { return "已暂停:当前批次结束后停住。Space 继续,Q 停止。" }
845
+ switch ($script:GcrTui.Phase) {
846
+ "init" { return "初始化本地仓库并配置 partial clone(只拉元数据,不拉文件内容)。" }
847
+ "fetch" { return "正在拉取 commit/tree 元数据(blob:none)。文件内容会在下一步按批下载。" }
848
+ "list" { return "枚举仓库文件树。不会为了拿大小去拉全部 blob。" }
849
+ "scan" { return "扫描工作区,跳过已经落盘的文件,其余进入待下载队列。" }
850
+ "download" { return "按批 checkout 文件。中断后重跑同一命令即可续传。" }
851
+ "repair" { return "修复 Windows 上可能被弄乱的 git index。" }
852
+ "done" { return "全部完成。工作区已可用。" }
853
+ "error" { return "出错或未完成。重新运行同一命令即可从断点继续。" }
854
+ default { return "断点续传克隆。Q 停止 · P 暂停 · ? 帮助" }
855
+ }
856
+ }
857
+
858
+ function Get-GcrWizardGuide {
859
+ param($St)
860
+ if ($St.ConfirmQuit) { return "退出向导?未开始的克隆不会写入进度。Enter 确定,Esc 取消。" }
861
+ if ($St.Edit) { return "正在编辑。Enter 确认,Esc 取消,Ctrl+V 粘贴。光标用 ← → Home End。" }
862
+ if ($St.Focus -eq "recent") { return "最近任务。Enter 填入 URL/目录/分支,可直接续传未完成的克隆。" }
863
+ $items = Get-GcrWizardItems -St $St
864
+ $sel = [int]$St.Sel
865
+ if ($sel -ge 0 -and $sel -lt $items.Count) {
866
+ $g = [string]$items[$sel].Guide
867
+ if ($g) { return $g }
868
+ }
869
+ return "↑↓ 选择选项,Enter 编辑或开始。每个选项的说明会显示在这一行。"
870
+ }
871
+
872
+ function Render-GcrTui {
873
+ if (-not (Test-GcrTuiActive)) { return }
874
+ if ($script:GcrTui.Screen -eq "wizard") { return }
875
+ $size = Get-GcrTuiSize
876
+ $w = $size.DrawW
877
+ $h = $size.H
878
+ $script:GcrTui.Width = $size.W
879
+ $script:GcrTui.Height = $h
880
+ $c = @{
881
+ R = Get-GcrColor "reset"
882
+ B = Get-GcrColor "bold"
883
+ D = Get-GcrColor "dim"
884
+ C = Get-GcrColor "cyan"
885
+ G = Get-GcrColor "green"
886
+ Y = Get-GcrColor "yellow"
887
+ E = Get-GcrColor "red"
888
+ W = Get-GcrColor "white"
889
+ T = Get-GcrColor "teal"
890
+ }
891
+ $box = $script:GcrTui.Box
892
+ $lines = New-Object System.Collections.Generic.List[string]
893
+ $inner = [Math]::Max(10, $w - 2)
894
+ function Push-GcrBorder {
895
+ param([string]$Kind)
896
+ $ch = $box.H
897
+ if ($Kind -eq "top") { $plain = $box.TL + ($ch * $inner) + $box.TR }
898
+ elseif ($Kind -eq "bot") { $plain = $box.BL + ($ch * $inner) + $box.BR }
899
+ else { $plain = $box.L + ($ch * $inner) + $box.R }
900
+ [void]$lines.Add($c.D + (Format-GcrCell $plain $w) + $c.R)
901
+ }
902
+ function Push-GcrRow {
903
+ param([string]$Left, [string]$Right = "", [string]$Color = "")
904
+ if (-not $Color) { $Color = $c.W }
905
+ $leftW = Get-GcrDisplayWidth $Left
906
+ $rightW = Get-GcrDisplayWidth $Right
907
+ $gap = $inner - $leftW - $rightW
908
+ if ($gap -lt 1) {
909
+ $keep = [Math]::Max(8, $inner - $rightW - 1)
910
+ $Left = Truncate-GcrDisplay $Left $keep
911
+ $leftW = Get-GcrDisplayWidth $Left
912
+ $gap = $inner - $leftW - $rightW
913
+ if ($gap -lt 0) { $Right = ""; $rightW = 0; $gap = $inner - $leftW }
914
+ if ($gap -lt 0) { $gap = 0 }
915
+ }
916
+ $body = $Left + (" " * $gap) + $Right
917
+ $plainSides = $box.V
918
+ $row = $c.D + $plainSides + $c.R + $Color + $body + $c.R + $c.D + $plainSides + $c.R
919
+ [void]$lines.Add($row)
920
+ }
921
+
922
+ $badge = "RUN"
923
+ $badgeC = $c.C
924
+ if ($script:GcrTui.Resume) { $badge = "RESUME" }
925
+ if ($script:GcrTui.Paused) { $badge = "PAUSED"; $badgeC = $c.Y }
926
+ if ($script:GcrTui.QuitRequested) { $badge = "STOPPING"; $badgeC = $c.Y }
927
+ if ($script:GcrTui.Status -eq "done") { $badge = "DONE"; $badgeC = $c.G }
928
+ if ($script:GcrTui.Status -eq "error") { $badge = "ERROR"; $badgeC = $c.E }
929
+ if ($script:GcrTui.ForceQuit) { $badge = "KILLED"; $badgeC = $c.E }
930
+
931
+ Push-GcrBorder "top"
932
+ $title = " git-clone-resume"
933
+ $sub = $badge + " "
934
+ Push-GcrRow -Left ($title) -Right $sub -Color ($c.B + $c.C)
935
+ Push-GcrBorder "mid"
936
+
937
+ $compact = ($h -lt 16)
938
+ if (-not $compact) {
939
+ Push-GcrRow -Left (" 仓库 " + $(if ($script:GcrTui.RepoUrl) { $script:GcrTui.RepoUrl } else { "-" })) -Color $c.W
940
+ Push-GcrRow -Left (" 目录 " + $(if ($script:GcrTui.OutDir) { $script:GcrTui.OutDir } else { "-" })) -Color $c.D
941
+ $sha = $script:GcrTui.Commit
942
+ if ($sha.Length -gt 12) { $sha = $sha.Substring(0, 12) }
943
+ $refLine = " 引用 " + $(if ($script:GcrTui.Ref) { $script:GcrTui.Ref } else { "HEAD" })
944
+ if ($sha) { $refLine = $refLine + " commit " + $sha }
945
+ Push-GcrRow -Left $refLine -Color $c.D
946
+ $phase = Format-GcrPhaseLabel $script:GcrTui.Phase
947
+ $detail = $script:GcrTui.PhaseDetail
948
+ Push-GcrRow -Left (" 阶段 " + $phase) -Right $detail -Color $c.C
949
+ Push-GcrBorder "mid"
950
+ }
951
+
952
+ $ratio = 0.0
953
+ $pctText = "0%"
954
+ $indet = $false
955
+ if ($script:GcrTui.Phase -eq "fetch" -and $script:GcrTui.GitPercent -ge 0) {
956
+ $ratio = $script:GcrTui.GitPercent / 100.0
957
+ $pctText = ("{0}%" -f $script:GcrTui.GitPercent)
958
+ } elseif ($script:GcrTui.Total -gt 0) {
959
+ $ratio = $script:GcrTui.Ok / [double]$script:GcrTui.Total
960
+ $pctText = ("{0:N1}%" -f (100.0 * $ratio))
961
+ } elseif ($script:GcrTui.Phase -in @("fetch", "init", "list")) {
962
+ $indet = $true
963
+ $pctText = "..."
964
+ }
965
+ $barW = [Math]::Max(10, $inner - 12)
966
+ $bar = New-GcrBar -Width $barW -Ratio $ratio -Indeterminate:$indet -Tick $script:GcrTui.Tick
967
+ $barColor = $c.G
968
+ if ($script:GcrTui.Fail -gt 0) { $barColor = $c.Y }
969
+ if ($script:GcrTui.Status -eq "error") { $barColor = $c.E }
970
+ Push-GcrRow -Left (" " + $bar) -Right $pctText -Color $barColor
971
+
972
+ $bytes = ""
973
+ try { $bytes = Format-Bytes ([int64]$script:GcrTui.Bytes) } catch { $bytes = [string]$script:GcrTui.Bytes }
974
+ $stats = (" {0}/{1} 失败 {2} {3} {4:N1}/s ETA {5}" -f @(
975
+ $script:GcrTui.Ok,
976
+ $script:GcrTui.Total,
977
+ $script:GcrTui.Fail,
978
+ $bytes,
979
+ $script:GcrTui.Rate,
980
+ $script:GcrTui.Eta
981
+ ))
982
+ Push-GcrRow -Left $stats -Color $c.W
983
+ if (-not $compact) {
984
+ $cur = $script:GcrTui.CurrentFile
985
+ if (-not $cur) { $cur = "-" }
986
+ Push-GcrRow -Left (" 当前 " + $cur) -Color $c.D
987
+ }
988
+
989
+ $footerReserve = 4
990
+ $used = $lines.Count
991
+ $activityH = $h - $used - $footerReserve
992
+ if ($activityH -lt 0) { $activityH = 0 }
993
+
994
+ if ($activityH -gt 0) {
995
+ Push-GcrBorder "mid"
996
+ $activityH = $h - $lines.Count - $footerReserve
997
+ if ($activityH -lt 1) { $activityH = 1 }
998
+
999
+ $bodyLines = New-Object System.Collections.ArrayList
1000
+ if ($script:GcrTui.Screen -eq "result" -and @($script:GcrTui.ResultBody).Count -gt 0) {
1001
+ foreach ($b in @($script:GcrTui.ResultBody)) {
1002
+ [void]$bodyLines.Add(@{ L = "INFO"; M = [string]$b; T = $null })
1003
+ }
1004
+ } elseif ($script:GcrTui.Help) {
1005
+ foreach ($b in @(
1006
+ "键盘",
1007
+ " Q / Ctrl+C 停止(当前 git 命令结束后生效;再按一次强制结束)",
1008
+ " P / Esc 当前批次结束后暂停",
1009
+ " Space 从暂停恢复",
1010
+ " F 切换失败文件列表",
1011
+ " Up/Down j k 滚动活动日志",
1012
+ " End 跟随最新日志",
1013
+ " ? / H 打开或关闭本帮助",
1014
+ "",
1015
+ "续传:重新运行同一条命令。进度在 .git/partial-resume/",
1016
+ "脚本模式:加 -NoTui。强制界面:加 -Tui。"
1017
+ )) {
1018
+ [void]$bodyLines.Add(@{ L = "INFO"; M = $b; T = $null })
1019
+ }
1020
+ } elseif ($script:GcrTui.FailView) {
1021
+ if ($script:GcrTui.Failures.Count -eq 0) {
1022
+ [void]$bodyLines.Add(@{ L = "INFO"; M = "暂无失败文件。"; T = $null })
1023
+ } else {
1024
+ foreach ($f in $script:GcrTui.Failures) {
1025
+ [void]$bodyLines.Add(@{ L = "ERROR"; M = $f; T = $null })
1026
+ }
1027
+ }
1028
+ } else {
1029
+ foreach ($e in $script:GcrTui.Logs) { [void]$bodyLines.Add($e) }
1030
+ }
1031
+
1032
+ $view = @($bodyLines)
1033
+ $n = $view.Count
1034
+ $off = [int]$script:GcrTui.LogOffset
1035
+ $end = $n - $off
1036
+ if ($end -lt 0) { $end = 0 }
1037
+ $start = $end - $activityH
1038
+ if ($start -lt 0) { $start = 0 }
1039
+ for ($row = 0; $row -lt $activityH; $row++) {
1040
+ $idx = $start + $row
1041
+ if ($idx -ge $end) {
1042
+ Push-GcrRow -Left "" -Color $c.D
1043
+ continue
1044
+ }
1045
+ $item = $view[$idx]
1046
+ $prefix = ""
1047
+ if ($item.T) { $prefix = ([datetime]$item.T).ToString("HH:mm:ss") + " " }
1048
+ $lvl = [string]$item.L
1049
+ if (-not $lvl) { $lvl = "INFO" }
1050
+ $text = $prefix + $item.M
1051
+ Push-GcrRow -Left (" " + $text) -Color (Get-GcrLevelColor $lvl)
1052
+ }
1053
+ }
1054
+
1055
+ Push-GcrBorder "mid"
1056
+ $guide = Get-GcrDashGuide
1057
+ Push-GcrRow -Left (" " + $guide) -Color $c.C
1058
+ $hint = ""
1059
+ if ($script:GcrTui.Screen -eq "result") {
1060
+ $hint = " Enter 关闭 · Q 退出 · ? 帮助"
1061
+ } elseif ($script:GcrTui.Help) {
1062
+ $hint = " 任意键关闭帮助"
1063
+ } elseif ($script:GcrTui.QuitRequested) {
1064
+ $hint = " Ctrl+C 再按一次强制结束 · ? 帮助"
1065
+ } elseif ($script:GcrTui.Paused) {
1066
+ $hint = " Space 继续 · Q 停止 · ? 帮助"
1067
+ } else {
1068
+ $hint = " Q 停止 · P 暂停 · F 失败 · ? 帮助 · ↑↓ 日志"
1069
+ }
1070
+ $hintColor = $c.D
1071
+ if ($script:GcrTui.Paused -or $script:GcrTui.QuitRequested) { $hintColor = $c.Y }
1072
+ Push-GcrRow -Left $hint -Color $hintColor
1073
+ Push-GcrBorder "bot"
1074
+
1075
+ while ($lines.Count -gt $h) { $lines.RemoveAt($lines.Count - 1) }
1076
+ while ($lines.Count -lt $h) { [void]$lines.Add("") }
1077
+ Out-GcrFrame -Lines $lines.ToArray()
1078
+ }
1079
+
1080
+ function Show-GcrTuiResult {
1081
+ param(
1082
+ [string]$Title,
1083
+ [string[]]$Body,
1084
+ [ValidateSet("done", "error")]
1085
+ [string]$Kind = "done"
1086
+ )
1087
+ if (-not (Test-GcrTuiActive)) { return }
1088
+ $script:GcrTui.Screen = "result"
1089
+ $script:GcrTui.Status = $Kind
1090
+ $script:GcrTui.Phase = $(if ($Kind -eq "done") { "done" } else { "error" })
1091
+ $script:GcrTui.ResultTitle = $Title
1092
+ $script:GcrTui.ResultBody = @($Body)
1093
+ $script:GcrTui.Help = $false
1094
+ $script:GcrTui.Paused = $false
1095
+ Add-GcrTuiLog -Level $(if ($Kind -eq "done") { "OK" } else { "ERROR" }) -Message $Title
1096
+ $script:GcrTui.Dirty = $true
1097
+ $pct = 100
1098
+ if ($script:GcrTui.Total -gt 0) {
1099
+ $pct = [int][Math]::Round(100.0 * $script:GcrTui.Ok / $script:GcrTui.Total)
1100
+ }
1101
+ $state = 1
1102
+ if ($Kind -eq "error") { $state = 2 }
1103
+ Set-GcrTuiTabProgress -Percent $pct -State $state
1104
+ Render-GcrTui
1105
+ $waited = 0
1106
+ while ($waited -lt 3600000) {
1107
+ $k = Read-GcrTuiKey -TimeoutMs 200
1108
+ if ($null -ne $k) {
1109
+ if ($k.Key -eq "Enter" -or $k.Key -eq "Q" -or $k.Key -eq "Escape" -or (Test-GcrCtrlKey $k "C")) {
1110
+ break
1111
+ }
1112
+ if ($k.KeyChar -eq "?") { $script:GcrTui.Help = -not $script:GcrTui.Help; Render-GcrTui }
1113
+ }
1114
+ $waited += 200
1115
+ $size = Get-GcrTuiSize
1116
+ if ($size.W -ne $script:GcrTui.Width -or $size.H -ne $script:GcrTui.Height) {
1117
+ $script:GcrTui.LastFrame = @()
1118
+ Render-GcrTui
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ function Test-GcrRepoUrlText {
1124
+ param([string]$Text)
1125
+ if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
1126
+ $s = $Text.Trim()
1127
+ if ($s -match "\s") { return $false }
1128
+ if ($s -match "^(https?|git|ssh)://") { return $true }
1129
+ if ($s -match "^[\w.-]+@[\w.-]+:") { return $true }
1130
+ if ($s -match "\.git$") { return $true }
1131
+ if ($s -match "^(github\.com|gitlab\.com|gitee\.com|bitbucket\.org)[/:]") { return $true }
1132
+ if ($s -match "^[A-Za-z]:[\\/]") { return $true }
1133
+ if ($s -match "^\\\\") { return $true }
1134
+ return $false
1135
+ }
1136
+
1137
+ function Get-GcrFolderNameSafe {
1138
+ param([string]$Url)
1139
+ if (Get-Command Get-RepoFolderName -ErrorAction SilentlyContinue) {
1140
+ return Get-RepoFolderName -Url $Url
1141
+ }
1142
+ $s = $Url.Trim().TrimEnd([char]47, [char]92)
1143
+ if ($s.Length -ge 4 -and $s.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
1144
+ $s = $s.Substring(0, $s.Length - 4)
1145
+ }
1146
+ $s = $s.Replace([char]92, [char]47)
1147
+ $i = $s.LastIndexOf([char]47)
1148
+ if ($i -ge 0) { $s = $s.Substring($i + 1) }
1149
+ if ([string]::IsNullOrWhiteSpace($s)) { return "repo" }
1150
+ return $s
1151
+ }
1152
+
1153
+ function Get-GcrClipboardText {
1154
+ try {
1155
+ $t = Get-Clipboard -Raw -ErrorAction Stop
1156
+ if ($null -eq $t) { return "" }
1157
+ return ([string]$t).Trim()
1158
+ } catch { return "" }
1159
+ }
1160
+
1161
+ function Convert-GcrWizardResult {
1162
+ param($St)
1163
+ $include = New-Object System.Collections.Generic.List[string]
1164
+ $exclude = New-Object System.Collections.Generic.List[string]
1165
+ $incText = ""
1166
+ $excText = ""
1167
+ try { $incText = [string]$St.Include } catch { }
1168
+ try { $excText = [string]$St.Exclude } catch { }
1169
+ if (-not [string]::IsNullOrWhiteSpace($incText)) {
1170
+ foreach ($p in @($incText -split "[,;]")) {
1171
+ $t = $p.Trim()
1172
+ if ($t) { [void]$include.Add($t) }
1173
+ }
1174
+ }
1175
+ if (-not [string]::IsNullOrWhiteSpace($excText)) {
1176
+ foreach ($p in @($excText -split "[,;]")) {
1177
+ $t = $p.Trim()
1178
+ if ($t) { [void]$exclude.Add($t) }
1179
+ }
1180
+ }
1181
+ $depth = $null
1182
+ $depthText = ""
1183
+ try { $depthText = [string]$St.Depth } catch { }
1184
+ if ($depthText -match "^\d+$") { $depth = [int]$depthText }
1185
+ $url = ""
1186
+ try { $url = ([string]$St.Url).Trim() } catch { }
1187
+ $dir = ""
1188
+ try { $dir = [string]$St.OutDir } catch { }
1189
+ if ([string]::IsNullOrWhiteSpace($dir)) { $dir = Get-GcrFolderNameSafe -Url $url }
1190
+ $ref = "HEAD"
1191
+ try {
1192
+ if ($St.Ref) { $ref = ([string]$St.Ref).Trim() }
1193
+ } catch { }
1194
+ if ([string]::IsNullOrWhiteSpace($ref)) { $ref = "HEAD" }
1195
+ $batch = 32
1196
+ $retries = 8
1197
+ try { if ($St.BatchSize) { $batch = [int]$St.BatchSize } } catch { }
1198
+ try { if ($St.MaxRetries) { $retries = [int]$St.MaxRetries } } catch { }
1199
+ if ($batch -lt 1) { $batch = 32 }
1200
+ if ($retries -lt 1) { $retries = 8 }
1201
+ $obj = New-Object psobject
1202
+ Add-Member -InputObject $obj -NotePropertyName RepoUrl -NotePropertyValue $url
1203
+ Add-Member -InputObject $obj -NotePropertyName OutDir -NotePropertyValue $dir
1204
+ Add-Member -InputObject $obj -NotePropertyName Ref -NotePropertyValue $ref
1205
+ Add-Member -InputObject $obj -NotePropertyName BatchSize -NotePropertyValue $batch
1206
+ Add-Member -InputObject $obj -NotePropertyName MaxRetries -NotePropertyValue $retries
1207
+ Add-Member -InputObject $obj -NotePropertyName Include -NotePropertyValue $include.ToArray()
1208
+ Add-Member -InputObject $obj -NotePropertyName Exclude -NotePropertyValue $exclude.ToArray()
1209
+ Add-Member -InputObject $obj -NotePropertyName Depth -NotePropertyValue $depth
1210
+ Add-Member -InputObject $obj -NotePropertyName Verify -NotePropertyValue ([bool]$St.Verify)
1211
+ Add-Member -InputObject $obj -NotePropertyName ForceRefetch -NotePropertyValue ([bool]$St.ForceRefetch)
1212
+ Add-Member -InputObject $obj -NotePropertyName DryRun -NotePropertyValue ([bool]$St.DryRun)
1213
+ return ,$obj
1214
+ }
1215
+
1216
+ function ConvertFrom-GcrWizardOutput {
1217
+ param($Raw)
1218
+ if ($null -eq $Raw) { return $null }
1219
+ foreach ($x in @($Raw)) {
1220
+ if ($null -eq $x) { continue }
1221
+ if ($x -is [hashtable]) {
1222
+ if ($x.ContainsKey("RepoUrl")) { return $x }
1223
+ continue
1224
+ }
1225
+ try {
1226
+ if ($null -ne $x.PSObject.Properties["RepoUrl"]) { return $x }
1227
+ } catch { }
1228
+ }
1229
+ return $null
1230
+ }
1231
+
1232
+ function Render-GcrTuiWizard {
1233
+ param($St)
1234
+ $size = Get-GcrTuiSize
1235
+ $w = $size.DrawW
1236
+ $h = $size.H
1237
+ $inner = [Math]::Max(10, $w - 2)
1238
+ $c = @{
1239
+ R = Get-GcrColor "reset"
1240
+ B = Get-GcrColor "bold"
1241
+ D = Get-GcrColor "dim"
1242
+ C = Get-GcrColor "cyan"
1243
+ G = Get-GcrColor "green"
1244
+ Y = Get-GcrColor "yellow"
1245
+ E = Get-GcrColor "red"
1246
+ W = Get-GcrColor "white"
1247
+ }
1248
+ $box = $script:GcrTui.Box
1249
+ $lines = New-Object System.Collections.Generic.List[string]
1250
+ function WBorder([string]$Kind) {
1251
+ $ch = $box.H
1252
+ if ($Kind -eq "top") { $plain = $box.TL + ($ch * $inner) + $box.TR }
1253
+ elseif ($Kind -eq "bot") { $plain = $box.BL + ($ch * $inner) + $box.BR }
1254
+ else { $plain = $box.L + ($ch * $inner) + $box.R }
1255
+ [void]$lines.Add($c.D + (Format-GcrCell $plain $w) + $c.R)
1256
+ }
1257
+ function WRow([string]$Text, [string]$Color, [switch]$Sel) {
1258
+ if (-not $Color) { $Color = $c.W }
1259
+ $body = Format-GcrCell -Text $Text -Width $inner
1260
+ if ($Sel) { $Color = (Get-GcrColor "rev") + $Color }
1261
+ $row = $c.D + $box.V + $c.R + $Color + $body + $c.R + $c.D + $box.V + $c.R
1262
+ [void]$lines.Add($row)
1263
+ }
1264
+
1265
+ $items = Get-GcrWizardItems -St $St
1266
+ WBorder "top"
1267
+ WRow " git-clone-resume" ($c.B + $c.C)
1268
+ WRow " 断点续传克隆 · partial clone + 按批 checkout" $c.D
1269
+ WBorder "mid"
1270
+
1271
+ $formEnd = 11
1272
+ $recent = @($St.Recent)
1273
+ $maxFormVisible = [Math]::Max(6, $h - 10)
1274
+ if ($maxFormVisible -gt ($formEnd + 1)) { $maxFormVisible = $formEnd + 1 }
1275
+ $sel = [int]$St.Sel
1276
+ $top = [int]$St.Scroll
1277
+ if ($sel -le $formEnd) {
1278
+ if ($sel -lt $top) { $top = $sel }
1279
+ if ($sel -ge $top + $maxFormVisible) { $top = $sel - $maxFormVisible + 1 }
1280
+ if ($top -lt 0) { $top = 0 }
1281
+ $St.Scroll = $top
1282
+ }
1283
+ $end = [Math]::Min($formEnd, $top + $maxFormVisible - 1)
1284
+ for ($i = $top; $i -le $end; $i++) {
1285
+ $it = $items[$i]
1286
+ $mark = " "
1287
+ if ($sel -eq $i) { $mark = " " + $script:GcrTui.Box.Pointer }
1288
+ $val = [string]$it.Value
1289
+ if ($it.Kind -eq "bool") { $val = $(if ($it.Flag) { "开" } else { "关" }) }
1290
+ if ($St.Edit -and $sel -eq $i) {
1291
+ $buf = [string]$St.EditBuf
1292
+ $cur = [int]$St.EditCur
1293
+ if ($cur -lt 0) { $cur = 0 }
1294
+ if ($cur -gt $buf.Length) { $cur = $buf.Length }
1295
+ $val = $buf.Insert($cur, "|")
1296
+ }
1297
+ $label = Format-GcrCell $it.Label 14
1298
+ $text = $mark + " " + $label + " " + $val
1299
+ $col = $c.W
1300
+ if ($it.Kind -eq "start") { $col = $c.G + $c.B }
1301
+ WRow $text $col -Sel:($sel -eq $i -and -not $St.Edit)
1302
+ }
1303
+
1304
+ WBorder "mid"
1305
+ WRow " 最近任务 (Tab 切换 · Enter 填入)" $c.D
1306
+ $recentSlots = $h - $lines.Count - 4
1307
+ if ($recentSlots -lt 1) { $recentSlots = 1 }
1308
+ if ($recent.Count -eq 0) {
1309
+ WRow " (无。完成一次克隆后会出现在这里)" $c.D
1310
+ $recentSlots--
1311
+ } else {
1312
+ $show = [Math]::Min($recent.Count, [Math]::Max(1, $recentSlots))
1313
+ for ($r = 0; $r -lt $show; $r++) {
1314
+ $it = $recent[$r]
1315
+ $name = [string]$it.outDir
1316
+ if ($name) { $name = Split-Path -Leaf $name }
1317
+ if (-not $name) { $name = [string]$it.url }
1318
+ $pct = ""
1319
+ try {
1320
+ if ([int]$it.total -gt 0) { $pct = ("{0}%" -f [int](100 * [int]$it.ok / [int]$it.total)) }
1321
+ } catch { }
1322
+ $stt = [string]$it.status
1323
+ $ago = ""
1324
+ try { $ago = Format-GcrAgo $it.updated } catch { }
1325
+ $mark = " "
1326
+ $isSel = ($St.Focus -eq "recent" -and [int]$St.RecentSel -eq $r)
1327
+ if ($isSel) { $mark = " " + $script:GcrTui.Box.Pointer + " " }
1328
+ $text = $mark + $name + " " + $stt + " " + $pct + " " + $ago
1329
+ WRow $text $(if ($isSel) { $c.C } else { $c.D }) -Sel:$isSel
1330
+ $recentSlots--
1331
+ }
1332
+ }
1333
+ while ($recentSlots -gt 0) { WRow "" $c.D; $recentSlots-- }
1334
+
1335
+ WBorder "mid"
1336
+ $guide = Get-GcrWizardGuide -St $St
1337
+ if ($St.Error) { $guide = [string]$St.Error }
1338
+ $guideColor = $c.C
1339
+ if ($St.Error) { $guideColor = $c.E }
1340
+ if ($St.ConfirmQuit) { $guideColor = $c.Y }
1341
+ WRow (" " + $guide) $guideColor
1342
+ $foot = " Enter 编辑/开始 · Space 开关 · ←→ 改批次 · Ctrl+V 粘贴 · Q 退出"
1343
+ if ($St.Edit) { $foot = " Enter 确认 · Esc 取消 · Ctrl+V 粘贴" }
1344
+ if ($St.ConfirmQuit) { $foot = " Enter 确定退出 · Esc 返回" }
1345
+ WRow $foot $c.D
1346
+ WBorder "bot"
1347
+ while ($lines.Count -gt $h) { $lines.RemoveAt($lines.Count - 1) }
1348
+ while ($lines.Count -lt $h) { [void]$lines.Add("") }
1349
+ Out-GcrFrame -Lines $lines.ToArray()
1350
+ }
1351
+
1352
+ function Get-GcrWizardItems {
1353
+ param($St)
1354
+ return @(
1355
+ @{ Id = "url"; Kind = "text"; Label = "仓库 URL"; Value = $St.Url; Flag = $false; Guide = "远程仓库地址,支持 https、ssh、git@ 以及本地路径。Ctrl+V 从剪贴板粘贴。" }
1356
+ @{ Id = "dir"; Kind = "text"; Label = "本地目录"; Value = $(if ($St.OutDir) { $St.OutDir } else { "(自动)" }); Flag = $false; Guide = "工作区目录。留空则用仓库名。已有 .git/partial-resume 时自动续传。" }
1357
+ @{ Id = "ref"; Kind = "text"; Label = "分支/标签"; Value = $St.Ref; Flag = $false; Guide = "分支、标签或 commit SHA。默认远程 HEAD。" }
1358
+ @{ Id = "batch"; Kind = "enum"; Label = "每批文件"; Value = [string]$St.BatchSize; Flag = $false; Guide = "每批 checkout 的文件数。越大越快,中断粒度越粗。← → 调整。" }
1359
+ @{ Id = "retry"; Kind = "enum"; Label = "重试次数"; Value = [string]$St.MaxRetries; Flag = $false; Guide = "单文件失败后的最大重试次数。← → 调整。网络不稳时可调大。" }
1360
+ @{ Id = "include"; Kind = "text"; Label = "只含路径"; Value = $(if ($St.Include) { $St.Include } else { "(全部)" }); Flag = $false; Guide = "只下载匹配的路径,逗号分隔通配符,例如 src/*,docs/*。空表示全部。" }
1361
+ @{ Id = "exclude"; Kind = "text"; Label = "排除路径"; Value = $(if ($St.Exclude) { $St.Exclude } else { "(无)" }); Flag = $false; Guide = "跳过匹配的路径,例如 *.bin,*.zip。可与「只含路径」同时使用。" }
1362
+ @{ Id = "depth"; Kind = "text"; Label = "浅克隆深度"; Value = $(if ($St.Depth) { $St.Depth } else { "(完整历史)" }); Flag = $false; Guide = "浅克隆深度。留空则拉完整 commit 历史(仍然不拉 blob)。" }
1363
+ @{ Id = "verify"; Kind = "bool"; Label = "哈希校验"; Value = ""; Flag = [bool]$St.Verify; Guide = "续传时对已有文件做 hash-object 校验,哈希不一致则重新下载。Space 开关。" }
1364
+ @{ Id = "force"; Kind = "bool"; Label = "强制 refetch"; Value = ""; Flag = [bool]$St.ForceRefetch; Guide = "强制重新 fetch 目标 ref。换分支或更新到最新 commit 时打开。Space 开关。" }
1365
+ @{ Id = "dry"; Kind = "bool"; Label = "DryRun"; Value = ""; Flag = [bool]$St.DryRun; Guide = "只列出将要处理的文件,不下载 blob。适合先看清单。Space 开关。" }
1366
+ @{ Id = "start"; Kind = "start"; Label = "开始克隆"; Value = ""; Flag = $false; Guide = "按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。" }
1367
+ )
1368
+ }
1369
+
1370
+ function Apply-GcrRecentToWizard {
1371
+ param($St, $Item)
1372
+ if ($null -eq $Item) { return }
1373
+ if ($Item.url) { $St.Url = [string]$Item.url }
1374
+ if ($Item.outDir) { $St.OutDir = [string]$Item.outDir; $St.OutDirAuto = $false }
1375
+ if ($Item.ref) { $St.Ref = [string]$Item.ref }
1376
+ $St.Focus = "form"
1377
+ $St.Sel = 11
1378
+ }
1379
+
1380
+ function Show-GcrTuiWizard {
1381
+ param([hashtable]$Defaults)
1382
+ if (-not (Test-GcrTuiActive)) {
1383
+ if (-not (Initialize-GcrTui)) { return $null }
1384
+ }
1385
+ $script:GcrTui.Screen = "wizard"
1386
+ $script:GcrTui.Phase = "wizard"
1387
+ $url = ""
1388
+ if ($Defaults -and $Defaults.ContainsKey("RepoUrl")) { $url = [string]$Defaults.RepoUrl }
1389
+ if (-not $url) {
1390
+ $clip = Get-GcrClipboardText
1391
+ if (Test-GcrRepoUrlText $clip) { $url = $clip }
1392
+ }
1393
+ $dir = ""
1394
+ $dirAuto = $true
1395
+ if ($Defaults -and $Defaults.ContainsKey("OutDir") -and $Defaults.OutDir) {
1396
+ $dir = [string]$Defaults.OutDir
1397
+ $dirAuto = $false
1398
+ }
1399
+ $ref = "HEAD"
1400
+ if ($Defaults -and $Defaults.ContainsKey("Ref") -and $Defaults.Ref) { $ref = [string]$Defaults.Ref }
1401
+ $batch = 32
1402
+ if ($Defaults -and $Defaults.ContainsKey("BatchSize") -and $Defaults.BatchSize) { $batch = [int]$Defaults.BatchSize }
1403
+ $retries = 8
1404
+ if ($Defaults -and $Defaults.ContainsKey("MaxRetries") -and $Defaults.MaxRetries) { $retries = [int]$Defaults.MaxRetries }
1405
+ $st = @{
1406
+ Url = $url
1407
+ OutDir = $dir
1408
+ OutDirAuto = $dirAuto
1409
+ Ref = $ref
1410
+ BatchSize = $batch
1411
+ MaxRetries = $retries
1412
+ Include = ""
1413
+ Exclude = ""
1414
+ Depth = ""
1415
+ Verify = $false
1416
+ ForceRefetch = $false
1417
+ DryRun = $false
1418
+ Sel = 0
1419
+ RecentSel = 0
1420
+ Focus = "form"
1421
+ Edit = $false
1422
+ EditBuf = ""
1423
+ EditCur = 0
1424
+ EditField = ""
1425
+ Recent = @(Get-GcrHistory)
1426
+ Scroll = 0
1427
+ ConfirmQuit = $false
1428
+ Error = ""
1429
+ Help = $false
1430
+ }
1431
+ if ($Defaults -and $Defaults.ContainsKey("Verify")) { $st.Verify = [bool]$Defaults.Verify }
1432
+ if ($Defaults -and $Defaults.ContainsKey("ForceRefetch")) { $st.ForceRefetch = [bool]$Defaults.ForceRefetch }
1433
+ if ($Defaults -and $Defaults.ContainsKey("DryRun")) { $st.DryRun = [bool]$Defaults.DryRun }
1434
+ if ($Defaults -and $Defaults.ContainsKey("Include") -and $Defaults.Include) {
1435
+ $st.Include = (@($Defaults.Include) -join ",")
1436
+ }
1437
+ if ($Defaults -and $Defaults.ContainsKey("Exclude") -and $Defaults.Exclude) {
1438
+ $st.Exclude = (@($Defaults.Exclude) -join ",")
1439
+ }
1440
+ if ($Defaults -and $Defaults.ContainsKey("Depth") -and $Defaults.Depth) {
1441
+ $st.Depth = [string]$Defaults.Depth
1442
+ }
1443
+
1444
+ $batches = @(8, 16, 32, 64, 128, 256)
1445
+ $retriesSet = @(3, 5, 8, 12, 20)
1446
+ $dirty = $true
1447
+
1448
+ while ($true) {
1449
+ if ($st.OutDirAuto -and $st.Url) {
1450
+ $st.OutDir = Get-GcrFolderNameSafe -Url $st.Url
1451
+ }
1452
+ $size = Get-GcrTuiSize
1453
+ if ($size.W -ne $script:GcrTui.Width -or $size.H -ne $script:GcrTui.Height) {
1454
+ $script:GcrTui.Width = $size.W
1455
+ $script:GcrTui.Height = $size.H
1456
+ $script:GcrTui.LastFrame = @()
1457
+ $dirty = $true
1458
+ }
1459
+ if ($dirty) {
1460
+ Render-GcrTuiWizard -St $st
1461
+ $dirty = $false
1462
+ }
1463
+ $k = Read-GcrTuiKey -TimeoutMs 250
1464
+ if ($null -eq $k) { continue }
1465
+ $dirty = $true
1466
+
1467
+ if ($st.ConfirmQuit) {
1468
+ if ($k.Key -eq "Enter" -or $k.Key -eq "Y" -or $k.Key -eq "Q") { return $null }
1469
+ $st.ConfirmQuit = $false
1470
+ continue
1471
+ }
1472
+ if ($st.Edit) {
1473
+ $buf = [string]$st.EditBuf
1474
+ $cur = [int]$st.EditCur
1475
+ if ($cur -lt 0) { $cur = 0 }
1476
+ if ($cur -gt $buf.Length) { $cur = $buf.Length }
1477
+ if ($k.Key -eq "Escape") { $st.Edit = $false; continue }
1478
+ if ($k.Key -eq "Enter") {
1479
+ $val = $buf
1480
+ switch ($st.EditField) {
1481
+ "url" { $st.Url = $val.Trim() }
1482
+ "dir" {
1483
+ $st.OutDir = $val.Trim()
1484
+ $st.OutDirAuto = [string]::IsNullOrWhiteSpace($st.OutDir)
1485
+ }
1486
+ "ref" { $st.Ref = $(if ($val.Trim()) { $val.Trim() } else { "HEAD" }) }
1487
+ "include" { $st.Include = $val.Trim() }
1488
+ "exclude" { $st.Exclude = $val.Trim() }
1489
+ "depth" { $st.Depth = $val.Trim() }
1490
+ }
1491
+ $st.Edit = $false
1492
+ continue
1493
+ }
1494
+ if (Test-GcrCtrlKey $k "V") {
1495
+ $paste = Get-GcrClipboardText
1496
+ if ($paste) {
1497
+ $paste = ($paste -split "[\r\n]")[0]
1498
+ $buf = $buf.Substring(0, $cur) + $paste + $buf.Substring($cur)
1499
+ $cur = $cur + $paste.Length
1500
+ }
1501
+ } elseif ($k.Key -eq "LeftArrow") {
1502
+ if ($cur -gt 0) { $cur-- }
1503
+ } elseif ($k.Key -eq "RightArrow") {
1504
+ if ($cur -lt $buf.Length) { $cur++ }
1505
+ } elseif ($k.Key -eq "Home") { $cur = 0 }
1506
+ elseif ($k.Key -eq "End") { $cur = $buf.Length }
1507
+ elseif ($k.Key -eq "Backspace") {
1508
+ if ($cur -gt 0) { $buf = $buf.Remove($cur - 1, 1); $cur-- }
1509
+ } elseif ($k.Key -eq "Delete") {
1510
+ if ($cur -lt $buf.Length) { $buf = $buf.Remove($cur, 1) }
1511
+ } elseif (-not [string]::IsNullOrEmpty([string]$k.KeyChar) -and [int][char]$k.KeyChar -ge 32) {
1512
+ $buf = $buf.Insert($cur, [string]$k.KeyChar)
1513
+ $cur++
1514
+ }
1515
+ $st.EditBuf = $buf
1516
+ $st.EditCur = $cur
1517
+ continue
1518
+ }
1519
+
1520
+ if (Test-GcrCtrlKey $k "C") { $st.ConfirmQuit = $true; continue }
1521
+ if (Test-GcrCtrlKey $k "V" -and $st.Focus -eq "form") {
1522
+ $paste = Get-GcrClipboardText
1523
+ if (Test-GcrRepoUrlText $paste) {
1524
+ $st.Url = $paste
1525
+ if ($st.OutDirAuto) { $st.OutDir = Get-GcrFolderNameSafe -Url $paste }
1526
+ }
1527
+ continue
1528
+ }
1529
+
1530
+ switch ($k.Key.ToString()) {
1531
+ "Q" { $st.ConfirmQuit = $true }
1532
+ "Escape" { $st.ConfirmQuit = $true }
1533
+ "Tab" {
1534
+ if ($st.Focus -eq "form") { $st.Focus = "recent"; if ($st.Recent.Count -eq 0) { $st.Focus = "form" } }
1535
+ else { $st.Focus = "form" }
1536
+ }
1537
+ "UpArrow" {
1538
+ if ($st.Focus -eq "recent") {
1539
+ if ($st.RecentSel -gt 0) { $st.RecentSel-- }
1540
+ else { $st.Focus = "form"; $st.Sel = 11 }
1541
+ } else {
1542
+ if ($st.Sel -gt 0) { $st.Sel-- }
1543
+ }
1544
+ }
1545
+ "DownArrow" {
1546
+ if ($st.Focus -eq "recent") {
1547
+ if ($st.RecentSel -lt ($st.Recent.Count - 1)) { $st.RecentSel++ }
1548
+ } else {
1549
+ if ($st.Sel -lt 11) { $st.Sel++ }
1550
+ elseif ($st.Recent.Count -gt 0) { $st.Focus = "recent"; $st.RecentSel = 0 }
1551
+ }
1552
+ }
1553
+ "K" {
1554
+ if ($k.KeyChar -eq "k") {
1555
+ if ($st.Focus -eq "form" -and $st.Sel -gt 0) { $st.Sel-- }
1556
+ }
1557
+ }
1558
+ "J" {
1559
+ if ($k.KeyChar -eq "j") {
1560
+ if ($st.Focus -eq "form" -and $st.Sel -lt 11) { $st.Sel++ }
1561
+ }
1562
+ }
1563
+ "LeftArrow" {
1564
+ if ($st.Focus -eq "form" -and $st.Sel -eq 3) {
1565
+ $idx = [array]::IndexOf($batches, [int]$st.BatchSize)
1566
+ if ($idx -lt 0) { $idx = 2 }
1567
+ if ($idx -gt 0) { $st.BatchSize = $batches[$idx - 1] }
1568
+ }
1569
+ if ($st.Focus -eq "form" -and $st.Sel -eq 4) {
1570
+ $idx = [array]::IndexOf($retriesSet, [int]$st.MaxRetries)
1571
+ if ($idx -lt 0) { $idx = 2 }
1572
+ if ($idx -gt 0) { $st.MaxRetries = $retriesSet[$idx - 1] }
1573
+ }
1574
+ }
1575
+ "RightArrow" {
1576
+ if ($st.Focus -eq "form" -and $st.Sel -eq 3) {
1577
+ $idx = [array]::IndexOf($batches, [int]$st.BatchSize)
1578
+ if ($idx -lt 0) { $idx = 2 }
1579
+ if ($idx -lt $batches.Count - 1) { $st.BatchSize = $batches[$idx + 1] }
1580
+ }
1581
+ if ($st.Focus -eq "form" -and $st.Sel -eq 4) {
1582
+ $idx = [array]::IndexOf($retriesSet, [int]$st.MaxRetries)
1583
+ if ($idx -lt 0) { $idx = 2 }
1584
+ if ($idx -lt $retriesSet.Count - 1) { $st.MaxRetries = $retriesSet[$idx + 1] }
1585
+ }
1586
+ }
1587
+ "Spacebar" {
1588
+ if ($st.Focus -eq "form") {
1589
+ if ($st.Sel -eq 8) { $st.Verify = -not $st.Verify }
1590
+ elseif ($st.Sel -eq 9) { $st.ForceRefetch = -not $st.ForceRefetch }
1591
+ elseif ($st.Sel -eq 10) { $st.DryRun = -not $st.DryRun }
1592
+ }
1593
+ }
1594
+ "Enter" {
1595
+ if ($st.Focus -eq "recent") {
1596
+ if ($st.Recent.Count -gt 0) { Apply-GcrRecentToWizard -St $st -Item $st.Recent[$st.RecentSel] }
1597
+ break
1598
+ }
1599
+ $wizItems = @(Get-GcrWizardItems -St $st)
1600
+ if ($st.Sel -lt 0 -or $st.Sel -ge $wizItems.Count) { break }
1601
+ $id = [string]$wizItems[$st.Sel].Id
1602
+ if ($id -eq "start") {
1603
+ if ([string]::IsNullOrWhiteSpace($st.Url) -or $st.Url -match "\s") {
1604
+ $st.Error = "请填写仓库 URL(Ctrl+V 可从剪贴板粘贴)"
1605
+ $st.Sel = 0
1606
+ } else {
1607
+ try {
1608
+ $result = Convert-GcrWizardResult -St $st
1609
+ $script:GcrTui.Screen = "dash"
1610
+ $script:GcrTui.Logs.Clear()
1611
+ return ,$result
1612
+ } catch {
1613
+ $st.Error = "无法开始: " + $_.Exception.Message
1614
+ $script:GcrTui.Screen = "wizard"
1615
+ }
1616
+ }
1617
+ } elseif ($id -eq "verify") { $st.Verify = -not $st.Verify }
1618
+ elseif ($id -eq "force") { $st.ForceRefetch = -not $st.ForceRefetch }
1619
+ elseif ($id -eq "dry") { $st.DryRun = -not $st.DryRun }
1620
+ elseif ($id -eq "batch" -or $id -eq "retry") { }
1621
+ else {
1622
+ $st.Edit = $true
1623
+ $st.EditField = $id
1624
+ $map = @{
1625
+ url = $st.Url
1626
+ dir = $st.OutDir
1627
+ ref = $st.Ref
1628
+ include = $st.Include
1629
+ exclude = $st.Exclude
1630
+ depth = $st.Depth
1631
+ }
1632
+ $st.EditBuf = [string]$map[$id]
1633
+ $st.EditCur = $st.EditBuf.Length
1634
+ }
1635
+ }
1636
+ "S" {
1637
+ if (-not [string]::IsNullOrWhiteSpace($st.Url)) {
1638
+ try {
1639
+ $result = Convert-GcrWizardResult -St $st
1640
+ $script:GcrTui.Screen = "dash"
1641
+ $script:GcrTui.Logs.Clear()
1642
+ return ,$result
1643
+ } catch {
1644
+ $st.Error = "无法开始: " + $_.Exception.Message
1645
+ $script:GcrTui.Screen = "wizard"
1646
+ }
1647
+ }
1648
+ }
1649
+ default {
1650
+ if ($k.KeyChar -eq "?") { $st.Error = "Enter 编辑 · Space 开关 · Tab 最近任务 · S 开始 · Q 退出" }
1651
+ }
1652
+ }
1653
+ if ($k.Key -ne "Enter") { $st.Error = "" }
1654
+ }
1655
+ return $null
1656
+ }
1657
+
1658
+ function Show-GcrCliWizard {
1659
+ param([hashtable]$Defaults)
1660
+ Write-Host ""
1661
+ Write-Host "git-clone-resume 交互设置" -ForegroundColor Cyan
1662
+ Write-Host "直接回车使用括号里的默认值。空 URL 则退出。" -ForegroundColor DarkGray
1663
+ Write-Host ""
1664
+ $pre = ""
1665
+ if ($Defaults -and $Defaults.ContainsKey("RepoUrl")) { $pre = [string]$Defaults.RepoUrl }
1666
+ if (-not $pre) {
1667
+ $clip = Get-GcrClipboardText
1668
+ if (Test-GcrRepoUrlText $clip) { $pre = $clip }
1669
+ }
1670
+ $prompt = "仓库 URL"
1671
+ if ($pre) { $prompt = "仓库 URL [$pre]" }
1672
+ $url = Read-Host $prompt
1673
+ if ([string]::IsNullOrWhiteSpace($url)) { $url = $pre }
1674
+ if ([string]::IsNullOrWhiteSpace($url)) { return $null }
1675
+ $defDir = Get-GcrFolderNameSafe -Url $url
1676
+ if ($Defaults -and $Defaults.ContainsKey("OutDir") -and $Defaults.OutDir) { $defDir = [string]$Defaults.OutDir }
1677
+ $dir = Read-Host "本地目录 [$defDir]"
1678
+ if ([string]::IsNullOrWhiteSpace($dir)) { $dir = $defDir }
1679
+ $defRef = "HEAD"
1680
+ if ($Defaults -and $Defaults.ContainsKey("Ref") -and $Defaults.Ref) { $defRef = [string]$Defaults.Ref }
1681
+ $ref = Read-Host "分支/标签/commit [$defRef]"
1682
+ if ([string]::IsNullOrWhiteSpace($ref)) { $ref = $defRef }
1683
+ $batch = 32
1684
+ if ($Defaults -and $Defaults.ContainsKey("BatchSize") -and $Defaults.BatchSize) { $batch = [int]$Defaults.BatchSize }
1685
+ $batchText = Read-Host "每批文件数 [$batch]"
1686
+ if ($batchText -match "^\d+$") { $batch = [int]$batchText }
1687
+ return @{
1688
+ RepoUrl = $url.Trim()
1689
+ OutDir = $dir
1690
+ Ref = $ref
1691
+ BatchSize = $batch
1692
+ MaxRetries = 8
1693
+ Include = @()
1694
+ Exclude = @()
1695
+ Depth = $null
1696
+ Verify = $false
1697
+ ForceRefetch = $false
1698
+ DryRun = $false
1699
+ }
1700
+ }
1701
+
1702
+ function Show-GcrInteractiveSetup {
1703
+ param([hashtable]$Defaults)
1704
+ $raw = $null
1705
+ if (Test-GcrTuiAvailable) {
1706
+ if (Initialize-GcrTui) {
1707
+ $raw = Show-GcrTuiWizard -Defaults $Defaults
1708
+ return ,(ConvertFrom-GcrWizardOutput $raw)
1709
+ }
1710
+ }
1711
+ $raw = Show-GcrCliWizard -Defaults $Defaults
1712
+ return ,(ConvertFrom-GcrWizardOutput $raw)
1713
+ }
1714
+
1715
+ function Write-GcrNewline {
1716
+ if (Test-GcrTuiActive) { return }
1717
+ Write-Host ""
1718
+ }