@weniatall/dsh-file-viewer 0.2.11 → 0.3.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.
package/lib/core.js CHANGED
@@ -1295,4 +1295,617 @@ export function sanitizeDocHtml(html) {
1295
1295
  .trim()
1296
1296
  }
1297
1297
 
1298
+ /* -------------------------------------------------------------------------- *
1299
+ * Git panel
1300
+ *
1301
+ * Everything the panel shows is derived from `git` output, which is only a
1302
+ * stable machine-readable format when it is asked for explicitly: the defaults
1303
+ * are colourised, paged, localized, and quote-escaped. The helpers below parse
1304
+ * the unambiguous shapes the host requests — `--format` with unit/record
1305
+ * separators, and `-z` name-status — and never try to re-parse human-facing
1306
+ * text. Keeping them here means the whole panel decision surface is testable
1307
+ * without spawning a process or owning a repository.
1308
+ * -------------------------------------------------------------------------- */
1309
+
1310
+ /** Commits requested per page, the size the panel opens with. */
1311
+ export const GIT_COMMIT_PAGE_SIZE = 30
1312
+
1313
+ /** Hard ceiling for one page, so a forged request cannot pull all of history. */
1314
+ export const GIT_COMMIT_PAGE_MAX = 100
1315
+
1316
+ /**
1317
+ * Subdirectories probed for nested repositories.
1318
+ *
1319
+ * A directory that holds many checkouts (a workspace of projects) has to be
1320
+ * scanned one level deep, but the scan costs one `git` process per candidate,
1321
+ * so it is bounded and the obvious dependency directories are skipped.
1322
+ */
1323
+ export const GIT_REPO_SCAN_LIMIT = 32
1324
+
1325
+ /** Directories that never hold a checkout worth listing. */
1326
+ const REPO_SCAN_SKIP = Object.freeze(['node_modules', 'bower_components', '__pycache__'])
1327
+
1328
+ /**
1329
+ * Normalize a path for comparison: forward slashes, no trailing separator.
1330
+ *
1331
+ * `git` prints POSIX separators on every platform, while a host root may
1332
+ * arrive with backslashes, so anything compared against git output goes
1333
+ * through here first.
1334
+ * @param {unknown} value - a path fragment.
1335
+ * @returns {string} the normalized path, or '' when nothing remains.
1336
+ */
1337
+ export function normalizeGitPath(value) {
1338
+ if (typeof value !== 'string') return ''
1339
+ const trimmed = value.replace(/\\/g, '/').replace(/\/+$/, '')
1340
+ return trimmed === '.' ? '' : trimmed
1341
+ }
1342
+
1343
+ /**
1344
+ * Whether `child` sits at or below `parent`, as a relative path.
1345
+ *
1346
+ * Windows resolves the same directory under either case, so the comparison
1347
+ * folds case there and stays exact everywhere else.
1348
+ * @param {string} parent - absolute directory path.
1349
+ * @param {string} child - absolute path to test.
1350
+ * @returns {string | null} '' when equal, the relative path when nested, null otherwise.
1351
+ */
1352
+ export function relativePathWithin(parent, child) {
1353
+ const base = normalizeGitPath(parent)
1354
+ const target = normalizeGitPath(child)
1355
+ if (base === '' || target === '') return null
1356
+ const fold = (value) => (process.platform === 'win32' ? value.toLowerCase() : value)
1357
+ if (fold(base) === fold(target)) return ''
1358
+ if (!fold(target).startsWith(fold(base) + '/')) return null
1359
+ return target.slice(base.length + 1)
1360
+ }
1361
+
1362
+ /**
1363
+ * Whether a name is a legitimate branch (or revision) name.
1364
+ *
1365
+ * This is `git check-ref-format`'s rule set, restated so the host can refuse a
1366
+ * bad name before it reaches git. The leading-dash rule matters most: the host
1367
+ * passes refs as argv entries, so `-D` or `--exec=…` would otherwise be parsed
1368
+ * as an option rather than a branch.
1369
+ * @param {unknown} name - a candidate branch name.
1370
+ * @returns {boolean} true when the name is safe to pass to git as a ref.
1371
+ */
1372
+ export function isSafeBranchName(name) {
1373
+ if (typeof name !== 'string') return false
1374
+ if (name.length === 0 || name.length > 255) return false
1375
+ if (name.startsWith('-')) return false
1376
+ // Control characters, spaces and the glob/revision metacharacters git forbids.
1377
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(name)) return false
1378
+ if (name.includes('..') || name.includes('@{') || name.includes('//')) return false
1379
+ if (name.endsWith('/') || name.endsWith('.')) return false
1380
+ for (const part of name.split('/')) {
1381
+ if (part === '' || part.startsWith('.') || part.endsWith('.lock')) return false
1382
+ }
1383
+ return true
1384
+ }
1385
+
1386
+ /**
1387
+ * Whether a value is a concrete commit hash rather than a revision expression.
1388
+ * @param {unknown} value - a candidate hash.
1389
+ * @returns {boolean} true for 7–40 lowercase hex characters.
1390
+ */
1391
+ export function isSafeCommitSha(value) {
1392
+ return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value)
1393
+ }
1394
+
1395
+ /**
1396
+ * Clamp a requested commit page onto the supported window.
1397
+ * @param {{offset?: unknown, limit?: unknown}} [payload] - the client request.
1398
+ * @returns {{offset: number, limit: number}} the resolved page.
1399
+ */
1400
+ export function normalizeCommitPage(payload) {
1401
+ const rawLimit = Number(payload?.limit)
1402
+ const rawOffset = Number(payload?.offset)
1403
+ const limit = Number.isSafeInteger(rawLimit) && rawLimit > 0
1404
+ ? Math.min(rawLimit, GIT_COMMIT_PAGE_MAX)
1405
+ : GIT_COMMIT_PAGE_SIZE
1406
+ const offset = Number.isSafeInteger(rawOffset) && rawOffset > 0 ? Math.min(rawOffset, 1_000_000) : 0
1407
+ return { offset, limit }
1408
+ }
1409
+
1410
+ /**
1411
+ * Whether a subdirectory is worth probing for its own checkout.
1412
+ * @param {unknown} name - a directory entry name.
1413
+ * @returns {boolean} true when the scan should try it.
1414
+ */
1415
+ export function isCandidateRepoDirectory(name) {
1416
+ if (typeof name !== 'string' || name === '') return false
1417
+ if (name.startsWith('.') || name.startsWith('$')) return false
1418
+ return !REPO_SCAN_SKIP.includes(name)
1419
+ }
1420
+
1421
+ /**
1422
+ * Parse `git branch --format=%(HEAD)%09%(refname:short)…` output.
1423
+ *
1424
+ * The host asks for tab-separated fields precisely so a branch name (which git
1425
+ * forbids from containing whitespace) cannot be confused with the `*` marker.
1426
+ * @param {string} stdout - the format output.
1427
+ * @returns {{name: string, current: boolean, sha: string, date: string, upstream: string}[]} branches.
1428
+ */
1429
+ export function parseGitBranches(stdout) {
1430
+ if (typeof stdout !== 'string' || stdout.trim() === '') return []
1431
+ const rows = []
1432
+ for (const rawLine of stdout.split('\n')) {
1433
+ if (rawLine.trim() === '') continue
1434
+ const fields = rawLine.split('\t')
1435
+ if (fields.length < 2) continue
1436
+ const name = fields[1].trim()
1437
+ if (name === '') continue
1438
+ rows.push({
1439
+ name,
1440
+ current: fields[0].trim() === '*',
1441
+ sha: (fields[2] || '').trim(),
1442
+ date: (fields[3] || '').trim(),
1443
+ upstream: (fields[4] || '').trim(),
1444
+ })
1445
+ }
1446
+ return rows
1447
+ }
1298
1448
 
1449
+ /**
1450
+ * Parse one `%x1f`-separated commit record into its fields.
1451
+ *
1452
+ * The two formats the host asks for differ by exactly one trailing field — the
1453
+ * list format ends at the subject, the detail format appends the raw message —
1454
+ * so the shape is a parameter rather than a guess. Guessing from the field
1455
+ * count would misread a subject that happens to contain the separator.
1456
+ *
1457
+ * @param {string} record - one record, already split on `%x1e`.
1458
+ * @param {boolean} withBody - whether a `%B` field follows the subject.
1459
+ * @returns {{commit: object, body: string} | null} the parsed commit.
1460
+ */
1461
+ function parseCommitRecord(record, withBody) {
1462
+ const text = record.replace(/^\r?\n+/, '').replace(/\s+$/, '')
1463
+ if (text === '') return null
1464
+ const fields = text.split('\x1f')
1465
+ const sha = (fields[0] || '').trim()
1466
+ if (!isSafeCommitSha(sha)) return null
1467
+ // List format: everything after the fixed fields is the (single-line)
1468
+ // subject, separators included. Detail format: field 6 is the subject and
1469
+ // field 7 onward is the raw message, whose newlines are content.
1470
+ const subject = withBody ? (fields[6] || '').trim() : fields.slice(6).join('\x1f').trim()
1471
+ return {
1472
+ commit: {
1473
+ sha,
1474
+ shortSha: (fields[1] || '').trim() || sha.slice(0, 7),
1475
+ author: fields[2] || '',
1476
+ authorEmail: fields[3] || '',
1477
+ authoredAt: (fields[4] || '').trim(),
1478
+ committedAt: (fields[5] || '').trim(),
1479
+ subject,
1480
+ },
1481
+ body: withBody ? fields.slice(7).join('\x1f').trim() : '',
1482
+ }
1483
+ }
1484
+
1485
+ /**
1486
+ * Parse `git log --format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s%x1e`.
1487
+ *
1488
+ * `%x1e` terminates each record and git appends a newline after it, so each
1489
+ * chunk is trimmed before the fields are read.
1490
+ * @param {string} stdout - the log output.
1491
+ * @returns {object[]} commits in the order git printed them.
1492
+ */
1493
+ export function parseGitLog(stdout) {
1494
+ if (typeof stdout !== 'string' || stdout.trim() === '') return []
1495
+ const commits = []
1496
+ for (const record of stdout.split('\x1e')) {
1497
+ const parsed = parseCommitRecord(record, false)
1498
+ if (parsed !== null) commits.push(parsed.commit)
1499
+ }
1500
+ return commits
1501
+ }
1502
+
1503
+ /**
1504
+ * Parse one commit header plus its message body.
1505
+ * @param {string} stdout - `git log -1 --format=…%x1f%B` output.
1506
+ * @returns {{commit: object, body: string} | null} the commit, or null when unparsable.
1507
+ */
1508
+ export function parseGitCommitDetail(stdout) {
1509
+ if (typeof stdout !== 'string') return null
1510
+ for (const record of stdout.split('\x1e')) {
1511
+ const parsed = parseCommitRecord(record, true)
1512
+ if (parsed !== null) return parsed
1513
+ }
1514
+ return null
1515
+ }
1516
+
1517
+ /**
1518
+ * Parse `git diff-tree -r -z --name-status` output.
1519
+ *
1520
+ * With `-z` every field is NUL-terminated and nothing is quoted, so a renamed
1521
+ * or non-ASCII path survives intact. A rename/copy status is followed by two
1522
+ * paths; every other status by one.
1523
+ * @param {string} stdout - the NUL-separated output.
1524
+ * @returns {{status: string, oldPath: string | null, path: string}[]} the changes.
1525
+ */
1526
+ export function parseGitNameStatus(stdout) {
1527
+ if (typeof stdout !== 'string' || stdout === '') return []
1528
+ const tokens = stdout.split('\0')
1529
+ const changes = []
1530
+ let index = 0
1531
+ while (index < tokens.length) {
1532
+ const status = (tokens[index] || '').trim()
1533
+ index += 1
1534
+ if (!/^[A-Z]\d*$/.test(status)) continue
1535
+ const first = tokens[index] ?? ''
1536
+ index += 1
1537
+ const letter = status.charAt(0)
1538
+ if (letter === 'R' || letter === 'C') {
1539
+ const to = tokens[index] ?? ''
1540
+ index += 1
1541
+ changes.push({ status, oldPath: trimGitPath(first), path: trimGitPath(to) })
1542
+ } else {
1543
+ changes.push({ status, oldPath: null, path: trimGitPath(first) })
1544
+ }
1545
+ }
1546
+ return changes
1547
+ }
1548
+
1549
+ /**
1550
+ * Strip git's record newline from a path field.
1551
+ *
1552
+ * Not a general trim: a path may legitimately end in a space, and only the
1553
+ * separator git itself adds should be removed.
1554
+ * @param {string} value - a raw `-z` field.
1555
+ * @returns {string} the path.
1556
+ */
1557
+ function trimGitPath(value) {
1558
+ return String(value ?? '').replace(/[\r\n]+$/, '')
1559
+ }
1560
+
1561
+ /**
1562
+ * The display form of one name-status code.
1563
+ * @param {unknown} status - e.g. `A`, `M`, `R100`.
1564
+ * @returns {{letter: string, label: string, tone: 'add'|'mod'|'del'}} the badge.
1565
+ */
1566
+ export function gitChangeOf(status) {
1567
+ const letter = String(status ?? '').charAt(0).toUpperCase()
1568
+ switch (letter) {
1569
+ case 'A': return { letter: 'A', label: '新增', tone: 'add' }
1570
+ case 'D': return { letter: 'D', label: '删除', tone: 'del' }
1571
+ case 'R': return { letter: 'R', label: '重命名', tone: 'mod' }
1572
+ case 'C': return { letter: 'C', label: '复制', tone: 'mod' }
1573
+ case 'T': return { letter: 'T', label: '类型变更', tone: 'mod' }
1574
+ case 'U': return { letter: 'U', label: '冲突', tone: 'del' }
1575
+ case 'M': return { letter: 'M', label: '修改', tone: 'mod' }
1576
+ default: return { letter: letter === '' ? '?' : letter, label: '变更', tone: 'mod' }
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Rebase a repository-relative change path onto the selected root.
1582
+ *
1583
+ * Two placements are possible and both have to be handled, because a root is
1584
+ * either inside a checkout (`rootRelative` names its position in the repo) or
1585
+ * above one or more checkouts (`prefix` names the checkout's position in the
1586
+ * root). Changes that fall outside the root are reported as such rather than
1587
+ * silently dropped: the panel can show them, it just cannot open them.
1588
+ * @param {{prefix?: string, rootRelative?: string}} placement - the root/repository relationship.
1589
+ * @param {string} filePath - repository-relative path from git.
1590
+ * @returns {{path: string, outsideRoot: boolean}} the root-relative path.
1591
+ */
1592
+ export function commitPathWithinRoot(placement, filePath) {
1593
+ const prefix = normalizeGitPath(placement?.prefix)
1594
+ const rootRelative = normalizeGitPath(placement?.rootRelative)
1595
+ const relative = normalizeGitPath(filePath).replace(/^\/+/, '')
1596
+ if (relative === '') return { path: '', outsideRoot: false }
1597
+ if (prefix !== '') return { path: `${prefix}/${relative}`, outsideRoot: false }
1598
+ if (rootRelative === '') return { path: relative, outsideRoot: false }
1599
+ if (relative === rootRelative) return { path: '', outsideRoot: false }
1600
+ if (relative.startsWith(rootRelative + '/')) {
1601
+ return { path: relative.slice(rootRelative.length + 1), outsideRoot: false }
1602
+ }
1603
+ return { path: relative, outsideRoot: true }
1604
+ }
1605
+
1606
+ /**
1607
+ * Translate a root-relative path back to the repository-relative path git wants.
1608
+ *
1609
+ * The inverse of {@link commitPathWithinRoot}, and the guard that keeps a
1610
+ * history read inside the root: a path that does not actually sit under the
1611
+ * chosen repository has no repository-relative form and is reported as `null`
1612
+ * rather than guessed at.
1613
+ *
1614
+ * @param {{prefix?: string, rootRelative?: string}} placement - the root/repository relationship.
1615
+ * @param {string} filePath - root-relative path from the client.
1616
+ * @returns {string | null} the repository-relative path, or null when it falls outside.
1617
+ */
1618
+ export function repoRelativeFromRoot(placement, filePath) {
1619
+ const prefix = normalizeGitPath(placement?.prefix)
1620
+ const rootRelative = normalizeGitPath(placement?.rootRelative)
1621
+ const relative = normalizeGitPath(filePath).replace(/^\/+/, '')
1622
+ if (relative === '') return null
1623
+ // A repository below the root: the root path must start with the
1624
+ // repository's own place inside it, and the remainder is the git path.
1625
+ if (prefix !== '') {
1626
+ if (relative === prefix) return null
1627
+ if (!relative.startsWith(prefix + '/')) return null
1628
+ const rest = relative.slice(prefix.length + 1)
1629
+ return rest === '' ? null : rest
1630
+ }
1631
+ // The root sits inside the repository: everything the client names is
1632
+ // already relative to the root, so the root's own position is re-prepended.
1633
+ if (rootRelative === '') return relative
1634
+ return `${rootRelative}/${relative}`
1635
+ }
1636
+
1637
+
1638
+
1639
+ /* -------------------------------------------------------------------------- *
1640
+ * Remote branches, working-tree changes and commit diffs
1641
+ *
1642
+ * The panel started with local branches and one commit's changed-file list.
1643
+ * The three pieces below extend it: remotes are a separate ref namespace that
1644
+ * must not be offered as something to check out; a working-tree change list has
1645
+ * to keep staged and unstaged state apart because they mean different things;
1646
+ * and a commit diff is one patch covering every file, which needs its own
1647
+ * parser rather than the single-file one.
1648
+ * -------------------------------------------------------------------------- */
1649
+
1650
+ /** How a branch reference relates to the local checkout. */
1651
+ export const BRANCH_KINDS = Object.freeze(['local', 'remote'])
1652
+
1653
+ /**
1654
+ * Parse `git branch -a --format=%(HEAD)%09%(refname)%09%(refname:short)…`.
1655
+ *
1656
+ * Remote refs are reported with their full name so `origin/main` and a local
1657
+ * `origin/main` cannot collide, and the `refs/remotes/…/HEAD` symbolic entry is
1658
+ * dropped: it points at another remote branch and is not something a reader can
1659
+ * browse on its own.
1660
+ *
1661
+ * @param {string} stdout - the format output.
1662
+ * @param {string} [headPrefix] - ref prefix marking a remote-tracking ref.
1663
+ * @returns {{name: string, shortName: string, kind: 'local'|'remote', remote: string, current: boolean, sha: string, date: string, upstream: string}[]} branches.
1664
+ */
1665
+ export function parseGitBranchList(stdout, headPrefix = 'refs/remotes/') {
1666
+ if (typeof stdout !== 'string' || stdout.trim() === '') return []
1667
+ const rows = []
1668
+ for (const rawLine of stdout.split('\n')) {
1669
+ if (rawLine.trim() === '') continue
1670
+ const fields = rawLine.split('\t')
1671
+ if (fields.length < 3) continue
1672
+ const full = fields[1].trim()
1673
+ const shortName = fields[2].trim()
1674
+ if (full === '' || shortName === '') continue
1675
+ const isRemote = full.startsWith(headPrefix)
1676
+ if (isRemote) {
1677
+ // `origin/HEAD` is a pointer to another branch, not a branch of its own.
1678
+ const tail = full.slice(headPrefix.length)
1679
+ if (tail.endsWith('/HEAD')) continue
1680
+ }
1681
+ // The remote's own name is the first path segment of a remote ref; a local
1682
+ // branch may legitimately contain a slash, so this only applies remotely.
1683
+ const remote = isRemote ? full.slice(headPrefix.length).split('/')[0] : ''
1684
+ rows.push({
1685
+ name: full,
1686
+ shortName,
1687
+ kind: isRemote ? 'remote' : 'local',
1688
+ remote,
1689
+ current: fields[0].trim() === '*',
1690
+ sha: (fields[3] || '').trim(),
1691
+ date: (fields[4] || '').trim(),
1692
+ upstream: (fields[5] || '').trim(),
1693
+ })
1694
+ }
1695
+ return rows
1696
+ }
1697
+
1698
+ /**
1699
+ * The revision to hand git for a branch the panel selected.
1700
+ *
1701
+ * Remote refs are stored under `refs/remotes/…` and are not on the branch
1702
+ * namespace, so `git log origin/main` and `git log refs/remotes/origin/main`
1703
+ * are not interchangeable: only the fully qualified form is unambiguous when a
1704
+ * local branch shares the short name.
1705
+ * @param {{name?: string, kind?: string}} branch - one entry from discovery.
1706
+ * @returns {string} the revision to read.
1707
+ */
1708
+ export function revisionOfBranch(branch) {
1709
+ const name = typeof branch?.name === 'string' ? branch.name : ''
1710
+ if (name === '') return ''
1711
+ return branch?.kind === 'remote' ? `refs/remotes/${name.replace(/^refs\/remotes\//, '')}` : name
1712
+ }
1713
+
1714
+ /**
1715
+ * Whether a branch is something `git checkout` can switch to.
1716
+ *
1717
+ * A remote-tracking branch is read-only from the reader's point of view:
1718
+ * checking it out silently creates a detached HEAD or a new local branch,
1719
+ * depending on git's configuration. The panel therefore offers it for browsing
1720
+ * only, which is a decision that belongs here rather than in the UI.
1721
+ * @param {{kind?: string}} branch - one entry from discovery.
1722
+ * @returns {boolean} true when a checkout is meaningful.
1723
+ */
1724
+ export function isCheckoutableBranch(branch) {
1725
+ return branch?.kind !== 'remote'
1726
+ }
1727
+
1728
+ /**
1729
+ * Parse `git status --porcelain=v1 -z` into structured changes.
1730
+ *
1731
+ * `-z` is not cosmetic: without it git quotes non-ASCII paths and uses `->` to
1732
+ * separate a rename, which is ambiguous when a filename contains either. With
1733
+ * `-z` each record is NUL-terminated and a rename carries two NUL-separated
1734
+ * paths.
1735
+ *
1736
+ * Staged and unstaged state are kept apart because they answer different
1737
+ * questions ("what would be committed" vs "what is not staged"), and the panel
1738
+ * shows them as two groups the way a source-control view does.
1739
+ *
1740
+ * @param {string} stdout - the NUL-separated porcelain output.
1741
+ * @returns {{path: string, repoPath: string, index: string, worktree: string, staged: boolean, untracked: boolean, ignored: boolean, conflicted: boolean, oldPath: string | null}[]} changes.
1742
+ */
1743
+ export function parseGitStatusEntries(stdout) {
1744
+ if (typeof stdout !== 'string' || stdout === '') return []
1745
+ const tokens = stdout.split('\0')
1746
+ const rows = []
1747
+ let index = 0
1748
+ while (index < tokens.length) {
1749
+ const record = tokens[index]
1750
+ index += 1
1751
+ if (typeof record !== 'string' || record.length < 4) continue
1752
+ const indexCode = record.charAt(0)
1753
+ const worktreeCode = record.charAt(1)
1754
+ const repoPath = record.slice(3)
1755
+ // A rename/copy record carries the original path as the next token.
1756
+ let oldPath = null
1757
+ if (indexCode === 'R' || indexCode === 'C') {
1758
+ const previous = tokens[index] ?? ''
1759
+ index += 1
1760
+ oldPath = previous === '' ? null : previous
1761
+ }
1762
+ if (repoPath === '') continue
1763
+ rows.push({
1764
+ path: repoPath,
1765
+ repoPath,
1766
+ index: indexCode,
1767
+ worktree: worktreeCode,
1768
+ // `X` is the index side, `Y` the working tree. `??` is untracked and `!!`
1769
+ // ignored; neither has a meaningful index state.
1770
+ staged: indexCode !== ' ' && indexCode !== '?' && indexCode !== '!',
1771
+ untracked: indexCode === '?',
1772
+ ignored: indexCode === '!',
1773
+ conflicted: indexCode === 'U' || worktreeCode === 'U'
1774
+ || (indexCode === 'A' && worktreeCode === 'A')
1775
+ || (indexCode === 'D' && worktreeCode === 'D'),
1776
+ oldPath,
1777
+ })
1778
+ }
1779
+ return rows
1780
+ }
1781
+
1782
+ /**
1783
+ * The single-letter badge for one status record.
1784
+ *
1785
+ * A file can be staged and then edited again, which is two facts. The index
1786
+ * side wins because that is what a commit would capture, and the working-tree
1787
+ * side is reported separately so the row can show "staged, then modified".
1788
+ * @param {{index?: string, worktree?: string, untracked?: boolean, conflicted?: boolean}} change - one record.
1789
+ * @returns {{letter: string, label: string, tone: 'add'|'mod'|'del'}} the badge.
1790
+ */
1791
+ export function gitStatusBadge(change) {
1792
+ if (change?.conflicted === true) return { letter: 'U', label: '冲突', tone: 'del' }
1793
+ if (change?.untracked === true) return { letter: '?', label: '未跟踪', tone: 'add' }
1794
+ const code = String(change?.index ?? ' ')
1795
+ const worktree = String(change?.worktree ?? ' ')
1796
+ const pick = code !== ' ' && code !== '?' ? code : worktree
1797
+ switch (pick) {
1798
+ case 'A': return { letter: 'A', label: '新增', tone: 'add' }
1799
+ case 'D': return { letter: 'D', label: '删除', tone: 'del' }
1800
+ case 'R': return { letter: 'R', label: '重命名', tone: 'mod' }
1801
+ case 'C': return { letter: 'C', label: '复制', tone: 'mod' }
1802
+ case 'M': return { letter: 'M', label: '修改', tone: 'mod' }
1803
+ default: return { letter: pick === ' ' ? 'M' : pick, label: '变更', tone: 'mod' }
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * Parse a multi-file unified diff into per-file sections.
1809
+ *
1810
+ * `git show`/`git diff` emit `diff --git a/x b/y` headers followed by one or
1811
+ * more `@@` hunks per file, so a commit's patch has to be split on those
1812
+ * headers before each file's rows are numbered — otherwise every file would
1813
+ * share the previous file's line numbers.
1814
+ *
1815
+ * @param {string} patch - the full patch text.
1816
+ * @returns {{path: string, oldPath: string, status: string, rows: object[], added: number, removed: number}[]} one entry per file.
1817
+ */
1818
+ export function parseUnifiedDiffFiles(patch) {
1819
+ if (typeof patch !== 'string' || patch.trim() === '') return []
1820
+ const files = []
1821
+ let current = null
1822
+ const flush = () => {
1823
+ if (current === null) return
1824
+ const rows = parsePatchToRows(current.hunkText)
1825
+ let added = 0
1826
+ let removed = 0
1827
+ for (const row of rows) {
1828
+ if (row.type === 'add') added += 1
1829
+ else if (row.type === 'del') removed += 1
1830
+ }
1831
+ files.push({
1832
+ path: current.path,
1833
+ oldPath: current.oldPath,
1834
+ status: current.status,
1835
+ rows,
1836
+ added,
1837
+ removed,
1838
+ })
1839
+ current = null
1840
+ }
1841
+
1842
+ for (const line of patch.split('\n')) {
1843
+ if (line.startsWith('diff --git ')) {
1844
+ flush()
1845
+ // `diff --git a/<path> b/<path>` — both sides are quoted only when the
1846
+ // path needs escaping, which `-z` avoids elsewhere but not here.
1847
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
1848
+ current = {
1849
+ path: match ? match[2] : '',
1850
+ oldPath: match ? match[1] : '',
1851
+ status: 'M',
1852
+ hunkText: '',
1853
+ }
1854
+ continue
1855
+ }
1856
+ if (current === null) continue
1857
+ if (line.startsWith('new file mode')) { current.status = 'A'; continue }
1858
+ if (line.startsWith('deleted file mode')) { current.status = 'D'; continue }
1859
+ if (line.startsWith('rename from ')) { current.oldPath = line.slice('rename from '.length); current.status = 'R'; continue }
1860
+ if (line.startsWith('rename to ')) { current.path = line.slice('rename to '.length); current.status = 'R'; continue }
1861
+ // The `index`, `---` and `+++` lines carry no line content, and feeding
1862
+ // them to the hunk parser would misread `--- a/x` as a removed line.
1863
+ if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) continue
1864
+ current.hunkText += line + '\n'
1865
+ }
1866
+ flush()
1867
+ // A section with no hunks is a pure mode/rename change; keep it so the file
1868
+ // still appears in the list, but with no rows to draw.
1869
+ return files
1870
+ }
1871
+
1872
+ /**
1873
+ * Split working-tree changes into the groups a source-control view shows.
1874
+ * @param {object[]} changes - records from {@link parseGitStatusEntries}.
1875
+ * @param {(path: string) => {path: string, outsideRoot: boolean}} rebase - maps a repo path onto the root.
1876
+ * @returns {{staged: object[], unstaged: object[], untracked: object[], conflicted: object[], outside: number}} the groups.
1877
+ */
1878
+ export function groupGitChanges(changes, rebase) {
1879
+ const groups = { staged: [], unstaged: [], untracked: [], conflicted: [], outside: 0 }
1880
+ for (const change of Array.isArray(changes) ? changes : []) {
1881
+ if (change.ignored === true) continue
1882
+ const placement = typeof rebase === 'function'
1883
+ ? rebase(change.repoPath)
1884
+ : { path: change.repoPath, outsideRoot: false }
1885
+ if (placement.outsideRoot === true) {
1886
+ groups.outside += 1
1887
+ continue
1888
+ }
1889
+ const badge = gitStatusBadge(change)
1890
+ const row = {
1891
+ path: placement.path,
1892
+ repoPath: change.repoPath,
1893
+ oldPath: change.oldPath,
1894
+ index: change.index,
1895
+ worktree: change.worktree,
1896
+ letter: badge.letter,
1897
+ change: badge.label,
1898
+ tone: badge.tone,
1899
+ // A file staged and then edited again is two facts; the row keeps both.
1900
+ partiallyStaged: change.staged === true && change.worktree !== ' ' && change.worktree !== '?',
1901
+ untracked: change.untracked === true,
1902
+ }
1903
+ if (change.conflicted === true) groups.conflicted.push(row)
1904
+ else if (change.untracked === true) groups.untracked.push(row)
1905
+ else {
1906
+ if (change.staged === true) groups.staged.push(row)
1907
+ if (change.worktree !== ' ' && change.worktree !== '?' && change.worktree !== '!') groups.unstaged.push(row)
1908
+ }
1909
+ }
1910
+ return groups
1911
+ }