@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/README.md +36 -0
- package/lib/client.js +1535 -123
- package/lib/core.js +613 -0
- package/lib/index.js +953 -1
- package/package.json +2 -2
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
|
+
}
|