✨ feat(vscode): 「改动」tab 重做为整 PR 聚合的主从式内联 diff(git-diff-view)
- 左树右 diff:左栏全 PR 文件树 + 统计条(共改 N · +X−Y · 已查看 M/N),右栏选中单文件内联 diff - 用 @git-diff-view/react 只渲染当前选中文件,绕开上万行全量渲染的性能坑 - gitea 新增 listPullRequestFiles、getPullRequest 取 merge_base/head,按需回两版原文 - diff 走 merge_base→head 三点口径(与 Gitea 网页一致),getRawFile 404 兜底成全增/全删/改名 - 已查看态按 head sha 落盘、新 push 自然重置;去掉提交列表、原生 diff 与 spx-gitea 虚拟文档 provider
This commit is contained in:
Generated
+120
@@ -58,6 +58,12 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: ^3.2.2
|
specifier: ^3.2.2
|
||||||
version: 3.2.2(react@19.2.6)
|
version: 3.2.2(react@19.2.6)
|
||||||
|
'@git-diff-view/file':
|
||||||
|
specifier: ^0.1.6
|
||||||
|
version: 0.1.6
|
||||||
|
'@git-diff-view/react':
|
||||||
|
specifier: ^0.1.6
|
||||||
|
version: 0.1.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -715,6 +721,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@git-diff-view/core@0.1.6':
|
||||||
|
resolution: {integrity: sha512-q2Ch8jURF6pL7VeNpOgHBRVY9gsGLXCOYpKXHG3BqpXe0kv6GNSUux8SmAYsDrakBzfgDClODxDtsM2rfiWpnA==}
|
||||||
|
|
||||||
|
'@git-diff-view/file@0.1.6':
|
||||||
|
resolution: {integrity: sha512-VSsByONBl98c4SVyoN8I1twooEZCh63AbH79tcpvCAzt7nJ5Ulmr1UIS8qAaMDDZiEgXq13JlkZkQh9vpc6xZQ==}
|
||||||
|
|
||||||
|
'@git-diff-view/lowlight@0.1.6':
|
||||||
|
resolution: {integrity: sha512-YIsiAc2aWAePWaDNi3k8xI0Vs/ZItt5J6nrftTIFbMFN3GwDOsyJFm2L7o8XWKTJkV2yItaz28KUI9CWj0MVZA==}
|
||||||
|
|
||||||
|
'@git-diff-view/react@0.1.6':
|
||||||
|
resolution: {integrity: sha512-koABBon5bNKh6/WnWSxggK9ojw+cvWAPnY2/ciOkwlR+8dm0h6A7Qa5kP2HFDxqYHwZ2imkGMcSLgXMOnWHRFA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -1183,6 +1204,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/hast@3.0.4':
|
||||||
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -1387,9 +1411,15 @@ packages:
|
|||||||
'@vue/compiler-ssr@3.4.27':
|
'@vue/compiler-ssr@3.4.27':
|
||||||
resolution: {integrity: sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==}
|
resolution: {integrity: sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==}
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.39':
|
||||||
|
resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==}
|
||||||
|
|
||||||
'@vue/shared@3.4.27':
|
'@vue/shared@3.4.27':
|
||||||
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
|
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
|
||||||
|
|
||||||
|
'@vue/shared@3.5.39':
|
||||||
|
resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1726,6 +1756,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
|
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
diff@8.0.4:
|
||||||
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -2032,6 +2066,9 @@ packages:
|
|||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-diff@1.3.0:
|
||||||
|
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||||
|
|
||||||
fast-glob@3.3.3:
|
fast-glob@3.3.3:
|
||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
@@ -2208,6 +2245,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
|
resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
highlight.js@11.11.1:
|
||||||
|
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
hosted-git-info@4.1.0:
|
hosted-git-info@4.1.0:
|
||||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2510,6 +2551,9 @@ packages:
|
|||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
|
lowlight@3.3.0:
|
||||||
|
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
@@ -2955,6 +2999,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reactivity-store@0.4.0:
|
||||||
|
resolution: {integrity: sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
read-pkg@9.0.1:
|
read-pkg@9.0.1:
|
||||||
resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==}
|
resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3339,6 +3388,11 @@ packages:
|
|||||||
url-join@4.0.1:
|
url-join@4.0.1:
|
||||||
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -4067,6 +4121,39 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@git-diff-view/core@0.1.6':
|
||||||
|
dependencies:
|
||||||
|
'@git-diff-view/lowlight': 0.1.6
|
||||||
|
fast-diff: 1.3.0
|
||||||
|
highlight.js: 11.11.1
|
||||||
|
lowlight: 3.3.0
|
||||||
|
|
||||||
|
'@git-diff-view/file@0.1.6':
|
||||||
|
dependencies:
|
||||||
|
'@git-diff-view/core': 0.1.6
|
||||||
|
diff: 8.0.4
|
||||||
|
fast-diff: 1.3.0
|
||||||
|
highlight.js: 11.11.1
|
||||||
|
lowlight: 3.3.0
|
||||||
|
|
||||||
|
'@git-diff-view/lowlight@0.1.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
highlight.js: 11.11.1
|
||||||
|
lowlight: 3.3.0
|
||||||
|
|
||||||
|
'@git-diff-view/react@0.1.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@git-diff-view/core': 0.1.6
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
fast-diff: 1.3.0
|
||||||
|
highlight.js: 11.11.1
|
||||||
|
lowlight: 3.3.0
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
reactivity-store: 0.4.0(react@19.2.6)
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@@ -4494,6 +4581,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/hast@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
@@ -4780,8 +4871,14 @@ snapshots:
|
|||||||
'@vue/compiler-dom': 3.4.27
|
'@vue/compiler-dom': 3.4.27
|
||||||
'@vue/shared': 3.4.27
|
'@vue/shared': 3.4.27
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.39':
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.5.39
|
||||||
|
|
||||||
'@vue/shared@3.4.27': {}
|
'@vue/shared@3.4.27': {}
|
||||||
|
|
||||||
|
'@vue/shared@3.5.39': {}
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@@ -5113,6 +5210,8 @@ snapshots:
|
|||||||
|
|
||||||
diff-sequences@27.5.1: {}
|
diff-sequences@27.5.1: {}
|
||||||
|
|
||||||
|
diff@8.0.4: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -5532,6 +5631,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-diff@1.3.0: {}
|
||||||
|
|
||||||
fast-glob@3.3.3:
|
fast-glob@3.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -5699,6 +5800,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
hosted-git-info@4.1.0:
|
hosted-git-info@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
@@ -5974,6 +6077,12 @@ snapshots:
|
|||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
|
lowlight@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
devlop: 1.1.0
|
||||||
|
highlight.js: 11.11.1
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@11.0.0: {}
|
lru-cache@11.0.0: {}
|
||||||
@@ -6621,6 +6730,13 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.6: {}
|
react@19.2.6: {}
|
||||||
|
|
||||||
|
reactivity-store@0.4.0(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.39
|
||||||
|
'@vue/shared': 3.5.39
|
||||||
|
react: 19.2.6
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||||
|
|
||||||
read-pkg@9.0.1:
|
read-pkg@9.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/normalize-package-data': 2.4.4
|
'@types/normalize-package-data': 2.4.4
|
||||||
@@ -7022,6 +7138,10 @@ snapshots:
|
|||||||
|
|
||||||
url-join@4.0.1: {}
|
url-join@4.0.1: {}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|||||||
+55
-92
@@ -216,6 +216,12 @@ export interface GiteaPullRequest {
|
|||||||
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
|
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
|
||||||
*/
|
*/
|
||||||
body: string
|
body: string
|
||||||
|
/** head 分支最新提交 sha:PR diff 的右侧 ref。 */
|
||||||
|
headSha: string
|
||||||
|
/** base 分支 sha:mergeBase 取不到时的左侧 ref 兜底。 */
|
||||||
|
baseSha: string
|
||||||
|
/** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */
|
||||||
|
mergeBase: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,6 +248,9 @@ export async function getPullRequest(opts: {
|
|||||||
merged_at?: unknown
|
merged_at?: unknown
|
||||||
html_url?: unknown
|
html_url?: unknown
|
||||||
body?: unknown
|
body?: unknown
|
||||||
|
head?: { sha?: unknown }
|
||||||
|
base?: { sha?: unknown }
|
||||||
|
merge_base?: unknown
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
number: typeof data.number === 'number' ? data.number : Number(data.number),
|
number: typeof data.number === 'number' ? data.number : Number(data.number),
|
||||||
@@ -250,6 +259,9 @@ export async function getPullRequest(opts: {
|
|||||||
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
|
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
|
||||||
html_url: typeof data.html_url === 'string' ? data.html_url : '',
|
html_url: typeof data.html_url === 'string' ? data.html_url : '',
|
||||||
body: typeof data.body === 'string' ? data.body : '',
|
body: typeof data.body === 'string' ? data.body : '',
|
||||||
|
headSha: typeof data.head?.sha === 'string' ? data.head.sha : '',
|
||||||
|
baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '',
|
||||||
|
mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,113 +525,64 @@ export async function deleteWebhook(opts: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PR 提交的精简视图:列表渲染只需 sha / 首行消息 / 作者 / 时间。 */
|
/** PR 改动里的一个文件:path/status 给文件树,additions/deletions 求和成统计条,previousFilename 用于改名时取左侧旧路径。 */
|
||||||
export interface PrCommitDto {
|
export interface PrFileDto {
|
||||||
sha: string
|
|
||||||
message: string
|
|
||||||
authorName: string
|
|
||||||
date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个提交内的一个文件改动:path 给 diff 用,status 决定徽标颜色。 */
|
|
||||||
export interface GitCommitFileDto {
|
|
||||||
path: string
|
path: string
|
||||||
status: string
|
status: string
|
||||||
}
|
additions: number
|
||||||
|
deletions: number
|
||||||
/** 一个提交的详情:parentSha 用作 diff 左侧 ref,files 是改动清单。 */
|
previousFilename?: string
|
||||||
export interface GitCommitDetailDto {
|
|
||||||
sha: string
|
|
||||||
parentSha: string | undefined
|
|
||||||
files: GitCommitFileDto[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出某 PR 的提交。关掉 files/stat/verification 让响应尽量小,列表只用到
|
* 列出某 PR 的全部改动文件(三点 diff,merge_base → head,与 Gitea 网页 diff 口径一致)。
|
||||||
* 顶层字段。message 取首行(完整消息留给 commit 详情页,这里只做概览)。
|
* 分页拉到短页为止;改名文件带 previousFilename(旧路径,取左侧内容用)。
|
||||||
*/
|
*/
|
||||||
export async function listPullRequestCommits(opts: {
|
export async function listPullRequestFiles(opts: {
|
||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
owner: string
|
owner: string
|
||||||
repo: string
|
repo: string
|
||||||
index: number
|
index: number
|
||||||
limit?: number
|
}): Promise<PrFileDto[]> {
|
||||||
}): Promise<PrCommitDto[]> {
|
const out: PrFileDto[] = []
|
||||||
const url = new URL(
|
let page = 1
|
||||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`,
|
// eslint-disable-next-line no-constant-condition
|
||||||
)
|
while (true) {
|
||||||
url.searchParams.set('page', '1')
|
const url = new URL(
|
||||||
url.searchParams.set('limit', String(opts.limit ?? 50))
|
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`,
|
||||||
url.searchParams.set('files', 'false')
|
)
|
||||||
url.searchParams.set('stat', 'false')
|
url.searchParams.set('limit', String(PAGE_SIZE))
|
||||||
url.searchParams.set('verification', 'false')
|
url.searchParams.set('page', String(page))
|
||||||
|
|
||||||
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
|
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
|
||||||
await ensureOk(res)
|
await ensureOk(res)
|
||||||
const data = await res.json() as Array<{
|
const batch = await res.json() as Array<{
|
||||||
sha?: unknown
|
filename?: unknown
|
||||||
created?: unknown
|
previous_filename?: unknown
|
||||||
commit?: { message?: unknown, author?: { name?: unknown, date?: unknown } }
|
status?: unknown
|
||||||
author?: { login?: unknown } | null
|
additions?: unknown
|
||||||
}>
|
deletions?: unknown
|
||||||
if (!Array.isArray(data))
|
}>
|
||||||
return []
|
if (!Array.isArray(batch) || batch.length === 0)
|
||||||
const result = data.map((c) => {
|
break
|
||||||
const fullMessage = typeof c.commit?.message === 'string' ? c.commit.message : ''
|
for (const f of batch) {
|
||||||
const message = fullMessage.split('\n', 1)[0] ?? ''
|
const prev = typeof f.previous_filename === 'string' && f.previous_filename.length > 0
|
||||||
const authorName = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : ''
|
? f.previous_filename
|
||||||
const commitDate = typeof c.commit?.author?.date === 'string' ? c.commit.author.date : ''
|
: undefined
|
||||||
const created = typeof c.created === 'string' ? c.created : ''
|
out.push({
|
||||||
return {
|
|
||||||
sha: typeof c.sha === 'string' ? c.sha : '',
|
|
||||||
message,
|
|
||||||
authorName,
|
|
||||||
date: commitDate || created,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// 按提交时间升序(最早在最前)。ISO 字符串字典序即时间序;date 为空者排前面。
|
|
||||||
result.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取单个提交详情,带文件清单。parentSha(首个父提交)作为 diff 左侧 ref;
|
|
||||||
* 根提交无 parent 时为 undefined,调用方据此把左侧当空内容处理(呈现全新增)。
|
|
||||||
*/
|
|
||||||
export async function getGitCommit(opts: {
|
|
||||||
host: string
|
|
||||||
token: string
|
|
||||||
owner: string
|
|
||||||
repo: string
|
|
||||||
sha: string
|
|
||||||
}): Promise<GitCommitDetailDto> {
|
|
||||||
const url = new URL(
|
|
||||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/git/commits/${opts.sha}`,
|
|
||||||
)
|
|
||||||
url.searchParams.set('files', 'true')
|
|
||||||
url.searchParams.set('stat', 'false')
|
|
||||||
url.searchParams.set('verification', 'false')
|
|
||||||
|
|
||||||
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
|
|
||||||
await ensureOk(res)
|
|
||||||
const data = await res.json() as {
|
|
||||||
sha?: unknown
|
|
||||||
parents?: Array<{ sha?: unknown }>
|
|
||||||
files?: Array<{ filename?: unknown, status?: unknown }>
|
|
||||||
}
|
|
||||||
const parentSha = typeof data.parents?.[0]?.sha === 'string' ? data.parents[0].sha : undefined
|
|
||||||
const files = Array.isArray(data.files)
|
|
||||||
? data.files.map(f => ({
|
|
||||||
path: typeof f.filename === 'string' ? f.filename : '',
|
path: typeof f.filename === 'string' ? f.filename : '',
|
||||||
status: typeof f.status === 'string' ? f.status : '',
|
status: typeof f.status === 'string' ? f.status : '',
|
||||||
}))
|
additions: typeof f.additions === 'number' ? f.additions : 0,
|
||||||
: []
|
deletions: typeof f.deletions === 'number' ? f.deletions : 0,
|
||||||
return {
|
previousFilename: prev,
|
||||||
sha: typeof data.sha === 'string' ? data.sha : opts.sha,
|
})
|
||||||
parentSha,
|
}
|
||||||
files,
|
if (batch.length < PAGE_SIZE)
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
|
|||||||
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
||||||
import * as issues from './handlers/issues'
|
import * as issues from './handlers/issues'
|
||||||
import * as managedSessions from './handlers/managedSessions'
|
import * as managedSessions from './handlers/managedSessions'
|
||||||
import * as prCommits from './handlers/prCommits'
|
import * as prFiles from './handlers/prFiles'
|
||||||
import * as sessions from './handlers/sessions'
|
import * as sessions from './handlers/sessions'
|
||||||
import * as settings from './handlers/settings'
|
import * as settings from './handlers/settings'
|
||||||
import * as terminals from './handlers/terminals'
|
import * as terminals from './handlers/terminals'
|
||||||
@@ -546,30 +546,20 @@ export class KanbanWebviewPanel {
|
|||||||
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
|
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'pr-commits/get') {
|
if (msg.type === 'pr-files/get') {
|
||||||
void prCommits.handleGetPrCommits(this, msg.issueNumber)
|
void prFiles.handleGetPrFiles(this, msg.issueNumber)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'pr-commit-files/get') {
|
if (msg.type === 'pr-file-diff/get') {
|
||||||
void prCommits.handleGetPrCommitFiles(this, msg.issueNumber, msg.sha)
|
void prFiles.handleGetPrFileDiff(this, {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.type === 'pr-commit-diff/open') {
|
|
||||||
void prCommits.handleOpenPrCommitDiff(this, {
|
|
||||||
issueNumber: msg.issueNumber,
|
issueNumber: msg.issueNumber,
|
||||||
sha: msg.sha,
|
|
||||||
parentSha: msg.parentSha,
|
|
||||||
path: msg.path,
|
path: msg.path,
|
||||||
status: msg.status,
|
previousPath: msg.previousPath,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'pr-review/set') {
|
if (msg.type === 'pr-review/set') {
|
||||||
void prCommits.handleSetPrReviewConfirmed(this, msg)
|
void prFiles.handleSetPrReviewConfirmed(this, msg)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.type === 'pr-review/set-commits') {
|
|
||||||
void prCommits.handleSetPrReviewConfirmedCommits(this, msg)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「提交」tab 的扩展侧 handler:实时从 Gitea 读 PR 提交、提交内文件,并用
|
|
||||||
* VS Code 原生 diff 比较改动前后。
|
|
||||||
*
|
|
||||||
* 数据全程实时拉取,不落任何 state JSON。文件内容通过一个
|
|
||||||
* TextDocumentContentProvider(scheme `spx-gitea`)按需取 raw,使 diff 的两侧
|
|
||||||
* 各自指向一个 ref 下的文件。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
|
||||||
import { commands, Uri, window, workspace, type TextDocumentContentProvider } from 'vscode'
|
|
||||||
import { getToken } from '../../auth/secrets'
|
|
||||||
import { detectRepo } from '../../git/remote'
|
|
||||||
import { getGitCommit, getRawFile, listPullRequestCommits } from '../../gitea/api'
|
|
||||||
import { readStateJsonComment } from '../../gitea/stateJson'
|
|
||||||
import { readConfirmed, readConfirmedCommits, writeConfirmed, writeConfirmedCommits } from '../../sessions/prReviewStore'
|
|
||||||
|
|
||||||
/** diff 文件内容走的虚拟文档 scheme。 */
|
|
||||||
const SPX_GITEA_SCHEME = 'spx-gitea'
|
|
||||||
|
|
||||||
/** provider 只需注册一次;模块级守卫避免重复注册同一 scheme 抛错。 */
|
|
||||||
let providerRegistered = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined,
|
|
||||||
* 调用方据此走空态/错误分支。
|
|
||||||
*/
|
|
||||||
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
|
|
||||||
{ host: string, owner: string, repo: string, token: string } | undefined
|
|
||||||
> {
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
if (!workspaceRoot)
|
|
||||||
return undefined
|
|
||||||
const remote = await detectRepo(workspaceRoot)
|
|
||||||
if (!remote)
|
|
||||||
return undefined
|
|
||||||
const token = await getToken(panel.context, remote.host)
|
|
||||||
if (!token)
|
|
||||||
return undefined
|
|
||||||
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取工单对应 PR 的提交列表并推给 webview。无 PR 时回空列表(前端按空态处理,
|
|
||||||
* 不算错误);缺工作区/远程/token 同样回空列表。真出错才带 error。
|
|
||||||
*/
|
|
||||||
export async function handleGetPrCommits(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const ctx = await resolveRepoContext(panel)
|
|
||||||
if (!ctx) {
|
|
||||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateObj = await readStateJsonComment({
|
|
||||||
host: ctx.host,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
token: ctx.token,
|
|
||||||
issueNumber,
|
|
||||||
})
|
|
||||||
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
|
|
||||||
if (!pr) {
|
|
||||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const commits = await listPullRequestCommits({
|
|
||||||
host: ctx.host,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
token: ctx.token,
|
|
||||||
index: Number(pr),
|
|
||||||
})
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
const confirmedCommits = workspaceRoot
|
|
||||||
? await readConfirmedCommits(workspaceRoot, issueNumber)
|
|
||||||
: []
|
|
||||||
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits, confirmedCommits })
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
panel.postMessage({
|
|
||||||
type: 'pr-commits/show',
|
|
||||||
issueNumber,
|
|
||||||
commits: [],
|
|
||||||
confirmedCommits: [],
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取某提交的文件清单并推给 webview。parentSha 一并回传,供前端发起 diff 时
|
|
||||||
* 作为左侧 ref。
|
|
||||||
*/
|
|
||||||
export async function handleGetPrCommitFiles(
|
|
||||||
panel: KanbanWebviewPanel,
|
|
||||||
issueNumber: number,
|
|
||||||
sha: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const ctx = await resolveRepoContext(panel)
|
|
||||||
if (!ctx) {
|
|
||||||
panel.postMessage({ type: 'pr-commit-files/show', issueNumber, sha, files: [], confirmed: [] })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = await getGitCommit({
|
|
||||||
host: ctx.host,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
token: ctx.token,
|
|
||||||
sha,
|
|
||||||
})
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
const confirmed = workspaceRoot
|
|
||||||
? await readConfirmed(workspaceRoot, issueNumber, sha)
|
|
||||||
: []
|
|
||||||
panel.postMessage({
|
|
||||||
type: 'pr-commit-files/show',
|
|
||||||
issueNumber,
|
|
||||||
sha,
|
|
||||||
parentSha: detail.parentSha,
|
|
||||||
files: detail.files,
|
|
||||||
confirmed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
panel.postMessage({
|
|
||||||
type: 'pr-commit-files/show',
|
|
||||||
issueNumber,
|
|
||||||
sha,
|
|
||||||
files: [],
|
|
||||||
confirmed: [],
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 持久化某提交里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`。
|
|
||||||
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
|
|
||||||
*/
|
|
||||||
export async function handleSetPrReviewConfirmed(
|
|
||||||
panel: KanbanWebviewPanel,
|
|
||||||
args: { issueNumber: number, sha: string, confirmed: string[] },
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
if (!workspaceRoot)
|
|
||||||
return
|
|
||||||
await writeConfirmed(workspaceRoot, args.issueNumber, args.sha, args.confirmed)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 持久化某工单已确认(已审阅)的提交 sha 集合到 `.spx/pr-review-confirmed.json`。
|
|
||||||
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
|
|
||||||
*/
|
|
||||||
export async function handleSetPrReviewConfirmedCommits(
|
|
||||||
panel: KanbanWebviewPanel,
|
|
||||||
args: { issueNumber: number, confirmed: string[] },
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
||||||
if (!workspaceRoot)
|
|
||||||
return
|
|
||||||
await writeConfirmedCommits(workspaceRoot, args.issueNumber, args.confirmed)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.warn('handleSetPrReviewConfirmedCommits 写盘失败', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 懒注册 raw 内容 provider(只一次)。闭包捕获 panel 以便取 token——diff 两侧的
|
|
||||||
* 文档内容都经此 provider 按 uri 里的 ref 拉取。
|
|
||||||
*/
|
|
||||||
function ensureContentProvider(panel: KanbanWebviewPanel): void {
|
|
||||||
if (providerRegistered)
|
|
||||||
return
|
|
||||||
providerRegistered = true
|
|
||||||
|
|
||||||
const provider: TextDocumentContentProvider = {
|
|
||||||
async provideTextDocumentContent(uri: Uri): Promise<string> {
|
|
||||||
const params = new URLSearchParams(uri.query)
|
|
||||||
const host = params.get('host') ?? ''
|
|
||||||
const owner = params.get('owner') ?? ''
|
|
||||||
const repo = params.get('repo') ?? ''
|
|
||||||
const ref = params.get('ref') ?? ''
|
|
||||||
// uri.path 形如 `/src/foo.ts`:去掉前导斜杠并 decode 还原原始路径。
|
|
||||||
const filepath = decodeURIComponent(uri.path.replace(/^\//, ''))
|
|
||||||
if (!host || !owner || !repo)
|
|
||||||
return ''
|
|
||||||
const token = await getToken(panel.context, host)
|
|
||||||
if (!token)
|
|
||||||
return ''
|
|
||||||
return getRawFile({ host, owner, repo, token, filepath, ref })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposable = workspace.registerTextDocumentContentProvider(SPX_GITEA_SCHEME, provider)
|
|
||||||
panel.context.subscriptions.push(disposable)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把改动文件的一侧(某 ref 下内容)编码成 spx-gitea 虚拟文档 uri。 */
|
|
||||||
function buildSideUri(opts: {
|
|
||||||
host: string
|
|
||||||
owner: string
|
|
||||||
repo: string
|
|
||||||
ref: string
|
|
||||||
path: string
|
|
||||||
}): Uri {
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
host: opts.host,
|
|
||||||
owner: opts.owner,
|
|
||||||
repo: opts.repo,
|
|
||||||
ref: opts.ref,
|
|
||||||
}).toString()
|
|
||||||
return Uri.from({ scheme: SPX_GITEA_SCHEME, path: `/${opts.path}`, query })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用 VS Code 原生 diff 比较一个文件在 parentSha → sha 之间的改动。
|
|
||||||
*
|
|
||||||
* 左侧 = parentSha 下内容,右侧 = sha 下内容。根提交无 parentSha 时左侧 ref 给
|
|
||||||
* 空串,raw 取不到(404)→ 空内容,自然呈现全新增。删除文件右侧在 sha 下 404
|
|
||||||
* → 空,呈现删除。
|
|
||||||
*/
|
|
||||||
export async function handleOpenPrCommitDiff(
|
|
||||||
panel: KanbanWebviewPanel,
|
|
||||||
args: { issueNumber: number, sha: string, parentSha?: string, path: string, status: string },
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const ctx = await resolveRepoContext(panel)
|
|
||||||
if (!ctx) {
|
|
||||||
void window.showWarningMessage('当前工作区没有 Gitea 仓库或未配置 token')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ensureContentProvider(panel)
|
|
||||||
|
|
||||||
const leftUri = buildSideUri({
|
|
||||||
host: ctx.host,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
ref: args.parentSha ?? '',
|
|
||||||
path: args.path,
|
|
||||||
})
|
|
||||||
const rightUri = buildSideUri({
|
|
||||||
host: ctx.host,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
ref: args.sha,
|
|
||||||
path: args.path,
|
|
||||||
})
|
|
||||||
const title = `${args.path} (${(args.parentSha ?? '∅').slice(0, 7)} ↔ ${args.sha.slice(0, 7)})`
|
|
||||||
await commands.executeCommand('vscode.diff', leftUri, rightUri, title)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
void window.showErrorMessage(`打开 diff 失败:${err instanceof Error ? err.message : String(err)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取
|
||||||
|
* 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。
|
||||||
|
*
|
||||||
|
* 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json`
|
||||||
|
* (键含 head sha,新 push 自然重置)。diff 左右两侧用 merge_base → head 的原文,
|
||||||
|
* 与 Gitea 网页 diff 口径一致;getRawFile 对 404 兜底空串,故新增/删除/改名天然成立。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
|
import { workspace } from 'vscode'
|
||||||
|
import { getToken } from '../../auth/secrets'
|
||||||
|
import { detectRepo } from '../../git/remote'
|
||||||
|
import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api'
|
||||||
|
import { readStateJsonComment } from '../../gitea/stateJson'
|
||||||
|
import { readConfirmed, writeConfirmed } from '../../sessions/prReviewStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined,
|
||||||
|
* 调用方据此走空态/错误分支。
|
||||||
|
*/
|
||||||
|
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
|
||||||
|
{ host: string, owner: string, repo: string, token: string } | undefined
|
||||||
|
> {
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!workspaceRoot)
|
||||||
|
return undefined
|
||||||
|
const remote = await detectRepo(workspaceRoot)
|
||||||
|
if (!remote)
|
||||||
|
return undefined
|
||||||
|
const token = await getToken(panel.context, remote.host)
|
||||||
|
if (!token)
|
||||||
|
return undefined
|
||||||
|
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */
|
||||||
|
async function resolvePrNumber(
|
||||||
|
ctx: { host: string, owner: string, repo: string, token: string },
|
||||||
|
issueNumber: number,
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
const stateObj = await readStateJsonComment({
|
||||||
|
host: ctx.host,
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.repo,
|
||||||
|
token: ctx.token,
|
||||||
|
issueNumber,
|
||||||
|
})
|
||||||
|
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
|
||||||
|
return pr ? Number(pr) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由文件扩展名推断高亮语言 id(highlight.js/lowlight 口径);未知则回退扩展名本身。 */
|
||||||
|
function langFromPath(path: string): string {
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
ts: 'typescript',
|
||||||
|
tsx: 'typescript',
|
||||||
|
js: 'javascript',
|
||||||
|
jsx: 'javascript',
|
||||||
|
mjs: 'javascript',
|
||||||
|
cjs: 'javascript',
|
||||||
|
json: 'json',
|
||||||
|
py: 'python',
|
||||||
|
go: 'go',
|
||||||
|
rs: 'rust',
|
||||||
|
java: 'java',
|
||||||
|
kt: 'kotlin',
|
||||||
|
kts: 'kotlin',
|
||||||
|
rb: 'ruby',
|
||||||
|
php: 'php',
|
||||||
|
c: 'c',
|
||||||
|
h: 'c',
|
||||||
|
cpp: 'cpp',
|
||||||
|
cc: 'cpp',
|
||||||
|
hpp: 'cpp',
|
||||||
|
cs: 'csharp',
|
||||||
|
swift: 'swift',
|
||||||
|
md: 'markdown',
|
||||||
|
markdown: 'markdown',
|
||||||
|
yml: 'yaml',
|
||||||
|
yaml: 'yaml',
|
||||||
|
toml: 'toml',
|
||||||
|
ini: 'ini',
|
||||||
|
sh: 'bash',
|
||||||
|
bash: 'bash',
|
||||||
|
zsh: 'bash',
|
||||||
|
sql: 'sql',
|
||||||
|
css: 'css',
|
||||||
|
scss: 'scss',
|
||||||
|
less: 'less',
|
||||||
|
html: 'xml',
|
||||||
|
htm: 'xml',
|
||||||
|
xml: 'xml',
|
||||||
|
vue: 'xml',
|
||||||
|
svelte: 'xml',
|
||||||
|
}
|
||||||
|
return map[ext] ?? ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取工单对应 PR 的全部改动文件清单 + head/mergeBase ref + 已确认集合,推给 webview。
|
||||||
|
* 无 PR/缺上下文回空(前端按空态处理);真出错才带 error。
|
||||||
|
*/
|
||||||
|
export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ctx = await resolveRepoContext(panel)
|
||||||
|
if (!ctx) {
|
||||||
|
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = await resolvePrNumber(ctx, issueNumber)
|
||||||
|
if (index === undefined) {
|
||||||
|
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index })
|
||||||
|
// 三点 diff 的左侧 ref:优先 merge_base,缺失则退回 base sha。
|
||||||
|
const mergeBase = pr.mergeBase || pr.baseSha
|
||||||
|
const files = await listPullRequestFiles({
|
||||||
|
host: ctx.host,
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.repo,
|
||||||
|
token: ctx.token,
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
const confirmed = workspaceRoot
|
||||||
|
? await readConfirmed(workspaceRoot, issueNumber, pr.headSha)
|
||||||
|
: []
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'pr-files/show',
|
||||||
|
issueNumber,
|
||||||
|
headSha: pr.headSha,
|
||||||
|
mergeBase,
|
||||||
|
files,
|
||||||
|
confirmed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'pr-files/show',
|
||||||
|
issueNumber,
|
||||||
|
headSha: '',
|
||||||
|
mergeBase: '',
|
||||||
|
files: [],
|
||||||
|
confirmed: [],
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取单个文件改动前后的原文:左 = previousPath@mergeBase(无改名则 path),
|
||||||
|
* 右 = path@head。两侧原文 + 语言推断回给 webview,由 git-diff-view 内联渲染。
|
||||||
|
*/
|
||||||
|
export async function handleGetPrFileDiff(
|
||||||
|
panel: KanbanWebviewPanel,
|
||||||
|
args: { issueNumber: number, path: string, previousPath?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ctx = await resolveRepoContext(panel)
|
||||||
|
if (!ctx) {
|
||||||
|
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = await resolvePrNumber(ctx, args.issueNumber)
|
||||||
|
if (index === undefined) {
|
||||||
|
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index })
|
||||||
|
const mergeBase = pr.mergeBase || pr.baseSha
|
||||||
|
const oldPath = args.previousPath ?? args.path
|
||||||
|
const [oldContent, newContent] = await Promise.all([
|
||||||
|
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }),
|
||||||
|
getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: args.path, ref: pr.headSha }),
|
||||||
|
])
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'pr-file-diff/show',
|
||||||
|
issueNumber: args.issueNumber,
|
||||||
|
path: args.path,
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
oldLang: langFromPath(oldPath),
|
||||||
|
newLang: langFromPath(args.path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'pr-file-diff/show',
|
||||||
|
issueNumber: args.issueNumber,
|
||||||
|
path: args.path,
|
||||||
|
oldContent: '',
|
||||||
|
newContent: '',
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化某 PR 里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`,
|
||||||
|
* 键含 head sha。拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失
|
||||||
|
* 不该中断 UI。
|
||||||
|
*/
|
||||||
|
export async function handleSetPrReviewConfirmed(
|
||||||
|
panel: KanbanWebviewPanel,
|
||||||
|
args: { issueNumber: number, headSha: string, confirmed: string[] },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!workspaceRoot)
|
||||||
|
return
|
||||||
|
await writeConfirmed(workspaceRoot, args.issueNumber, args.headSha, args.confirmed)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,18 +19,16 @@ export interface ManagedSessionsShowData {
|
|||||||
sessions: ManagedSessionShowItem[]
|
sessions: ManagedSessionShowItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PR 提交的精简视图(webview 列表渲染用)。 */
|
/**
|
||||||
export interface PrCommit {
|
* PR 改动里的一个文件:status 取 added/modified/deleted/renamed/copied 等,
|
||||||
sha: string
|
* additions/deletions 求和成统计条,previousFilename 在改名时给出旧路径。
|
||||||
message: string
|
*/
|
||||||
authorName: string
|
export interface PrFile {
|
||||||
date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交内单个文件改动:status 取 added/modified/deleted/renamed/copied 等。 */
|
|
||||||
export interface PrCommitFile {
|
|
||||||
path: string
|
path: string
|
||||||
status: string
|
status: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
previousFilename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToastLink {
|
export interface ToastLink {
|
||||||
@@ -102,8 +100,8 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
|
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
|
||||||
| { type: 'pr-commits/show', issueNumber: number, commits: PrCommit[], confirmedCommits: string[], error?: string }
|
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
|
||||||
| { type: 'pr-commit-files/show', issueNumber: number, sha: string, parentSha?: string, files: PrCommitFile[], confirmed: string[], error?: string }
|
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
|
||||||
|
|
||||||
export type WebviewToExtension
|
export type WebviewToExtension
|
||||||
= | { type: 'issues/refresh' }
|
= | { type: 'issues/refresh' }
|
||||||
@@ -174,8 +172,6 @@ export type WebviewToExtension
|
|||||||
| { type: 'managed-sessions/resume', sessionId: string }
|
| { type: 'managed-sessions/resume', sessionId: string }
|
||||||
| { type: 'managed-sessions/delete', sessionId: string }
|
| { type: 'managed-sessions/delete', sessionId: string }
|
||||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||||
| { type: 'pr-commits/get', issueNumber: number }
|
| { type: 'pr-files/get', issueNumber: number }
|
||||||
| { type: 'pr-commit-files/get', issueNumber: number, sha: string }
|
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||||
| { type: 'pr-commit-diff/open', issueNumber: number, sha: string, parentSha?: string, path: string, status: string }
|
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }
|
||||||
| { type: 'pr-review/set', issueNumber: number, sha: string, confirmed: string[] }
|
|
||||||
| { type: 'pr-review/set-commits', issueNumber: number, confirmed: string[] }
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* Workspace 级「PR 审查已确认文件」持久化。
|
* Workspace 级「PR 审查已确认文件」持久化。
|
||||||
*
|
*
|
||||||
* 落盘到 `<workspace>/.spx/pr-review-confirmed.json`,形状
|
* 落盘到 `<workspace>/.spx/pr-review-confirmed.json`,形状
|
||||||
* `Record<string, string[]>`:key = `${issueNumber}:${sha}`,value = 该提交里
|
* `Record<string, string[]>`:key = `${issueNumber}:${sha}`(sha 取 PR 的 head
|
||||||
* 已确认(已审阅)的文件完整路径数组。目录不单独记录——目录是否确认靠「其下
|
* sha,故新 push 换 key、已查看进度自然重置),value = 该 PR 里已确认(已审阅)
|
||||||
* 可见文件是否全已确认」推导。文件不存在或解析失败时按空处理。
|
* 的文件完整路径数组。目录不单独记录——目录是否确认靠「其下可见文件是否全已
|
||||||
|
* 确认」推导。文件不存在或解析失败时按空处理。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
@@ -79,36 +80,3 @@ export async function writeConfirmed(
|
|||||||
await fsp.mkdir(dir, { recursive: true })
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
|
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取某工单已确认(已审阅)的提交 sha 数组。key = `${issueNumber}:commits`
|
|
||||||
* (`commits` 非合法 sha,不与文件确认的 `${issueNumber}:${sha}` key 冲突)。
|
|
||||||
* 文件缺失、解析失败或无该 key 时返回 `[]`。
|
|
||||||
*/
|
|
||||||
export async function readConfirmedCommits(
|
|
||||||
workspaceRoot: string,
|
|
||||||
issueNumber: number,
|
|
||||||
): Promise<string[]> {
|
|
||||||
const map = await readMap(workspaceRoot)
|
|
||||||
return map[`${issueNumber}:commits`] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入某工单已确认的提交 sha 数组。空数组则删除该 key;非空则设值。父目录不存在
|
|
||||||
* 会自动创建。
|
|
||||||
*/
|
|
||||||
export async function writeConfirmedCommits(
|
|
||||||
workspaceRoot: string,
|
|
||||||
issueNumber: number,
|
|
||||||
shas: string[],
|
|
||||||
): Promise<void> {
|
|
||||||
const map = await readMap(workspaceRoot)
|
|
||||||
const key = `${issueNumber}:commits`
|
|
||||||
if (shas.length > 0)
|
|
||||||
map[key] = shas
|
|
||||||
else
|
|
||||||
delete map[key]
|
|
||||||
const dir = path.join(workspaceRoot, '.spx')
|
|
||||||
await fsp.mkdir(dir, { recursive: true })
|
|
||||||
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@git-diff-view/file": "^0.1.6",
|
||||||
|
"@git-diff-view/react": "^0.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import type { ProfilesData } from '../lib/messages'
|
import type { ProfilesData } from '../lib/messages'
|
||||||
import type { Issue, ManagedSessionsData } from '../types'
|
import type { Issue, ManagedSessionsData } from '../types'
|
||||||
import { ClipboardList, GitCommitHorizontal, Layers, MessagesSquare } from 'lucide-react'
|
import { ClipboardList, FileDiff, Layers, MessagesSquare } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { IssueDetailPanel } from './IssueDetailPanel'
|
import { IssueDetailPanel } from './IssueDetailPanel'
|
||||||
import { ManagedSessionsPanel } from './ManagedSessionsPanel'
|
import { ManagedSessionsPanel } from './ManagedSessionsPanel'
|
||||||
import { PrCommitsPanel } from './PrCommitsPanel'
|
import { PrFilesPanel } from './PrFilesPanel'
|
||||||
import { ProfileGrid } from './ProfileGrid'
|
import { ProfileGrid } from './ProfileGrid'
|
||||||
|
|
||||||
type TabKey = 'issue' | 'profile' | 'sessions' | 'commits'
|
type TabKey = 'issue' | 'profile' | 'sessions' | 'changes'
|
||||||
|
|
||||||
interface BottomTabsProps {
|
interface BottomTabsProps {
|
||||||
// Issue tab props (pass-through to IssueDetailPanel)
|
// Issue tab props (pass-through to IssueDetailPanel)
|
||||||
@@ -87,10 +87,10 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
title="会话"
|
title="会话"
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={tab === 'commits'}
|
active={tab === 'changes'}
|
||||||
onClick={() => setTab('commits')}
|
onClick={() => setTab('changes')}
|
||||||
icon={<GitCommitHorizontal className="size-4" />}
|
icon={<FileDiff className="size-4" />}
|
||||||
title="提交"
|
title="改动"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,8 +143,8 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
onCloseTab={props.onManagedSessionCloseTab}
|
onCloseTab={props.onManagedSessionCloseTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'commits' && (
|
{tab === 'changes' && (
|
||||||
<PrCommitsPanel issue={props.issue} />
|
<PrFilesPanel issue={props.issue} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,533 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「提交」tab(底部第四个):浏览当前工单 PR 的提交 → 提交内文件 → 原生 diff。
|
|
||||||
*
|
|
||||||
* - 上半区:提交列表(sha 前 7 位 + 消息首行 + 作者 + 时间),单选高亮。
|
|
||||||
* - 下半区:选中提交的文件清单,渲染成目录树(合并单子目录链),文件行状态徽标
|
|
||||||
* (A/M/D/R 着色)+ 文件名,点击触发 `vscode.diff` 比较该文件在 parentSha → sha
|
|
||||||
* 之间的改动;`tests/` 目录下的文件不展示。
|
|
||||||
*
|
|
||||||
* 自带 usePrCommits hook,直接经 lib/vscode 收发消息,不依赖上层回调。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
||||||
import type { Issue } from '../types'
|
|
||||||
import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { usePrCommits } from '../hooks/usePrCommits'
|
|
||||||
|
|
||||||
interface PrCommitsPanelProps {
|
|
||||||
issue: Issue | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交时间格式化,与 ManagedSessionsPanel 风格一致;无效值回空串。 */
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
if (!iso)
|
|
||||||
return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
if (Number.isNaN(d.getTime()))
|
|
||||||
return ''
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 状态首字母徽标(大写),未知状态取首字母兜底。 */
|
|
||||||
function statusBadge(status: string): string {
|
|
||||||
const s = status.toLowerCase()
|
|
||||||
if (s.startsWith('add'))
|
|
||||||
return 'A'
|
|
||||||
if (s.startsWith('modif'))
|
|
||||||
return 'M'
|
|
||||||
if (s.startsWith('delet') || s.startsWith('remov'))
|
|
||||||
return 'D'
|
|
||||||
if (s.startsWith('renam'))
|
|
||||||
return 'R'
|
|
||||||
if (s.startsWith('cop'))
|
|
||||||
return 'C'
|
|
||||||
return (status[0] ?? '?').toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */
|
|
||||||
function statusColorVar(status: string): string {
|
|
||||||
const s = status.toLowerCase()
|
|
||||||
if (s.startsWith('add') || s.startsWith('cop'))
|
|
||||||
return 'var(--vscode-gitDecoration-addedResourceForeground)'
|
|
||||||
if (s.startsWith('delet') || s.startsWith('remov'))
|
|
||||||
return 'var(--vscode-gitDecoration-deletedResourceForeground)'
|
|
||||||
if (s.startsWith('renam'))
|
|
||||||
return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))'
|
|
||||||
return 'var(--vscode-gitDecoration-modifiedResourceForeground)'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */
|
|
||||||
function isIgnoredPath(path: string): boolean {
|
|
||||||
return path.split('/').some(seg => seg === 'tests')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拍平后的可渲染行:
|
|
||||||
* - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、
|
|
||||||
* 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联);
|
|
||||||
* - 文件行额外带完整路径与状态。
|
|
||||||
*/
|
|
||||||
type TreeRow
|
|
||||||
= | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] }
|
|
||||||
| { kind: 'file', name: string, depth: number, path: string, status: string }
|
|
||||||
|
|
||||||
interface FileLeaf { name: string, path: string, status: string }
|
|
||||||
|
|
||||||
/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */
|
|
||||||
interface DirNode {
|
|
||||||
dirs: Map<string, DirNode>
|
|
||||||
files: FileLeaf[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function newDirNode(): DirNode {
|
|
||||||
return { dirs: new Map(), files: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。
|
|
||||||
*
|
|
||||||
* 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。
|
|
||||||
*/
|
|
||||||
function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode {
|
|
||||||
const root = newDirNode()
|
|
||||||
for (const f of files) {
|
|
||||||
const segs = f.path.split('/')
|
|
||||||
const fileName = segs[segs.length - 1]
|
|
||||||
let node = root
|
|
||||||
for (let i = 0; i < segs.length - 1; i++) {
|
|
||||||
const seg = segs[i]
|
|
||||||
let child = node.dirs.get(seg)
|
|
||||||
if (!child) {
|
|
||||||
child = newDirNode()
|
|
||||||
node.dirs.set(seg, child)
|
|
||||||
}
|
|
||||||
node = child
|
|
||||||
}
|
|
||||||
node.files.push({ name: fileName, path: f.path, status: f.status })
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DFS 拍平目录树成可渲染行列表,并感知折叠状态。
|
|
||||||
*
|
|
||||||
* - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时,
|
|
||||||
* 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径
|
|
||||||
* (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。
|
|
||||||
* - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。
|
|
||||||
* - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。
|
|
||||||
*/
|
|
||||||
/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */
|
|
||||||
function collectDescendantFiles(node: DirNode): string[] {
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (n: DirNode) => {
|
|
||||||
for (const f of n.files)
|
|
||||||
out.push(f.path)
|
|
||||||
for (const child of n.dirs.values())
|
|
||||||
walk(child)
|
|
||||||
}
|
|
||||||
walk(node)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenTree(root: DirNode, collapsed: ReadonlySet<string>): TreeRow[] {
|
|
||||||
const rows: TreeRow[] = []
|
|
||||||
// prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。
|
|
||||||
const emit = (node: DirNode, depth: number, prefix: string) => {
|
|
||||||
const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b))
|
|
||||||
for (const name of dirNames) {
|
|
||||||
// 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来;
|
|
||||||
// path 同步累计每个被合并的段,作为折叠状态的唯一标识。
|
|
||||||
let label = name
|
|
||||||
let path = prefix + name
|
|
||||||
let cur = node.dirs.get(name)!
|
|
||||||
while (cur.files.length === 0 && cur.dirs.size === 1) {
|
|
||||||
const [childName, childNode] = [...cur.dirs.entries()][0]
|
|
||||||
label += `/${childName}`
|
|
||||||
path += `/${childName}`
|
|
||||||
cur = childNode
|
|
||||||
}
|
|
||||||
const isCollapsed = collapsed.has(path)
|
|
||||||
rows.push({
|
|
||||||
kind: 'dir',
|
|
||||||
name: label,
|
|
||||||
depth,
|
|
||||||
path,
|
|
||||||
collapsed: isCollapsed,
|
|
||||||
descendantFiles: collectDescendantFiles(cur),
|
|
||||||
})
|
|
||||||
// 折叠则隐藏其下所有内容,不再递归。
|
|
||||||
if (!isCollapsed)
|
|
||||||
emit(cur, depth + 1, `${path}/`)
|
|
||||||
}
|
|
||||||
const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
for (const f of sortedFiles)
|
|
||||||
rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status })
|
|
||||||
}
|
|
||||||
emit(root, 0, '')
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrCommitsPanel({ issue }: PrCommitsPanelProps) {
|
|
||||||
const issueNumber = issue?.number
|
|
||||||
const {
|
|
||||||
commits,
|
|
||||||
commitsError,
|
|
||||||
loadingCommits,
|
|
||||||
filesBySha,
|
|
||||||
parentShaBySha,
|
|
||||||
filesErrorBySha,
|
|
||||||
confirmedBySha,
|
|
||||||
confirmedCommits,
|
|
||||||
getFiles,
|
|
||||||
openDiff,
|
|
||||||
setConfirmed,
|
|
||||||
markCommitsConfirmed,
|
|
||||||
} = usePrCommits(issueNumber)
|
|
||||||
|
|
||||||
const [selectedSha, setSelectedSha] = useState<string | undefined>(undefined)
|
|
||||||
// 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。
|
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
|
||||||
// JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover),
|
|
||||||
// Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。
|
|
||||||
const [hoveredPath, setHoveredPath] = useState<string | null>(null)
|
|
||||||
// 提交行的悬停态(与文件行的 hoveredPath 分开),同样 JS 驱动确认按钮显隐。
|
|
||||||
const [hoveredCommit, setHoveredCommit] = useState<string | null>(null)
|
|
||||||
// 提交列表 <ul>,用于方向键切换后把选中项滚进可视区。
|
|
||||||
const commitListRef = useRef<HTMLUListElement | null>(null)
|
|
||||||
|
|
||||||
// 切工单时清掉选中态(hook 已清缓存,这里只管 UI 选中)。
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedSha(undefined)
|
|
||||||
}, [issueNumber])
|
|
||||||
|
|
||||||
// 切提交后文件树默认全展开,避免沿用上一提交的折叠状态。
|
|
||||||
useEffect(() => {
|
|
||||||
setCollapsed(new Set())
|
|
||||||
}, [selectedSha])
|
|
||||||
|
|
||||||
// 选中提交且尚无文件缓存 → 拉一次文件清单。
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedSha && !(selectedSha in filesBySha))
|
|
||||||
getFiles(selectedSha)
|
|
||||||
}, [selectedSha, filesBySha, getFiles])
|
|
||||||
|
|
||||||
// 方向键切换选中后,把选中的提交行滚进可视区(按 dataset 匹配,避免 sha 进选择器)。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedSha)
|
|
||||||
return
|
|
||||||
const ul = commitListRef.current
|
|
||||||
if (!ul)
|
|
||||||
return
|
|
||||||
for (const li of ul.children) {
|
|
||||||
if ((li as HTMLElement).dataset.sha === selectedSha) {
|
|
||||||
(li as HTMLElement).scrollIntoView({ block: 'nearest' })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedSha])
|
|
||||||
|
|
||||||
const selectedFiles = selectedSha ? filesBySha[selectedSha] : undefined
|
|
||||||
const selectedFilesError = selectedSha ? filesErrorBySha[selectedSha] : undefined
|
|
||||||
const selectedParentSha = selectedSha ? parentShaBySha[selectedSha] : undefined
|
|
||||||
|
|
||||||
// 过滤掉 tests/ 后构嵌套树;selectedFiles 还没加载(undefined)时给空列表。
|
|
||||||
// 构树只依赖文件本身,折叠状态变化不必重构树,只重新拍平。
|
|
||||||
const dirTree = useMemo(
|
|
||||||
() => buildDirTree((selectedFiles ?? []).filter(f => !isIgnoredPath(f.path))),
|
|
||||||
[selectedFiles],
|
|
||||||
)
|
|
||||||
const fileTreeRows = useMemo(() => flattenTree(dirTree, collapsed), [dirTree, collapsed])
|
|
||||||
|
|
||||||
// 当前提交已确认的文件路径集合(目录确认靠「其下文件全在此集合」推导)。
|
|
||||||
const confirmed = useMemo(
|
|
||||||
() => new Set(confirmedBySha[selectedSha ?? ''] ?? []),
|
|
||||||
[confirmedBySha, selectedSha],
|
|
||||||
)
|
|
||||||
|
|
||||||
// 当前工单已确认的提交 sha 集合(提交行勾标记 + 行淡化用)。
|
|
||||||
const confirmedCommitSet = useMemo(() => new Set(confirmedCommits), [confirmedCommits])
|
|
||||||
|
|
||||||
// 所有 hook 必须在任何 early return 之前调用完。否则工单空/有无 PR 之间切换时
|
|
||||||
// hook 数量变化,触发 React #300(表现为面板空白 / 渲染出错)。
|
|
||||||
if (!issue) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
未选中工单
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issue.pr) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
该工单还没有 PR
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交列表方向键切换:未选中时 ArrowDown 选首、ArrowUp 选末;已选中则按下标夹取边界移动。
|
|
||||||
const onCommitListKeyDown = (e: ReactKeyboardEvent<HTMLUListElement>) => {
|
|
||||||
if (commits.length === 0)
|
|
||||||
return
|
|
||||||
const cur = selectedSha ? commits.findIndex(c => c.sha === selectedSha) : -1
|
|
||||||
let next: number | undefined
|
|
||||||
if (e.key === 'ArrowDown')
|
|
||||||
next = cur < 0 ? 0 : Math.min(cur + 1, commits.length - 1)
|
|
||||||
else if (e.key === 'ArrowUp')
|
|
||||||
next = cur < 0 ? commits.length - 1 : Math.max(cur - 1, 0)
|
|
||||||
else if (e.key === 'Home')
|
|
||||||
next = 0
|
|
||||||
else if (e.key === 'End')
|
|
||||||
next = commits.length - 1
|
|
||||||
if (next === undefined)
|
|
||||||
return
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectedSha(commits[next].sha)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
|
||||||
{/* 上半区:提交列表 */}
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-b border-[var(--vscode-panel-border)]">
|
|
||||||
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
|
|
||||||
{`提交 #${issue.pr}`}
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
|
||||||
{commitsError
|
|
||||||
? (
|
|
||||||
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
|
|
||||||
{commitsError}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: loadingCommits
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
加载中…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: commits.length === 0
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
该 PR 暂无提交
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<ul
|
|
||||||
ref={commitListRef}
|
|
||||||
tabIndex={0}
|
|
||||||
role="listbox"
|
|
||||||
aria-label="PR 提交列表"
|
|
||||||
onKeyDown={onCommitListKeyDown}
|
|
||||||
className="flex flex-col outline-none"
|
|
||||||
>
|
|
||||||
{commits.map((c) => {
|
|
||||||
const isConfirmed = confirmedCommitSet.has(c.sha)
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={c.sha}
|
|
||||||
data-sha={c.sha}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selectedSha === c.sha}
|
|
||||||
onMouseEnter={() => setHoveredCommit(c.sha)}
|
|
||||||
onMouseLeave={() => setHoveredCommit(p => (p === c.sha ? null : p))}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedSha(c.sha)
|
|
||||||
// 选中后把焦点给列表,方向键即时可用(不被看板抢走)。
|
|
||||||
commitListRef.current?.focus()
|
|
||||||
}}
|
|
||||||
className={`flex cursor-pointer flex-col gap-0.5 border-b border-[var(--vscode-panel-border)] px-2 py-1.5 ${
|
|
||||||
selectedSha === c.sha
|
|
||||||
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
|
|
||||||
: 'hover:bg-[var(--vscode-list-hoverBackground)]'
|
|
||||||
} ${isConfirmed ? 'opacity-60' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--vscode-descriptionForeground)]">
|
|
||||||
{c.sha.slice(0, 7)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-xs" title={c.message}>
|
|
||||||
{c.message}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const next = isConfirmed
|
|
||||||
? confirmedCommits.filter(s => s !== c.sha)
|
|
||||||
: [...confirmedCommits, c.sha]
|
|
||||||
markCommitsConfirmed(next)
|
|
||||||
}}
|
|
||||||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
|
||||||
className="shrink-0 transition-opacity"
|
|
||||||
style={{
|
|
||||||
opacity: isConfirmed || hoveredCommit === c.sha ? 1 : 0,
|
|
||||||
color: isConfirmed
|
|
||||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
|
||||||
: 'var(--vscode-descriptionForeground)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
|
||||||
{[c.authorName, formatDate(c.date)].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 下半区:选中提交的文件清单 */}
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
|
|
||||||
文件
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
|
||||||
{!selectedSha
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
选择一个提交查看文件
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: selectedFilesError
|
|
||||||
? (
|
|
||||||
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
|
|
||||||
{selectedFilesError}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: selectedFiles === undefined
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
加载中…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: selectedFiles.length === 0
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
该提交无文件改动
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
// selectedFiles 非空但全被 tests/ 过滤掉,行列表才会为空。
|
|
||||||
: fileTreeRows.length === 0
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
|
||||||
该提交改动均在 tests/ 目录(已忽略)
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<ul className="flex flex-col py-0.5">
|
|
||||||
{fileTreeRows.map((row) => {
|
|
||||||
const indentStyle = { paddingLeft: row.depth * 12 + 8 }
|
|
||||||
if (row.kind === 'dir') {
|
|
||||||
// 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。
|
|
||||||
const isConfirmed
|
|
||||||
= row.descendantFiles.length > 0
|
|
||||||
&& row.descendantFiles.every(p => confirmed.has(p))
|
|
||||||
return (
|
|
||||||
// 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。
|
|
||||||
<li
|
|
||||||
key={row.path}
|
|
||||||
onMouseEnter={() => setHoveredPath(row.path)}
|
|
||||||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
|
||||||
onClick={() => setCollapsed((prev) => {
|
|
||||||
const n = new Set(prev)
|
|
||||||
n.has(row.path) ? n.delete(row.path) : n.add(row.path)
|
|
||||||
return n
|
|
||||||
})}
|
|
||||||
className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${
|
|
||||||
isConfirmed ? 'opacity-60' : ''
|
|
||||||
}`}
|
|
||||||
style={indentStyle}
|
|
||||||
>
|
|
||||||
{row.collapsed
|
|
||||||
? <ChevronRight className="size-3.5 shrink-0" />
|
|
||||||
: <ChevronDown className="size-3.5 shrink-0" />}
|
|
||||||
<Folder
|
|
||||||
className="size-3.5 shrink-0"
|
|
||||||
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate">{row.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const next = isConfirmed
|
|
||||||
? [...confirmed].filter(p => !row.descendantFiles.includes(p))
|
|
||||||
: [...new Set([...confirmed, ...row.descendantFiles])]
|
|
||||||
setConfirmed(selectedSha, next)
|
|
||||||
}}
|
|
||||||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
|
||||||
className="shrink-0 transition-opacity"
|
|
||||||
style={{
|
|
||||||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
|
||||||
color: isConfirmed
|
|
||||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
|
||||||
: 'var(--vscode-descriptionForeground)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const isConfirmed = confirmed.has(row.path)
|
|
||||||
return (
|
|
||||||
// 文件行:点击触发 diff,title 用完整路径。
|
|
||||||
<li
|
|
||||||
key={row.path}
|
|
||||||
onMouseEnter={() => setHoveredPath(row.path)}
|
|
||||||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
|
||||||
onClick={() => openDiff(selectedSha, selectedParentSha, row.path, row.status)}
|
|
||||||
title={`${row.status} · ${row.path}`}
|
|
||||||
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 hover:bg-[var(--vscode-list-hoverBackground)] ${
|
|
||||||
isConfirmed ? 'opacity-60' : ''
|
|
||||||
}`}
|
|
||||||
style={indentStyle}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
|
||||||
style={{ color: statusColorVar(row.status) }}
|
|
||||||
>
|
|
||||||
{statusBadge(row.status)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-xs">{row.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const next = isConfirmed
|
|
||||||
? [...confirmed].filter(p => p !== row.path)
|
|
||||||
: [...confirmed, row.path]
|
|
||||||
setConfirmed(selectedSha, next)
|
|
||||||
}}
|
|
||||||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
|
||||||
className="shrink-0 transition-opacity"
|
|
||||||
style={{
|
|
||||||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
|
||||||
color: isConfirmed
|
|
||||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
|
||||||
: 'var(--vscode-descriptionForeground)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」tab(底部第四个):整个 PR 的改动以主从式呈现。
|
||||||
|
*
|
||||||
|
* - 左栏:统计条(共改 N 文件 · +X −Y · 已查看 M/N)+ 全 PR 文件树(目录树、状态徽标、
|
||||||
|
* tests/ 过滤、已查看勾)。
|
||||||
|
* - 右栏:选中文件的内联 diff(git-diff-view,只渲染当前一个文件)。
|
||||||
|
*
|
||||||
|
* 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用
|
||||||
|
* merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按 head sha 落盘。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Issue } from '../types'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { usePrFiles } from '../hooks/usePrFiles'
|
||||||
|
import { FileDiffPane } from './prFiles/FileDiffPane'
|
||||||
|
import { FileTree } from './prFiles/FileTree'
|
||||||
|
import { buildDirTree, flattenTree, isIgnoredPath } from './prFiles/fileTree'
|
||||||
|
|
||||||
|
interface PrFilesPanelProps {
|
||||||
|
issue: Issue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrFilesPanel({ issue }: PrFilesPanelProps) {
|
||||||
|
const issueNumber = issue?.number
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
filesError,
|
||||||
|
loading,
|
||||||
|
confirmed,
|
||||||
|
diffByPath,
|
||||||
|
getFileDiff,
|
||||||
|
setConfirmed,
|
||||||
|
} = usePrFiles(issueNumber)
|
||||||
|
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | undefined>(undefined)
|
||||||
|
// 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 切工单时清掉选中态与折叠态(hook 已清缓存)。
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPath(undefined)
|
||||||
|
setCollapsed(new Set())
|
||||||
|
}, [issueNumber])
|
||||||
|
|
||||||
|
// 过滤掉 tests/ 后的可见文件;构树只依赖文件本身,折叠态变化只重新拍平。
|
||||||
|
const visibleFiles = useMemo(() => files.filter(f => !isIgnoredPath(f.path)), [files])
|
||||||
|
const dirTree = useMemo(
|
||||||
|
() => buildDirTree(visibleFiles.map(f => ({ path: f.path, status: f.status }))),
|
||||||
|
[visibleFiles],
|
||||||
|
)
|
||||||
|
const rows = useMemo(() => flattenTree(dirTree, collapsed), [dirTree, collapsed])
|
||||||
|
|
||||||
|
const confirmedSet = useMemo(() => new Set(confirmed), [confirmed])
|
||||||
|
// 路径 → 文件,选中时取 status/previousFilename。
|
||||||
|
const fileByPath = useMemo(() => new Map(visibleFiles.map(f => [f.path, f])), [visibleFiles])
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
let additions = 0
|
||||||
|
let deletions = 0
|
||||||
|
for (const f of visibleFiles) {
|
||||||
|
additions += f.additions
|
||||||
|
deletions += f.deletions
|
||||||
|
}
|
||||||
|
const confirmedCount = visibleFiles.filter(f => confirmedSet.has(f.path)).length
|
||||||
|
return { total: visibleFiles.length, additions, deletions, confirmedCount }
|
||||||
|
}, [visibleFiles, confirmedSet])
|
||||||
|
|
||||||
|
const selectedFile = selectedPath ? fileByPath.get(selectedPath) : undefined
|
||||||
|
|
||||||
|
// 所有 hook 必须在任何 early return 之前调用完,否则工单有无 PR 之间切换时
|
||||||
|
// hook 数量变化会触发 React #300(表现为面板空白 / 渲染出错)。
|
||||||
|
if (!issue)
|
||||||
|
return <Centered>未选中工单</Centered>
|
||||||
|
if (!issue.pr)
|
||||||
|
return <Centered>该工单还没有 PR</Centered>
|
||||||
|
|
||||||
|
const onSelectFile = (path: string) => {
|
||||||
|
setSelectedPath(path)
|
||||||
|
if (!(path in diffByPath))
|
||||||
|
getFileDiff(path, fileByPath.get(path)?.previousFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleFileConfirm = (path: string, next: boolean) => {
|
||||||
|
setConfirmed(next ? [...confirmed, path] : confirmed.filter(p => p !== path))
|
||||||
|
}
|
||||||
|
const onToggleDirConfirm = (descendantFiles: string[], next: boolean) => {
|
||||||
|
setConfirmed(
|
||||||
|
next
|
||||||
|
? [...new Set([...confirmed, ...descendantFiles])]
|
||||||
|
: confirmed.filter(p => !descendantFiles.includes(p)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
||||||
|
{/* 左栏:统计条 + 文件树 */}
|
||||||
|
<div className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-[var(--vscode-panel-border)]">
|
||||||
|
<div className="shrink-0 border-b border-[var(--vscode-panel-border)] px-2 py-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
|
||||||
|
{`改动 · PR #${issue.pr}`}
|
||||||
|
</div>
|
||||||
|
{!loading && !filesError && stats.total > 0 && (
|
||||||
|
<div className="mt-0.5 truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||||
|
{`共 ${stats.total} 文件 · `}
|
||||||
|
<span style={{ color: 'var(--vscode-gitDecoration-addedResourceForeground)' }}>{`+${stats.additions}`}</span>
|
||||||
|
{' '}
|
||||||
|
<span style={{ color: 'var(--vscode-gitDecoration-deletedResourceForeground)' }}>{`−${stats.deletions}`}</span>
|
||||||
|
{` · 已查看 ${stats.confirmedCount}/${stats.total}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
{filesError
|
||||||
|
? <div className="p-3 text-xs text-[var(--vscode-errorForeground)]">{filesError}</div>
|
||||||
|
: loading
|
||||||
|
? <Centered>加载中…</Centered>
|
||||||
|
: files.length === 0
|
||||||
|
? <Centered>该 PR 暂无改动</Centered>
|
||||||
|
: rows.length === 0
|
||||||
|
? <Centered>改动均在 tests/ 目录(已忽略)</Centered>
|
||||||
|
: (
|
||||||
|
<FileTree
|
||||||
|
rows={rows}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
confirmed={confirmedSet}
|
||||||
|
onToggleCollapse={path => setCollapsed((prev) => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.has(path) ? n.delete(path) : n.add(path)
|
||||||
|
return n
|
||||||
|
})}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onToggleFileConfirm={onToggleFileConfirm}
|
||||||
|
onToggleDirConfirm={onToggleDirConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右栏:选中文件的内联 diff */}
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<FileDiffPane
|
||||||
|
path={selectedPath}
|
||||||
|
status={selectedFile?.status}
|
||||||
|
diff={selectedPath ? diffByPath[selectedPath] : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」面板右栏:把选中单个文件改动前后的原文交给 git-diff-view 内联渲染
|
||||||
|
* (主从式,只渲染当前一个文件,故上万行 PR 也不卡)。
|
||||||
|
*
|
||||||
|
* - 左右内容来自 merge_base / head 两版原文;diff 由库在 webview 端算。
|
||||||
|
* - 主题跟随 VS Code(body 的 vscode-light/dark class),MutationObserver 监听切换。
|
||||||
|
* - 语法高亮用库自带(diffViewHighlight),色彩取库的 light/dark 主题——不会和 VS Code
|
||||||
|
* 主题 100% 一致,是本方案已知且可接受的小瑕疵。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FileDiff } from '../../hooks/usePrFiles'
|
||||||
|
import { generateDiffFile } from '@git-diff-view/file'
|
||||||
|
import { DiffModeEnum, DiffView } from '@git-diff-view/react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { statusBadge, statusColorVar } from './fileTree'
|
||||||
|
import '@git-diff-view/react/styles/diff-view.css'
|
||||||
|
|
||||||
|
interface FileDiffPaneProps {
|
||||||
|
/** 选中文件路径;undefined 表示尚未选中。 */
|
||||||
|
path: string | undefined
|
||||||
|
/** 选中文件状态(A/M/D/R…),仅用于右栏标题徽标。 */
|
||||||
|
status: string | undefined
|
||||||
|
/** 该文件的 diff 内容;undefined 表示请求中(加载态)。 */
|
||||||
|
diff: FileDiff | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */
|
||||||
|
function readThemeKind(): 'light' | 'dark' {
|
||||||
|
if (typeof document === 'undefined')
|
||||||
|
return 'dark'
|
||||||
|
const cl = document.body.classList
|
||||||
|
if (cl.contains('vscode-light') || cl.contains('vscode-high-contrast-light'))
|
||||||
|
return 'light'
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) {
|
||||||
|
const [mode, setMode] = useState<DiffModeEnum>(DiffModeEnum.Split)
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind())
|
||||||
|
|
||||||
|
// 主题随 VS Code 实时切换。
|
||||||
|
useEffect(() => {
|
||||||
|
const obs = new MutationObserver(() => setTheme(readThemeKind()))
|
||||||
|
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 由两版原文构建并预处理 DiffFile;path/内容/模式/主题任一变就重建。
|
||||||
|
// oldContent === newContent(二进制或纯改名)时不出 diff,留空态由下方分支处理。
|
||||||
|
const diffFile = useMemo(() => {
|
||||||
|
if (!path || !diff || diff.error)
|
||||||
|
return null
|
||||||
|
if (diff.oldContent === diff.newContent)
|
||||||
|
return null
|
||||||
|
const file = generateDiffFile(
|
||||||
|
path,
|
||||||
|
diff.oldContent,
|
||||||
|
path,
|
||||||
|
diff.newContent,
|
||||||
|
diff.oldLang ?? '',
|
||||||
|
diff.newLang ?? '',
|
||||||
|
)
|
||||||
|
file.initTheme(theme)
|
||||||
|
file.init()
|
||||||
|
if (mode === DiffModeEnum.Unified)
|
||||||
|
file.buildUnifiedDiffLines()
|
||||||
|
else
|
||||||
|
file.buildSplitDiffLines()
|
||||||
|
return file
|
||||||
|
}, [path, diff, mode, theme])
|
||||||
|
|
||||||
|
if (!path)
|
||||||
|
return <Centered>选择左侧文件查看改动</Centered>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
{/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--vscode-panel-border)] px-2 py-1">
|
||||||
|
{status && (
|
||||||
|
<span
|
||||||
|
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||||
|
style={{ color: statusColorVar(status) }}
|
||||||
|
>
|
||||||
|
{statusBadge(status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--vscode-foreground)]" title={path}>
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 overflow-hidden rounded border border-[var(--vscode-panel-border)] text-[10px]">
|
||||||
|
{([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`px-1.5 py-0.5 transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? 'bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)]'
|
||||||
|
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-[var(--vscode-editor-background)] text-xs">
|
||||||
|
{diff === undefined
|
||||||
|
? <Centered>加载中…</Centered>
|
||||||
|
: diff.error
|
||||||
|
? (
|
||||||
|
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">{diff.error}</div>
|
||||||
|
)
|
||||||
|
: diffFile === null
|
||||||
|
? <Centered>无文本差异(可能是二进制文件或纯重命名)</Centered>
|
||||||
|
: (
|
||||||
|
<DiffView
|
||||||
|
diffFile={diffFile}
|
||||||
|
diffViewMode={mode}
|
||||||
|
diffViewTheme={theme}
|
||||||
|
diffViewHighlight
|
||||||
|
diffViewFontSize={12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」面板左栏的文件树:目录可折叠、单子目录链合并,文件行点击即选中(驱动右栏
|
||||||
|
* diff),行尾「已查看」勾可单独切换;目录勾 = 其下可见文件全已查看,点击级联整目录。
|
||||||
|
*
|
||||||
|
* 纯展示 + 回调:折叠态/选中态/已确认集合都由容器持有,本组件只发语义回调。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TreeRow } from './fileTree'
|
||||||
|
import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { statusBadge, statusColorVar } from './fileTree'
|
||||||
|
|
||||||
|
interface FileTreeProps {
|
||||||
|
rows: TreeRow[]
|
||||||
|
selectedPath: string | undefined
|
||||||
|
confirmed: ReadonlySet<string>
|
||||||
|
onToggleCollapse: (path: string) => void
|
||||||
|
onSelectFile: (path: string) => void
|
||||||
|
/** 切换单个文件的已查看态,next 为切换后应处于的状态。 */
|
||||||
|
onToggleFileConfirm: (path: string, next: boolean) => void
|
||||||
|
/** 切换整个目录(其下全部可见文件)的已查看态,next 为切换后应处于的状态。 */
|
||||||
|
onToggleDirConfirm: (descendantFiles: string[], next: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTree({
|
||||||
|
rows,
|
||||||
|
selectedPath,
|
||||||
|
confirmed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onSelectFile,
|
||||||
|
onToggleFileConfirm,
|
||||||
|
onToggleDirConfirm,
|
||||||
|
}: FileTreeProps) {
|
||||||
|
// JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover),
|
||||||
|
// Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。
|
||||||
|
const [hoveredPath, setHoveredPath] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col py-0.5">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const indentStyle = { paddingLeft: row.depth * 12 + 8 }
|
||||||
|
if (row.kind === 'dir') {
|
||||||
|
// 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。
|
||||||
|
const isConfirmed
|
||||||
|
= row.descendantFiles.length > 0
|
||||||
|
&& row.descendantFiles.every(p => confirmed.has(p))
|
||||||
|
return (
|
||||||
|
// 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。
|
||||||
|
<li
|
||||||
|
key={row.path}
|
||||||
|
onMouseEnter={() => setHoveredPath(row.path)}
|
||||||
|
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||||||
|
onClick={() => onToggleCollapse(row.path)}
|
||||||
|
className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${
|
||||||
|
isConfirmed ? 'opacity-60' : ''
|
||||||
|
}`}
|
||||||
|
style={indentStyle}
|
||||||
|
>
|
||||||
|
{row.collapsed
|
||||||
|
? <ChevronRight className="size-3.5 shrink-0" />
|
||||||
|
: <ChevronDown className="size-3.5 shrink-0" />}
|
||||||
|
<Folder
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">{row.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleDirConfirm(row.descendantFiles, !isConfirmed)
|
||||||
|
}}
|
||||||
|
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
|
||||||
|
className="shrink-0 transition-opacity"
|
||||||
|
style={{
|
||||||
|
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||||||
|
color: isConfirmed
|
||||||
|
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||||
|
: 'var(--vscode-descriptionForeground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const isConfirmed = confirmed.has(row.path)
|
||||||
|
const isSelected = selectedPath === row.path
|
||||||
|
return (
|
||||||
|
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。
|
||||||
|
<li
|
||||||
|
key={row.path}
|
||||||
|
onMouseEnter={() => setHoveredPath(row.path)}
|
||||||
|
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||||||
|
onClick={() => onSelectFile(row.path)}
|
||||||
|
title={`${row.status} · ${row.path}`}
|
||||||
|
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
|
||||||
|
: 'hover:bg-[var(--vscode-list-hoverBackground)]'
|
||||||
|
} ${isConfirmed ? 'opacity-60' : ''}`}
|
||||||
|
style={indentStyle}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||||
|
style={{ color: statusColorVar(row.status) }}
|
||||||
|
>
|
||||||
|
{statusBadge(row.status)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-xs">{row.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleFileConfirm(row.path, !isConfirmed)
|
||||||
|
}}
|
||||||
|
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
|
||||||
|
className="shrink-0 transition-opacity"
|
||||||
|
style={{
|
||||||
|
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||||||
|
color: isConfirmed
|
||||||
|
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||||
|
: 'var(--vscode-descriptionForeground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」文件树的纯函数层:把 `{ path, status }[]` 构建成目录树并拍平成可渲染行,
|
||||||
|
* 外加状态徽标/颜色/忽略路径等小工具。无 React 依赖,便于单测与复用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拍平后的可渲染行:
|
||||||
|
* - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、
|
||||||
|
* 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联);
|
||||||
|
* - 文件行额外带完整路径与状态。
|
||||||
|
*/
|
||||||
|
export type TreeRow
|
||||||
|
= | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] }
|
||||||
|
| { kind: 'file', name: string, depth: number, path: string, status: string }
|
||||||
|
|
||||||
|
interface FileLeaf { name: string, path: string, status: string }
|
||||||
|
|
||||||
|
/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */
|
||||||
|
interface DirNode {
|
||||||
|
dirs: Map<string, DirNode>
|
||||||
|
files: FileLeaf[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDirNode(): DirNode {
|
||||||
|
return { dirs: new Map(), files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态首字母徽标(大写),未知状态取首字母兜底。 */
|
||||||
|
export function statusBadge(status: string): string {
|
||||||
|
const s = status.toLowerCase()
|
||||||
|
if (s.startsWith('add'))
|
||||||
|
return 'A'
|
||||||
|
if (s.startsWith('modif'))
|
||||||
|
return 'M'
|
||||||
|
if (s.startsWith('delet') || s.startsWith('remov'))
|
||||||
|
return 'D'
|
||||||
|
if (s.startsWith('renam'))
|
||||||
|
return 'R'
|
||||||
|
if (s.startsWith('cop'))
|
||||||
|
return 'C'
|
||||||
|
return (status[0] ?? '?').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */
|
||||||
|
export function statusColorVar(status: string): string {
|
||||||
|
const s = status.toLowerCase()
|
||||||
|
if (s.startsWith('add') || s.startsWith('cop'))
|
||||||
|
return 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||||
|
if (s.startsWith('delet') || s.startsWith('remov'))
|
||||||
|
return 'var(--vscode-gitDecoration-deletedResourceForeground)'
|
||||||
|
if (s.startsWith('renam'))
|
||||||
|
return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))'
|
||||||
|
return 'var(--vscode-gitDecoration-modifiedResourceForeground)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */
|
||||||
|
export function isIgnoredPath(path: string): boolean {
|
||||||
|
return path.split('/').some(seg => seg === 'tests')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。
|
||||||
|
*
|
||||||
|
* 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。
|
||||||
|
*/
|
||||||
|
export function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode {
|
||||||
|
const root = newDirNode()
|
||||||
|
for (const f of files) {
|
||||||
|
const segs = f.path.split('/')
|
||||||
|
const fileName = segs[segs.length - 1]
|
||||||
|
let node = root
|
||||||
|
for (let i = 0; i < segs.length - 1; i++) {
|
||||||
|
const seg = segs[i]
|
||||||
|
let child = node.dirs.get(seg)
|
||||||
|
if (!child) {
|
||||||
|
child = newDirNode()
|
||||||
|
node.dirs.set(seg, child)
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
}
|
||||||
|
node.files.push({ name: fileName, path: f.path, status: f.status })
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */
|
||||||
|
function collectDescendantFiles(node: DirNode): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const walk = (n: DirNode) => {
|
||||||
|
for (const f of n.files)
|
||||||
|
out.push(f.path)
|
||||||
|
for (const child of n.dirs.values())
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
walk(node)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFS 拍平目录树成可渲染行列表,并感知折叠状态。
|
||||||
|
*
|
||||||
|
* - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时,
|
||||||
|
* 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径
|
||||||
|
* (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。
|
||||||
|
* - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。
|
||||||
|
* - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。
|
||||||
|
*/
|
||||||
|
export function flattenTree(root: DirNode, collapsed: ReadonlySet<string>): TreeRow[] {
|
||||||
|
const rows: TreeRow[] = []
|
||||||
|
// prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。
|
||||||
|
const emit = (node: DirNode, depth: number, prefix: string) => {
|
||||||
|
const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b))
|
||||||
|
for (const name of dirNames) {
|
||||||
|
// 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来;
|
||||||
|
// path 同步累计每个被合并的段,作为折叠状态的唯一标识。
|
||||||
|
let label = name
|
||||||
|
let path = prefix + name
|
||||||
|
let cur = node.dirs.get(name)!
|
||||||
|
while (cur.files.length === 0 && cur.dirs.size === 1) {
|
||||||
|
const [childName, childNode] = [...cur.dirs.entries()][0]
|
||||||
|
label += `/${childName}`
|
||||||
|
path += `/${childName}`
|
||||||
|
cur = childNode
|
||||||
|
}
|
||||||
|
const isCollapsed = collapsed.has(path)
|
||||||
|
rows.push({
|
||||||
|
kind: 'dir',
|
||||||
|
name: label,
|
||||||
|
depth,
|
||||||
|
path,
|
||||||
|
collapsed: isCollapsed,
|
||||||
|
descendantFiles: collectDescendantFiles(cur),
|
||||||
|
})
|
||||||
|
// 折叠则隐藏其下所有内容,不再递归。
|
||||||
|
if (!isCollapsed)
|
||||||
|
emit(cur, depth + 1, `${path}/`)
|
||||||
|
}
|
||||||
|
const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
for (const f of sortedFiles)
|
||||||
|
rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status })
|
||||||
|
}
|
||||||
|
emit(root, 0, '')
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「提交」tab 的订阅 hook:按工单号拉取 PR 提交、按需拉提交内文件,并触发原生
|
|
||||||
* diff。数据全程实时从扩展侧(Gitea API)取,本 hook 只做缓存与订阅。
|
|
||||||
*
|
|
||||||
* - issueNumber 变化(含首次)时 postMessage `pr-commits/get`,并清空上一工单的
|
|
||||||
* 文件缓存与 parentSha 映射,避免串台。
|
|
||||||
* - 订阅 `pr-commits/show` / `pr-commit-files/show`,只认 issueNumber 匹配的消息
|
|
||||||
* (扩展侧可能并发多工单的回包)。
|
|
||||||
* - filesBySha 缓存:同一提交点开第二次不再发请求。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PrCommit, PrCommitFile } from '../lib/messages'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { onMessage, postMessage } from '../lib/vscode'
|
|
||||||
|
|
||||||
export interface UsePrCommitsResult {
|
|
||||||
commits: PrCommit[]
|
|
||||||
commitsError: string | undefined
|
|
||||||
loadingCommits: boolean
|
|
||||||
filesBySha: Record<string, PrCommitFile[]>
|
|
||||||
parentShaBySha: Record<string, string | undefined>
|
|
||||||
filesErrorBySha: Record<string, string | undefined>
|
|
||||||
confirmedBySha: Record<string, string[]>
|
|
||||||
confirmedCommits: string[]
|
|
||||||
getFiles: (sha: string) => void
|
|
||||||
openDiff: (sha: string, parentSha: string | undefined, path: string, status: string) => void
|
|
||||||
setConfirmed: (sha: string, paths: string[]) => void
|
|
||||||
markCommitsConfirmed: (shas: string[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePrCommits(issueNumber: number | undefined): UsePrCommitsResult {
|
|
||||||
const [commits, setCommits] = useState<PrCommit[]>([])
|
|
||||||
const [commitsError, setCommitsError] = useState<string | undefined>(undefined)
|
|
||||||
const [loadingCommits, setLoadingCommits] = useState(false)
|
|
||||||
const [filesBySha, setFilesBySha] = useState<Record<string, PrCommitFile[]>>({})
|
|
||||||
const [parentShaBySha, setParentShaBySha] = useState<Record<string, string | undefined>>({})
|
|
||||||
const [filesErrorBySha, setFilesErrorBySha] = useState<Record<string, string | undefined>>({})
|
|
||||||
const [confirmedBySha, setConfirmedBySha] = useState<Record<string, string[]>>({})
|
|
||||||
const [confirmedCommits, setConfirmedCommits] = useState<string[]>([])
|
|
||||||
|
|
||||||
// issueNumber 装进 ref,让订阅闭包始终读到当前值(订阅只在 mount 挂一次)。
|
|
||||||
const issueRef = useRef<number | undefined>(issueNumber)
|
|
||||||
issueRef.current = issueNumber
|
|
||||||
|
|
||||||
// 已发出 files 请求的 sha 集合:防同一提交并发重复请求(缓存到达前的窗口)。
|
|
||||||
const requestedRef = useRef<Set<string>>(new Set())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = onMessage((msg) => {
|
|
||||||
if (msg.type === 'pr-commits/show') {
|
|
||||||
if (msg.issueNumber !== issueRef.current)
|
|
||||||
return
|
|
||||||
setCommits(msg.commits)
|
|
||||||
setCommitsError(msg.error)
|
|
||||||
setConfirmedCommits(msg.confirmedCommits ?? [])
|
|
||||||
setLoadingCommits(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.type === 'pr-commit-files/show') {
|
|
||||||
if (msg.issueNumber !== issueRef.current)
|
|
||||||
return
|
|
||||||
setFilesBySha(prev => ({ ...prev, [msg.sha]: msg.files }))
|
|
||||||
setParentShaBySha(prev => ({ ...prev, [msg.sha]: msg.parentSha }))
|
|
||||||
setFilesErrorBySha(prev => ({ ...prev, [msg.sha]: msg.error }))
|
|
||||||
setConfirmedBySha(prev => ({ ...prev, [msg.sha]: msg.confirmed ?? [] }))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return cleanup
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 切工单:清空缓存与已请求集合,重新拉提交列表。无工单时只清空。
|
|
||||||
useEffect(() => {
|
|
||||||
setCommits([])
|
|
||||||
setCommitsError(undefined)
|
|
||||||
setFilesBySha({})
|
|
||||||
setParentShaBySha({})
|
|
||||||
setFilesErrorBySha({})
|
|
||||||
setConfirmedBySha({})
|
|
||||||
setConfirmedCommits([])
|
|
||||||
requestedRef.current = new Set()
|
|
||||||
if (issueNumber === undefined) {
|
|
||||||
setLoadingCommits(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoadingCommits(true)
|
|
||||||
postMessage({ type: 'pr-commits/get', issueNumber })
|
|
||||||
}, [issueNumber])
|
|
||||||
|
|
||||||
const getFiles = useCallback((sha: string): void => {
|
|
||||||
const num = issueRef.current
|
|
||||||
if (num === undefined)
|
|
||||||
return
|
|
||||||
if (requestedRef.current.has(sha))
|
|
||||||
return
|
|
||||||
requestedRef.current.add(sha)
|
|
||||||
postMessage({ type: 'pr-commit-files/get', issueNumber: num, sha })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const openDiff = useCallback(
|
|
||||||
(sha: string, parentSha: string | undefined, path: string, status: string): void => {
|
|
||||||
const num = issueRef.current
|
|
||||||
if (num === undefined)
|
|
||||||
return
|
|
||||||
postMessage({ type: 'pr-commit-diff/open', issueNumber: num, sha, parentSha, path, status })
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
// 乐观更新本地确认态并落盘到扩展侧;下次拉文件回包会以持久化值校正。
|
|
||||||
const setConfirmed = useCallback((sha: string, paths: string[]): void => {
|
|
||||||
const num = issueRef.current
|
|
||||||
if (num === undefined)
|
|
||||||
return
|
|
||||||
setConfirmedBySha(prev => ({ ...prev, [sha]: paths }))
|
|
||||||
postMessage({ type: 'pr-review/set', issueNumber: num, sha, confirmed: paths })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 乐观更新本地已确认提交集并落盘;下次拉提交列表回包会以持久化值校正。
|
|
||||||
const markCommitsConfirmed = useCallback((shas: string[]): void => {
|
|
||||||
const num = issueRef.current
|
|
||||||
if (num === undefined)
|
|
||||||
return
|
|
||||||
setConfirmedCommits(shas)
|
|
||||||
postMessage({ type: 'pr-review/set-commits', issueNumber: num, confirmed: shas })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
commits,
|
|
||||||
commitsError,
|
|
||||||
loadingCommits,
|
|
||||||
filesBySha,
|
|
||||||
parentShaBySha,
|
|
||||||
filesErrorBySha,
|
|
||||||
confirmedBySha,
|
|
||||||
confirmedCommits,
|
|
||||||
getFiles,
|
|
||||||
openDiff,
|
|
||||||
setConfirmed,
|
|
||||||
markCommitsConfirmed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 「改动」tab 的订阅 hook:按工单号拉取整个 PR 的改动文件清单,并按需取单个文件
|
||||||
|
* 改动前后的原文(供 git-diff-view 内联渲染)。数据全程实时从扩展侧(Gitea)取,
|
||||||
|
* 本 hook 只做缓存与订阅。
|
||||||
|
*
|
||||||
|
* - issueNumber 变化(含首次)时 postMessage `pr-files/get`,并清空上一工单缓存。
|
||||||
|
* - 订阅 `pr-files/show` / `pr-file-diff/show`,只认 issueNumber 匹配的消息
|
||||||
|
* (扩展侧可能并发多工单的回包)。
|
||||||
|
* - diffByPath 缓存:同一文件点开第二次不再发请求。
|
||||||
|
* - 已确认(已查看)按 head sha 落盘,故 setConfirmed 要带当前 headSha。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrFile } from '../lib/messages'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { onMessage, postMessage } from '../lib/vscode'
|
||||||
|
|
||||||
|
/** 单个文件改动前后的原文 + 语言推断;error 表示该文件取内容失败。 */
|
||||||
|
export interface FileDiff {
|
||||||
|
oldContent: string
|
||||||
|
newContent: string
|
||||||
|
oldLang?: string
|
||||||
|
newLang?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePrFilesResult {
|
||||||
|
files: PrFile[]
|
||||||
|
headSha: string
|
||||||
|
filesError: string | undefined
|
||||||
|
loading: boolean
|
||||||
|
confirmed: string[]
|
||||||
|
/** key ∈ diffByPath 即「已请求/已到」;值为内容。仅 key 在但值 undefined 不会出现。 */
|
||||||
|
diffByPath: Record<string, FileDiff>
|
||||||
|
getFileDiff: (path: string, previousPath?: string) => void
|
||||||
|
setConfirmed: (paths: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||||
|
const [files, setFiles] = useState<PrFile[]>([])
|
||||||
|
const [headSha, setHeadSha] = useState<string>('')
|
||||||
|
const [filesError, setFilesError] = useState<string | undefined>(undefined)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [confirmed, setConfirmedState] = useState<string[]>([])
|
||||||
|
const [diffByPath, setDiffByPath] = useState<Record<string, FileDiff>>({})
|
||||||
|
|
||||||
|
// issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。
|
||||||
|
const issueRef = useRef<number | undefined>(issueNumber)
|
||||||
|
issueRef.current = issueNumber
|
||||||
|
const headShaRef = useRef<string>('')
|
||||||
|
headShaRef.current = headSha
|
||||||
|
|
||||||
|
// 已发出 diff 请求的 path 集合:防同一文件并发重复请求(缓存到达前的窗口)。
|
||||||
|
const requestedRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = onMessage((msg) => {
|
||||||
|
if (msg.type === 'pr-files/show') {
|
||||||
|
if (msg.issueNumber !== issueRef.current)
|
||||||
|
return
|
||||||
|
setFiles(msg.files)
|
||||||
|
setHeadSha(msg.headSha)
|
||||||
|
setConfirmedState(msg.confirmed ?? [])
|
||||||
|
setFilesError(msg.error)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'pr-file-diff/show') {
|
||||||
|
if (msg.issueNumber !== issueRef.current)
|
||||||
|
return
|
||||||
|
setDiffByPath(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.path]: {
|
||||||
|
oldContent: msg.oldContent,
|
||||||
|
newContent: msg.newContent,
|
||||||
|
oldLang: msg.oldLang,
|
||||||
|
newLang: msg.newLang,
|
||||||
|
error: msg.error,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return cleanup
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 切工单:清空缓存与已请求集合,重新拉文件清单。无工单时只清空。
|
||||||
|
useEffect(() => {
|
||||||
|
setFiles([])
|
||||||
|
setHeadSha('')
|
||||||
|
setFilesError(undefined)
|
||||||
|
setConfirmedState([])
|
||||||
|
setDiffByPath({})
|
||||||
|
requestedRef.current = new Set()
|
||||||
|
if (issueNumber === undefined) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
postMessage({ type: 'pr-files/get', issueNumber })
|
||||||
|
}, [issueNumber])
|
||||||
|
|
||||||
|
const getFileDiff = useCallback((path: string, previousPath?: string): void => {
|
||||||
|
const num = issueRef.current
|
||||||
|
if (num === undefined)
|
||||||
|
return
|
||||||
|
if (requestedRef.current.has(path))
|
||||||
|
return
|
||||||
|
requestedRef.current.add(path)
|
||||||
|
postMessage({ type: 'pr-file-diff/get', issueNumber: num, path, previousPath })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 乐观更新本地确认态并落盘(键含当前 headSha);下次拉文件回包会以持久化值校正。
|
||||||
|
const setConfirmed = useCallback((paths: string[]): void => {
|
||||||
|
const num = issueRef.current
|
||||||
|
if (num === undefined)
|
||||||
|
return
|
||||||
|
setConfirmedState(paths)
|
||||||
|
postMessage({ type: 'pr-review/set', issueNumber: num, headSha: headShaRef.current, confirmed: paths })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
headSha,
|
||||||
|
filesError,
|
||||||
|
loading,
|
||||||
|
confirmed,
|
||||||
|
diffByPath,
|
||||||
|
getFileDiff,
|
||||||
|
setConfirmed,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,18 +11,16 @@ export interface ToastLink {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PR 提交的精简视图(提交列表渲染用)。 */
|
/**
|
||||||
export interface PrCommit {
|
* PR 改动里的一个文件:status 取 added/modified/deleted/renamed/copied 等,
|
||||||
sha: string
|
* additions/deletions 求和成统计条,previousFilename 在改名时给出旧路径。
|
||||||
message: string
|
*/
|
||||||
authorName: string
|
export interface PrFile {
|
||||||
date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交内单个文件改动:status 取 added/modified/deleted/renamed/copied 等。 */
|
|
||||||
export interface PrCommitFile {
|
|
||||||
path: string
|
path: string
|
||||||
status: string
|
status: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
previousFilename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToastLevel = 'info' | 'success' | 'error'
|
export type ToastLevel = 'info' | 'success' | 'error'
|
||||||
@@ -117,8 +115,8 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
| { type: 'managed-sessions/show', data: ManagedSessionsData }
|
| { type: 'managed-sessions/show', data: ManagedSessionsData }
|
||||||
| { type: 'pr-commits/show', issueNumber: number, commits: PrCommit[], confirmedCommits: string[], error?: string }
|
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
|
||||||
| { type: 'pr-commit-files/show', issueNumber: number, sha: string, parentSha?: string, files: PrCommitFile[], confirmed: string[], error?: string }
|
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
|
||||||
|
|
||||||
export type WebviewToExtension
|
export type WebviewToExtension
|
||||||
= | { type: 'issues/refresh' }
|
= | { type: 'issues/refresh' }
|
||||||
@@ -189,8 +187,6 @@ export type WebviewToExtension
|
|||||||
| { type: 'managed-sessions/resume', sessionId: string }
|
| { type: 'managed-sessions/resume', sessionId: string }
|
||||||
| { type: 'managed-sessions/delete', sessionId: string }
|
| { type: 'managed-sessions/delete', sessionId: string }
|
||||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||||
| { type: 'pr-commits/get', issueNumber: number }
|
| { type: 'pr-files/get', issueNumber: number }
|
||||||
| { type: 'pr-commit-files/get', issueNumber: number, sha: string }
|
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||||
| { type: 'pr-commit-diff/open', issueNumber: number, sha: string, parentSha?: string, path: string, status: string }
|
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }
|
||||||
| { type: 'pr-review/set', issueNumber: number, sha: string, confirmed: string[] }
|
|
||||||
| { type: 'pr-review/set-commits', issueNumber: number, confirmed: string[] }
|
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// 让 tsc 认识副作用式 CSS 导入(如 git-diff-view 的样式表);Vite 负责真正打包。
|
||||||
|
declare module '*.css'
|
||||||
Reference in New Issue
Block a user