@l1yp/file-viewer 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +57 -6
  2. package/dist/assets/step.worker-CfDTYOf_.js.map +1 -0
  3. package/dist/components/AnsiText.d.ts +47 -0
  4. package/dist/components/AnsiText.js +41 -0
  5. package/dist/components/AnsiText.js.map +1 -0
  6. package/dist/components/CertViewer.js +1 -1
  7. package/dist/components/CertViewer.js.map +1 -1
  8. package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/CodeView.js +1 -1
  10. package/dist/components/CodeView.js.map +1 -1
  11. package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js +80 -71
  12. package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js.map +1 -1
  13. package/dist/components/DocxEmbeddedFiles.js +9 -0
  14. package/dist/components/DocxEmbeddedFiles.js.map +1 -0
  15. package/dist/components/DocxEmbeddedFiles.vue.d.ts +15 -0
  16. package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js +69 -0
  17. package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js.map +1 -0
  18. package/dist/components/DocxNavigationPane.js +1 -1
  19. package/dist/components/DocxNavigationPane.js.map +1 -1
  20. package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  21. package/dist/components/DocxViewer.js +1 -1
  22. package/dist/components/DocxViewer.js.map +1 -1
  23. package/dist/components/DocxViewer.vue.d.ts +1 -1
  24. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +182 -50
  25. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/EplViewer.js +1 -1
  27. package/dist/components/EplViewer.js.map +1 -1
  28. package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  29. package/dist/components/FileViewerBody.js +1 -1
  30. package/dist/components/FileViewerBody.js.map +1 -1
  31. package/dist/components/FileViewerBody.vue.d.ts +1 -0
  32. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +292 -281
  33. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
  34. package/dist/components/FileViewerPanel.js +1 -1
  35. package/dist/components/FileViewerPanel.js.map +1 -1
  36. package/dist/components/FileViewerPanel.vue.d.ts +6 -1
  37. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +53 -51
  38. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  39. package/dist/components/HarViewer.js +1 -1
  40. package/dist/components/HarViewer.js.map +1 -1
  41. package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  42. package/dist/components/JksViewer.js +1 -1
  43. package/dist/components/JksViewer.js.map +1 -1
  44. package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  45. package/dist/components/LogViewer.js +1 -1
  46. package/dist/components/LogViewer.js.map +1 -1
  47. package/dist/components/LogViewer.vue.d.ts +3 -1
  48. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js +284 -280
  49. package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  50. package/dist/components/ModelViewModes.js +9 -0
  51. package/dist/components/ModelViewModes.js.map +1 -0
  52. package/dist/components/ModelViewModes.vue.d.ts +11 -0
  53. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js +49 -0
  54. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js.map +1 -0
  55. package/dist/components/ModelViewer.js +1 -1
  56. package/dist/components/ModelViewer.js.map +1 -1
  57. package/dist/components/ModelViewer.vue.d.ts +2 -1
  58. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +183 -96
  59. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  60. package/dist/components/PdfNavigationPane.js +1 -1
  61. package/dist/components/PdfNavigationPane.js.map +1 -1
  62. package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  63. package/dist/components/PdfViewer.js +1 -1
  64. package/dist/components/PdfViewer.js.map +1 -1
  65. package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/components/PptxNavigationPane.js +1 -1
  67. package/dist/components/PptxNavigationPane.js.map +1 -1
  68. package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  69. package/dist/components/PsdViewer.js +1 -1
  70. package/dist/components/PsdViewer.js.map +1 -1
  71. package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  72. package/dist/components/SqliteViewer.js +1 -1
  73. package/dist/components/SqliteViewer.js.map +1 -1
  74. package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  75. package/dist/components/ViewerLoadError.js +9 -0
  76. package/dist/components/ViewerLoadError.js.map +1 -0
  77. package/dist/components/ViewerLoadError.vue.d.ts +17 -0
  78. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js +58 -0
  79. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js.map +1 -0
  80. package/dist/components/XlsxChart.js +1 -1
  81. package/dist/components/XlsxChart.js.map +1 -1
  82. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +9 -9
  83. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
  84. package/dist/components/XlsxImageLayer.js +9 -0
  85. package/dist/components/XlsxImageLayer.js.map +1 -0
  86. package/dist/components/XlsxImageLayer.vue.d.ts +9 -0
  87. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js +46 -0
  88. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js.map +1 -0
  89. package/dist/components/XlsxViewer.js +1 -1
  90. package/dist/components/XlsxViewer.js.map +1 -1
  91. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +230 -216
  92. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  93. package/dist/components/epl/EplResource.js +1 -1
  94. package/dist/components/epl/EplResource.js.map +1 -1
  95. package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -1
  96. package/dist/components/optionalViewer.d.ts +3 -2
  97. package/dist/components/optionalViewer.js +6 -3
  98. package/dist/components/optionalViewer.js.map +1 -1
  99. package/dist/composables/useFileViewer.d.ts +11 -3
  100. package/dist/composables/useFileViewer.js.map +1 -1
  101. package/dist/composables/useTeleportTheme.d.ts +7 -0
  102. package/dist/composables/useTeleportTheme.js +42 -0
  103. package/dist/composables/useTeleportTheme.js.map +1 -0
  104. package/dist/index.d.ts +1 -0
  105. package/dist/index.js +29 -28
  106. package/dist/lib/ansi.d.ts +44 -0
  107. package/dist/lib/ansi.js +224 -0
  108. package/dist/lib/ansi.js.map +1 -0
  109. package/dist/lib/cfb.d.ts +37 -0
  110. package/dist/lib/cfb.js +112 -0
  111. package/dist/lib/cfb.js.map +1 -0
  112. package/dist/lib/docxEmbeddedDom.d.ts +22 -0
  113. package/dist/lib/docxEmbeddedDom.js +128 -0
  114. package/dist/lib/docxEmbeddedDom.js.map +1 -0
  115. package/dist/lib/docxEmbeddings.d.ts +41 -0
  116. package/dist/lib/docxEmbeddings.js +226 -0
  117. package/dist/lib/docxEmbeddings.js.map +1 -0
  118. package/dist/lib/docxTheme.d.ts +6 -0
  119. package/dist/lib/docxTheme.js +64 -0
  120. package/dist/lib/docxTheme.js.map +1 -0
  121. package/dist/lib/epl/code/structures.js +82 -0
  122. package/dist/lib/epl/code/structures.js.map +1 -0
  123. package/dist/lib/epl/code/variable.js +16 -0
  124. package/dist/lib/epl/code/variable.js.map +1 -0
  125. package/dist/lib/epl/eplDocument.js +28 -22
  126. package/dist/lib/epl/eplDocument.js.map +1 -1
  127. package/dist/lib/epl/index.d.ts +2 -1
  128. package/dist/lib/epl/index.js.map +1 -1
  129. package/dist/lib/epl/sections/code.js +37 -0
  130. package/dist/lib/epl/sections/code.js.map +1 -0
  131. package/dist/lib/epl/sections/esystemInfo.js +29 -0
  132. package/dist/lib/epl/sections/esystemInfo.js.map +1 -0
  133. package/dist/lib/epl/sections/losable.js +33 -0
  134. package/dist/lib/epl/sections/losable.js.map +1 -0
  135. package/dist/lib/epl/sections/projectConfig.js +39 -0
  136. package/dist/lib/epl/sections/projectConfig.js.map +1 -0
  137. package/dist/lib/epl/sections/resource.js +84 -0
  138. package/dist/lib/epl/sections/resource.js.map +1 -0
  139. package/dist/lib/fileType.d.ts +3 -1
  140. package/dist/lib/fileType.js +150 -137
  141. package/dist/lib/fileType.js.map +1 -1
  142. package/dist/lib/modelAppearance.d.ts +7 -0
  143. package/dist/lib/modelAppearance.js +41 -0
  144. package/dist/lib/modelAppearance.js.map +1 -0
  145. package/dist/lib/msi.js +62 -154
  146. package/dist/lib/msi.js.map +1 -1
  147. package/dist/lib/olePackage.d.ts +8 -0
  148. package/dist/lib/olePackage.js +37 -0
  149. package/dist/lib/olePackage.js.map +1 -0
  150. package/dist/lib/pptxFirstSlide.d.ts +11 -0
  151. package/dist/lib/pptxFirstSlide.js +34 -0
  152. package/dist/lib/pptxFirstSlide.js.map +1 -0
  153. package/dist/lib/step.d.ts +10 -0
  154. package/dist/lib/step.js +35 -0
  155. package/dist/lib/step.js.map +1 -0
  156. package/dist/lib/step.worker.d.ts +1 -0
  157. package/dist/lib/step.worker.js +18 -0
  158. package/dist/lib/step.worker.js.map +1 -0
  159. package/dist/lib/stepObject.d.ts +4 -0
  160. package/dist/lib/stepObject.js +52 -0
  161. package/dist/lib/stepObject.js.map +1 -0
  162. package/dist/lib/stepTypes.d.ts +60 -0
  163. package/dist/lib/viewerKind.js +10 -5
  164. package/dist/lib/viewerKind.js.map +1 -1
  165. package/dist/lib/xlsx.d.ts +1 -1
  166. package/dist/lib/xlsx.js +106 -89
  167. package/dist/lib/xlsx.js.map +1 -1
  168. package/dist/lib/xlsxCharts.js +71 -119
  169. package/dist/lib/xlsxCharts.js.map +1 -1
  170. package/dist/lib/xlsxImages.d.ts +43 -0
  171. package/dist/lib/xlsxImages.js +114 -0
  172. package/dist/lib/xlsxImages.js.map +1 -0
  173. package/dist/lib/xlsxParts.d.ts +17 -0
  174. package/dist/lib/xlsxParts.js +86 -0
  175. package/dist/lib/xlsxParts.js.map +1 -0
  176. package/dist/style.css +1 -1
  177. package/package.json +8 -3
package/README.md CHANGED
@@ -7,10 +7,14 @@
7
7
  ## 特性
8
8
 
9
9
  - **60+ 格式**:docx/xlsx/pptx/pdf、zip/7z/rar/tar、png/tiff/psd/heic/dcm、csv/jsonl/sqlite、markdown/代码高亮、eml/mht/har、字体/证书/密钥库、epub/mob、drawio/dxf/geojson、hprof/.class/.so/.dll、易语言 .e/.ec 等。
10
+ - **Word 内嵌文件**:识别 DOCX 中被 `docx-preview` 跳过的嵌入包(含直接嵌入的 XLSX 与 OLE 包装的 PPTX),把可用的 Word/WPS 内容快照插回正文原位置;遇到浏览器不支持的 EMF 快照时,按需用 PPTX renderer 渲染第一页。点击正文预览即可打开完整文件,也可下载原始附件。
11
+ - **终端日志还原**:日志和纯文本可解释 ANSI SGR 前景色、背景色、16/256/真彩色及常见文字装饰;搜索、折叠、复制使用净化后的纯文本,其他终端控制命令不会在页面执行。
12
+ - **STEP 三维预览**:`.stp/.step` 在独立 Worker 内解析,保留可用的零件/面颜色,支持旋转、缩放、平移与自动适配视口;可取消,切换文件和关闭预览会终止解析。
13
+ - **三维查看模式**:所有网格模型均支持「原色 / 白模 / 线框」切换。原色保留导入的材质、贴图及顶点/面颜色,白模使用统一浅色哑光材质,线框沿用模型原有的材质颜色、贴图及顶点/面颜色,显示包含背面及已有内部结构的三角网格。切换无需重新下载或解析,保持当前视角;线框展示的是离散后的三角边,不等同于 CAD 特征边或剖切面。
10
14
  - **ZIP 网页预览**:打开压缩包里的 HTML 时,自动解析并加载同包的 JS、CSS、图片、字体等相对依赖;源码保持原样,脚本仍需用户主动开启后才会在不透明源沙箱中运行。
11
15
  - **零后端**:上游字节只在浏览器解析;HTML/SVG 走沙箱 iframe、下载端点无法内联执行。
12
16
  - **按需分包**:每个查看器是独立的懒加载 chunk,打开 PDF 才拉 PDF 引擎;用不到的格式零成本。
13
- - **重型依赖可选**:只装你需要的格式的引擎;没装时显示「请安装 X」提示卡,不崩溃。
17
+ - **重型依赖可选**:只装你需要的格式的引擎;明确缺少依赖时显示安装提示,网络、缓存或模块执行失败时显示真实错误与刷新入口。
14
18
  - **主题可定制**:CSS 变量作用域限定在组件根,不污染宿主;`theme` prop 或覆盖变量即可换肤。
15
19
 
16
20
  ## 安装
@@ -31,6 +35,7 @@ npm i @l1yp/file-viewer vue
31
35
  | PowerPoint `.pptx` | `@aiden0z/pptx-renderer pdfjs-dist` |
32
36
  | SQLite `.sqlite/.db` | `sql.js` |
33
37
  | 3D 模型 `.stl/.obj/.glb/…` | `three` |
38
+ | STEP 模型 `.stp/.step` | `three occt-import-js` |
34
39
  | 地图 `.geojson/.kml/.gpx` | `maplibre-gl` |
35
40
  | Photoshop `.psd` | `ag-psd` |
36
41
  | TIFF `.tif/.tiff` | `utif2` |
@@ -41,10 +46,24 @@ npm i @l1yp/file-viewer vue
41
46
  | Brotli 归档 `.br` | `brotli-dec-wasm` |
42
47
  | 微信/QQ 语音(SILK/AMR) | `silk-wasm @audio/decode-amr` |
43
48
 
44
- 未安装某格式的引擎时,打开该文件会显示一张「预览 X 需要安装 `<pkg>`」的卡片(含一键复制安装命令 + 下载原文件),不会报错崩溃。
49
+ 加载器明确报告缺少某格式的引擎时,打开该文件会显示一张安装提示卡(含一键复制安装命令 + 下载原文件)。模块下载失败、开发缓存失效或模块执行异常会显示「预览暂时无法加载」、错误详情与刷新入口,不会误报需要安装依赖。
45
50
 
46
51
  > **打包器提示**:这些可选依赖在库构建里是 `external`。若你的打包器(Vite/webpack)对未安装的可选依赖在**构建期**报「无法解析」,把你不用的那几个加进 bundler 的 external / `optimizeDeps.exclude` 即可。
47
52
 
53
+ ### STEP / STP
54
+
55
+ ```vue
56
+ <FileViewer :blob="file" name="part.step" />
57
+ <!-- 与其他附件一样支持下载 URL、鉴权 fetchConfig、load 和压缩包内的 Blob URL。 -->
58
+ <FileViewer :src="downloadUrl" name="assembly.stp" :size="fileSize" />
59
+ ```
60
+
61
+ STEP 使用 `occt-import-js`(OpenCascade WASM)进行曲面离散,再交给现有 Three.js 查看器显示。解析器只在打开 STEP 时加载;其 JS/WASM 通过 Vite `?url` 交由宿主打包成静态资源,不访问 CDN、不上传模型、不需要后端转换接口。发布的库内包含 Worker 启动代码,消费者无需复制 Worker 文件或配置固定资源路径;使用其他打包器时需支持这些资源 URL 导入。
62
+
63
+ 超过 **20 MiB** 或大小未知的 STEP 文件需点击后再开始加载,解析任务最多 **120 秒**,可以主动取消;每次解析使用独立 Worker,成功、失败、取消及超时都会终止 Worker 并释放 WASM 内存。复杂装配体的耗时与内存还取决于几何复杂度,大小提示不是兼容性保证。只读预览不提供 CAD 编辑、工程精度测量或装配树操作;输出单位统一为毫米,保留解析器读出的层级、零件名称及颜色。
64
+
65
+ 宿主若配置了严格 CSP,需允许 Blob Worker(`worker-src blob:`)、自托管解析器脚本及 WASM 执行;不依赖 `SharedArrayBuffer` 或 COOP/COEP。`occt-import-js` 为可选外部依赖,使用其 [LGPL-2.1 许可](https://github.com/kovacsv/occt-import-js/blob/main/LICENSE.md),与本包自身的 MIT 许可分开。
66
+
48
67
  ## 使用
49
68
 
50
69
  ```ts
@@ -101,7 +120,7 @@ import {
101
120
 
102
121
  所有样式的 CSS 变量作用域限定在组件根 `.fv-root`,**不碰宿主全局样式**。三种定制方式:
103
122
 
104
- 1. **`theme` prop**:`theme="light | dark | system | inherit"`。`inherit` = 不设主题、直接吃宿主已定义的变量。
123
+ 1. **`theme` prop**:`theme="light | dark | system | inherit"`。`FileViewer` 默认 `system`,跟随系统偏好并实时响应切换;显式 `light` / `dark` 只控制当前实例,可在同一页面并排使用。`inherit` 不声明配色与字体变量,直接继承最近宿主或父查看器的变量及 `color-scheme`。
105
124
  2. **覆盖 CSS 变量**:在你自己的 `.fv-root` 规则里改,例如
106
125
  ```css
107
126
  .fv-root {
@@ -112,20 +131,52 @@ import {
112
131
  3. 复制 `dist` 里的令牌整套替换。
113
132
 
114
133
  > **数学公式**:如需 Markdown 里的数学公式正常显示,宿主需自行 `import 'katex/dist/katex.min.css'`。
115
- > 直接用低层的 `<FileViewerPanel>`(不带 `.fv-root` 包裹)时,请自己定义那批 CSS 变量,或改用带 `.fv-root` 的 `<FileViewer>`。
134
+
135
+ `FileViewerPanel` 同样带 `.fv-root` 并接受上述 `theme` prop,默认 `inherit` 以延续宿主页面主题。单独使用侧栏且宿主未定义变量时,传 `theme="system"`。只用 `FileViewerBody` 时,可在外层添加 `<div class="fv-root" data-theme="system">`,或提供自己的变量。
136
+
137
+ ```vue
138
+ <!-- 宿主维护 light / dark / system 时,直接绑定,主题切换不会重新读取文件。 -->
139
+ <FileViewer :blob="file" :name="file.name" :theme="appTheme" />
140
+
141
+ <!-- 宿主已有完整配色变量时:字体、配色和原生控件都继承宿主。 -->
142
+ <FileViewer :src="url" name="report.csv" theme="inherit" />
143
+ ```
144
+
145
+ 使用 `inherit` 的宿主应同步设置 `color-scheme: light` / `dark`(跟随系统时为 `light dark`),让滚动条、下拉框等原生控件与 CSS 配色一致。代码高亮与 ANSI 基础色随包内样式提供,不再依赖宿主额外导入高亮样式。HTML 全屏工具栏保留来源查看器的主题和自定义变量,并在打开期间响应主题切换。
146
+
147
+ 主题覆盖查看器外壳、导航、工具栏及状态提示。XLSX 的默认表格、行列标题、冻结区域、选区和图表也跟随主题;自动文字色与未调色的 Office 默认文字色(`theme: 1`)随背景切换。文件明确指定的字体色和填充色保持原样,这些单元格缺省的一侧沿用原稿的黑字/白底,避免深色背景与原稿字体冲突;图表中明确指定的数据系列颜色同样保留。
148
+
149
+ DOCX 的黑白正文页会自动适配纸张、文字和表格线,切换主题无需重新解析;图片像素保留原样,透明部分保留原白纸底色。检测到彩色文字、底纹、复杂绘图或生成内容的页面会整页保留原稿,避免局部改色造成黑底黑字或丢失颜色含义。
150
+
151
+ PDF、PPTX、DOCX 的自实现目录、页面导航、页码、选中状态、缩略图容器及外围区域都跟随主题。PDF 和 PPTX 正文及缩略图保留原稿:PDF 的页面配色接口会对整页画布施加滤镜,PPTX 的文件主题与版式配色也不能可靠地当作界面主题替换。RTF 纸张、图片、视频和沙箱网页同样保留原稿配色。
116
152
 
117
153
  ## 开发
118
154
 
119
155
  ```sh
120
156
  pnpm -C packages/file-viewer type-check # vue-tsc 类型检查
121
- pnpm -C packages/file-viewer build # vp build(JS 分包 + style.css)+ vue-tsc(.d.ts)
157
+ pnpm -C packages/file-viewer build # vp build(JS 分包 + style.css)+ 产物冒烟 + vue-tsc(.d.ts)
158
+ ```
159
+
160
+ 构建与 `prepack` 会直接运行易语言解析器的 JS 产物,检查按模块分包后的依赖是否完整,并覆盖源码/模块元数据、GBK 解码和异常类型。也可额外传入本地 `.e` / `.ec` 文件验证(文件仅在本机读取):
161
+
162
+ ```sh
163
+ node packages/file-viewer/scripts/epl-build-smoke.mjs /path/to/example.e
164
+ ```
165
+
166
+ 在本仓库中,STEP 浏览器回归页会用自生成的带面颜色立方体运行真实 OCCT/WASM,覆盖类型识别、毫米尺寸、面颜色、Worker 取消/超时、文件切换与 WebGL 卸载。先构建组件库,再启动 `pnpm dev` 并打开 `/scripts/step-smoke.html`。验证消费者生产打包(包括相对 `base` 下的 Worker/JS/WASM 地址):
167
+
168
+ ```sh
169
+ pnpm exec vp build --config scripts/step-smoke.config.ts
170
+ pnpm exec vp preview --config scripts/step-smoke.config.ts --port 4174
171
+ # 打开 http://localhost:4174/scripts/step-smoke.html,等待 STEP smoke PASSED。
122
172
  ```
123
173
 
124
174
  ## 发布
125
175
 
126
176
  ```sh
127
177
  npm pack --dry-run # 预览将发布的文件(dist + README + LICENSE)
128
- npm publish # publishConfig.access=public,已配好公开发布
178
+ npm login --registry=https://registry.npmjs.org/
179
+ npm publish --registry=https://registry.npmjs.org/ # publishConfig.access=public,已配好公开发布
129
180
  ```
130
181
 
131
182
  `prepack` 脚本会在打包/发布前自动重新构建。发布前记得在 `package.json` 补上 `repository` / `homepage` 字段。
@@ -0,0 +1 @@
1
+ {"version":3,"file":"step.worker-CfDTYOf_.js","names":[],"sources":["../../src/lib/step.worker.ts"],"sourcesContent":["/// <reference lib=\"webworker\" />\nimport type { OcctResult, StepReply, StepRequest, StepModel } from './stepTypes'\n\ndeclare const self: DedicatedWorkerGlobalScope & {\n occtimportjs: (options: {\n locateFile: (path: string) => string\n print: () => void\n printErr: () => void\n }) => Promise<{\n ReadStepFile: (bytes: Uint8Array, params: Record<string, string | number>) => OcctResult\n }>\n}\n\nfunction send(reply: StepReply, transfer: Transferable[] = []): void {\n self.postMessage(reply, transfer)\n}\n\n// One worker per import: terminating it cancels synchronous OCCT work and releases the entire WASM heap.\nself.onmessage = async ({ data }: MessageEvent<StepRequest>) => {\n try {\n send({ type: 'phase', phase: 'engine' })\n // OCCT ships a classic Emscripten script. Keeping it as an asset avoids bundling its Node fs/path branch.\n self.importScripts(data.scriptUrl)\n const occt = await self.occtimportjs({\n locateFile: () => data.wasmUrl,\n print: () => {},\n printErr: () => {},\n })\n send({ type: 'phase', phase: 'parse' })\n const result = occt.ReadStepFile(new Uint8Array(data.buffer), {\n linearUnit: 'millimeter',\n linearDeflectionType: 'bounding_box_ratio',\n linearDeflection: 0.001,\n angularDeflection: 0.5,\n })\n if (!result.success) throw new Error('无法解析 STEP 文件,文件可能已损坏或格式不受支持。')\n if (!result.meshes?.some((mesh) => mesh.index.array.length > 0)) {\n throw new Error('STEP 文件中没有可预览的曲面或实体。')\n }\n // Transfer typed buffers, rather than copying OCCT's large JS number arrays onto the UI thread.\n const transfer: ArrayBuffer[] = []\n const model: StepModel = {\n root: result.root,\n meshes: result.meshes.map((mesh) => {\n const positions = new Float32Array(mesh.attributes.position.array)\n const normals = mesh.attributes.normal\n ? new Float32Array(mesh.attributes.normal.array)\n : undefined\n const indices = new Uint32Array(mesh.index.array)\n transfer.push(positions.buffer, indices.buffer)\n if (normals) transfer.push(normals.buffer)\n return {\n name: mesh.name,\n color: mesh.color,\n faces: mesh.brep_faces ?? [],\n positions,\n normals,\n indices,\n }\n }),\n }\n send({ type: 'result', model }, transfer)\n } catch (error) {\n send({ type: 'error', message: error instanceof Error ? error.message : String(error) })\n }\n}\n"],"mappings":"YAaA,SAAS,EAAK,EAAkB,EAA2B,CAAC,EAAS,CACnE,KAAK,YAAY,EAAO,CAAQ,CAClC,CAGA,KAAK,UAAY,MAAO,CAAE,UAAsC,CAC9D,GAAI,CACF,EAAK,CAAE,KAAM,QAAS,MAAO,QAAS,CAAC,EAEvC,KAAK,cAAc,EAAK,SAAS,EACjC,IAAM,EAAO,MAAM,KAAK,aAAa,CACnC,eAAkB,EAAK,QACvB,UAAa,CAAC,EACd,aAAgB,CAAC,CACnB,CAAC,EACD,EAAK,CAAE,KAAM,QAAS,MAAO,OAAQ,CAAC,EACtC,IAAM,EAAS,EAAK,aAAa,IAAI,WAAW,EAAK,MAAM,EAAG,CAC5D,WAAY,aACZ,qBAAsB,qBACtB,iBAAkB,KAClB,kBAAmB,EACrB,CAAC,EACD,GAAI,CAAC,EAAO,QAAS,MAAU,MAAM,8BAA8B,EACnE,GAAI,CAAC,EAAO,QAAQ,KAAM,GAAS,EAAK,MAAM,MAAM,OAAS,CAAC,EAC5D,MAAU,MAAM,sBAAsB,EAGxC,IAAM,EAA0B,CAAC,EAqBjC,EAAK,CAAE,KAAM,SAAU,MAAA,CAnBrB,KAAM,EAAO,KACb,OAAQ,EAAO,OAAO,IAAK,GAAS,CAClC,IAAM,EAAY,IAAI,aAAa,EAAK,WAAW,SAAS,KAAK,EAC3D,EAAU,EAAK,WAAW,OAC5B,IAAI,aAAa,EAAK,WAAW,OAAO,KAAK,EAC7C,IAAA,GACE,EAAU,IAAI,YAAY,EAAK,MAAM,KAAK,EAGhD,OAFA,EAAS,KAAK,EAAU,OAAQ,EAAQ,MAAM,EAC1C,GAAS,EAAS,KAAK,EAAQ,MAAM,EAClC,CACL,KAAM,EAAK,KACX,MAAO,EAAK,MACZ,MAAO,EAAK,YAAc,CAAC,EAC3B,YACA,UACA,SACF,CACF,CAAC,CAEoB,CAAM,EAAG,CAAQ,CAC1C,OAAS,EAAO,CACd,EAAK,CAAE,KAAM,QAAS,QAAS,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAE,CAAC,CACzF,CACF"}
@@ -0,0 +1,47 @@
1
+ import { type PropType, type VNodeChild } from 'vue';
2
+ import { type AnsiLine } from '@/lib/ansi';
3
+ /**
4
+ * Render one inert ANSI line with Vue text nodes. The parser owns the style whitelist; this component never
5
+ * accepts or emits raw HTML. A query is optionally wrapped in `<mark>` after matching against control-free text.
6
+ */
7
+ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
8
+ line: {
9
+ type: PropType<string | AnsiLine>;
10
+ default: undefined;
11
+ };
12
+ lines: {
13
+ type: PropType<readonly (string | AnsiLine)[]>;
14
+ default: () => never[];
15
+ };
16
+ query: {
17
+ type: StringConstructor;
18
+ default: string;
19
+ };
20
+ markClass: {
21
+ type: StringConstructor;
22
+ default: string;
23
+ };
24
+ }>, () => VNodeChild[], {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
25
+ line: {
26
+ type: PropType<string | AnsiLine>;
27
+ default: undefined;
28
+ };
29
+ lines: {
30
+ type: PropType<readonly (string | AnsiLine)[]>;
31
+ default: () => never[];
32
+ };
33
+ query: {
34
+ type: StringConstructor;
35
+ default: string;
36
+ };
37
+ markClass: {
38
+ type: StringConstructor;
39
+ default: string;
40
+ };
41
+ }>> & Readonly<{}>, {
42
+ line: string | AnsiLine;
43
+ lines: readonly (string | AnsiLine)[];
44
+ query: string;
45
+ markClass: string;
46
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
47
+ export default _default;
@@ -0,0 +1,41 @@
1
+ import { ansiRenderPieces as e } from "../lib/ansi.js";
2
+ import { defineComponent as t, h as n } from "vue";
3
+ //#region src/components/AnsiText.ts
4
+ var r = t({
5
+ name: "AnsiText",
6
+ props: {
7
+ line: {
8
+ type: [String, Object],
9
+ default: void 0
10
+ },
11
+ lines: {
12
+ type: Array,
13
+ default: () => []
14
+ },
15
+ query: {
16
+ type: String,
17
+ default: ""
18
+ },
19
+ markClass: {
20
+ type: String,
21
+ default: "ansi__hit"
22
+ }
23
+ },
24
+ setup(t) {
25
+ return () => {
26
+ let r = t.line === void 0 ? t.lines : [t.line], i = [];
27
+ return r.forEach((a, o) => {
28
+ e(a, t.query).forEach((e, r) => {
29
+ i.push(n("span", {
30
+ key: `${o}:${r}`,
31
+ style: e.style
32
+ }, e.marked ? n("mark", { class: t.markClass }, e.text) : e.text));
33
+ }), o < r.length - 1 && i.push("\n");
34
+ }), i;
35
+ };
36
+ }
37
+ });
38
+ //#endregion
39
+ export { r as default };
40
+
41
+ //# sourceMappingURL=AnsiText.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnsiText.js","names":[],"sources":["../../src/components/AnsiText.ts"],"sourcesContent":["import { defineComponent, h, type PropType, type VNodeChild } from 'vue'\nimport { ansiRenderPieces, type AnsiLine } from '@/lib/ansi'\n\n/**\n * Render one inert ANSI line with Vue text nodes. The parser owns the style whitelist; this component never\n * accepts or emits raw HTML. A query is optionally wrapped in `<mark>` after matching against control-free text.\n */\nexport default defineComponent({\n name: 'AnsiText',\n props: {\n line: { type: [String, Object] as PropType<string | AnsiLine>, default: undefined },\n lines: {\n type: Array as PropType<readonly (string | AnsiLine)[]>,\n default: () => [],\n },\n query: { type: String, default: '' },\n markClass: { type: String, default: 'ansi__hit' },\n },\n setup(props) {\n return (): VNodeChild[] => {\n const lines = props.line !== undefined ? [props.line] : props.lines\n const nodes: VNodeChild[] = []\n lines.forEach((line, lineIndex) => {\n ansiRenderPieces(line, props.query).forEach((piece, pieceIndex) => {\n nodes.push(\n h(\n 'span',\n { key: `${lineIndex}:${pieceIndex}`, style: piece.style },\n piece.marked ? h('mark', { class: props.markClass }, piece.text) : piece.text,\n ),\n )\n })\n if (lineIndex < lines.length - 1) nodes.push('\\n')\n })\n return nodes\n }\n },\n})\n"],"mappings":";;;AAOA,IAAA,IAAe,EAAgB;CAC7B,MAAM;CACN,OAAO;EACL,MAAM;GAAE,MAAM,CAAC,QAAQ,MAAM;GAAkC,SAAS,KAAA;EAAU;EAClF,OAAO;GACL,MAAM;GACN,eAAe,CAAC;EAClB;EACA,OAAO;GAAE,MAAM;GAAQ,SAAS;EAAG;EACnC,WAAW;GAAE,MAAM;GAAQ,SAAS;EAAY;CAClD;CACA,MAAM,GAAO;EACX,aAA2B;GACzB,IAAM,IAAQ,EAAM,SAAS,KAAA,IAA2B,EAAM,QAArB,CAAC,EAAM,IAAI,GAC9C,IAAsB,CAAC;GAa7B,OAZA,EAAM,SAAS,GAAM,MAAc;IAUjC,AATA,EAAiB,GAAM,EAAM,KAAK,CAAC,CAAC,SAAS,GAAO,MAAe;KACjE,EAAM,KACJ,EACE,QACA;MAAE,KAAK,GAAG,EAAU,GAAG;MAAc,OAAO,EAAM;KAAM,GACxD,EAAM,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAM,UAAU,GAAG,EAAM,IAAI,IAAI,EAAM,IAC3E,CACF;IACF,CAAC,GACG,IAAY,EAAM,SAAS,KAAG,EAAM,KAAK,IAAI;GACnD,CAAC,GACM;EACT;CACF;AACF,CAAC"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./CertViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/CertViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-adece100"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-1b2096e6"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"CertViewer.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.cert__badge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.cert__badge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"CertViewer.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.cert__badge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.cert__badge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"CertViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.cert__badge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.cert__badge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"CertViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/CertViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport {\n asn1String,\n asn1Time,\n bytesToHex,\n decodeAsn1,\n oidToString,\n tagName,\n TAG,\n type Asn1Node,\n} from '@/lib/asn1'\nimport {\n looksLikePem,\n oidName,\n parseCertificate,\n parseCsr,\n parsePemBlocks,\n sha256Hex,\n summarizeBlock,\n type CertSummary,\n} from '@/lib/x509'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for certificates / keys (pem / crt / cer / der / csr / key / p12 / pfx). Fetches the BYTES\n * itself (binary-safe — a .der/.p12 would be mangled by the panel's text load), sniffs PEM vs raw DER, and\n * for each block shows the human-meaningful X.509 fields (subject / issuer, validity with an expiry badge,\n * serial, signature + public-key algorithm, SANs, key usages, SHA-256 fingerprint) plus a collapsible raw\n * ASN.1 structure tree. Parsing is hand-rolled and READ-ONLY (lib/asn1.ts + lib/x509.ts) — it never verifies\n * signatures or trust. PKCS#12 (.p12/.pfx) containers are usually encrypted, so they're shown as structure\n * only with a \"use openssl to extract\" note (best effort).\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ninterface CertItem {\n summary: CertSummary\n forest: Asn1Node[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst items = ref<CertItem[]>([])\nconst fingerprints = reactive<Record<number, string>>({})\nconst expanded = reactive(new Set<string>())\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nfunction buildItem(summary: CertSummary): CertItem {\n return { summary, forest: decodeAsn1(summary.der) }\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const ext = fileExt(props.name)\n const head = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(64, bytes.length)))\n const list: CertItem[] = []\n\n if (head.includes('-----BEGIN') || looksLikePem(new TextDecoder('latin1').decode(bytes))) {\n const text = new TextDecoder('utf-8').decode(bytes)\n for (const block of parsePemBlocks(text))\n list.push(buildItem(summarizeBlock(block.label, block.der)))\n }\n if (!list.length) {\n // Raw DER (or a PEM that yielded no blocks). Pick a parser from the extension.\n if (ext === 'p12' || ext === 'pfx') {\n const s = summarizeP12(bytes)\n list.push(buildItem(s))\n } else if (ext === 'csr' || ext === 'req') {\n list.push(buildItem(parseCsr(bytes)))\n } else {\n const cert = parseCertificate(bytes)\n list.push(buildItem(cert.ok ? cert : fallbackStructure(bytes, ext)))\n }\n }\n\n items.value = list\n status.value = 'ready'\n // Fingerprint each certificate asynchronously (SHA-256 of its DER).\n list.forEach((it, i) => {\n if (it.summary.kind === 'certificate' && it.summary.ok) {\n void sha256Hex(it.summary.der)\n .then((hex) => {\n fingerprints[i] = hex\n })\n .catch(() => {})\n }\n })\n // Open the first level of the first item so there's something to see without a click.\n if (list.length && list[0].forest.length) expanded.add('0.0')\n}\n\nfunction summarizeP12(bytes: Uint8Array): CertSummary {\n return {\n ok: true,\n kind: 'pkcs12',\n label: 'PKCS#12',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: 'PKCS#12 容器通常已加密,浏览器内仅展示 ASN.1 结构;如需提取证书/私钥请用 openssl pkcs12 -info。',\n }\n}\n\nfunction fallbackStructure(bytes: Uint8Array, ext: string): CertSummary {\n return {\n ok: true,\n kind: 'unknown',\n label: ext ? `.${ext}` : '二进制',\n subject: [],\n issuer: [],\n sans: [],\n keyUsages: [],\n extKeyUsages: [],\n der: bytes,\n note: '无法识别为 X.509 证书 —— 展开下方结构树查看原始 ASN.1。',\n }\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n items.value = []\n expanded.clear()\n for (const k of Object.keys(fingerprints)) delete fingerprints[Number(k)]\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\n// ── friendly-field formatting ────────────────────────────────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst KIND_LABEL: Record<CertSummary['kind'], string> = {\n certificate: '证书',\n request: '证书请求 (CSR)',\n key: '密钥',\n crl: 'CRL',\n pkcs12: 'PKCS#12',\n pkcs7: 'PKCS#7',\n unknown: '未知',\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'cert__badge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'cert__badge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'cert__badge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'cert__badge--ok' }\n}\n\n// ── ASN.1 structure tree (flattened with an expand set) ──────────────────────────────────────────\ninterface TreeRow {\n node: Asn1Node\n depth: number\n path: string\n hasChildren: boolean\n}\n\nfunction flatten(forest: Asn1Node[], prefix: string): TreeRow[] {\n const rows: TreeRow[] = []\n const walk = (nodes: Asn1Node[], depth: number, pre: string): void => {\n nodes.forEach((node, i) => {\n const path = `${pre}.${i}`\n const hasChildren = node.children.length > 0\n rows.push({ node, depth, path, hasChildren })\n if (hasChildren && expanded.has(path)) walk(node.children, depth + 1, path)\n })\n }\n walk(forest, 0, prefix)\n return rows\n}\n\nconst treeRows = computed(() => items.value.map((it, i) => flatten(it.forest, String(i))))\n\nfunction toggleNode(path: string): void {\n if (expanded.has(path)) expanded.delete(path)\n else expanded.add(path)\n}\n\n// A short, human value preview for a primitive node (constructed nodes show only their tag).\nfunction nodePreview(node: Asn1Node): string {\n if (node.constructed) return ''\n switch (node.tagNumber) {\n case TAG.OID: {\n const oid = oidToString(node.content)\n const nm = oidName(oid)\n return nm === oid ? oid : `${nm} (${oid})`\n }\n case TAG.BOOLEAN:\n return node.content[0] ? 'true' : 'false'\n case TAG.INTEGER: {\n if (node.content.length <= 4) {\n let v = 0\n for (const b of node.content) v = v * 256 + b\n return String(v)\n }\n return `0x${bytesToHex(node.content).slice(0, 32)}${node.content.length > 16 ? '…' : ''}`\n }\n case TAG.UTF8String:\n case TAG.PrintableString:\n case TAG.IA5String:\n return asn1String(node)\n case TAG.UTCTime:\n case TAG.GeneralizedTime:\n return asn1Time(node)?.text ?? asn1String(node)\n case TAG.NULL:\n return ''\n default: {\n if (node.tagClass === 2) {\n // context-specific primitive (e.g. a SAN name) — show as text if printable, else hex\n const txt = new TextDecoder('latin1').decode(node.content)\n if (/^[\\x20-\\x7e]*$/.test(txt)) return txt\n }\n const hex = bytesToHex(node.content)\n return hex.length > 32 ? `${hex.slice(0, 32)}… (${node.length}B)` : hex\n }\n }\n}\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"cert\">\n <p v-if=\"status === 'loading'\" class=\"cert__msg\">正在解析证书…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"cert__msg cert__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"cert__msg cert__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"cert__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else class=\"cert__scroll\">\n <section v-for=\"(it, i) in items\" :key=\"i\" class=\"cert__card\">\n <header class=\"cert__cardhead\">\n <span class=\"cert__kind\">{{ KIND_LABEL[it.summary.kind] }}</span>\n <span class=\"cert__label\">{{ it.summary.subjectCN || it.summary.label }}</span>\n <span v-if=\"expiry(it.summary)\" class=\"cert__badge\" :class=\"expiry(it.summary)?.cls\">\n {{ expiry(it.summary)?.text }}\n </span>\n </header>\n\n <p v-if=\"it.summary.note\" class=\"cert__note\">{{ it.summary.note }}</p>\n\n <dl v-if=\"it.summary.subject.length || it.summary.kind === 'certificate'\" class=\"cert__dl\">\n <template v-if=\"it.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(it.summary.subject) }}</dd>\n </template>\n <template v-if=\"it.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(it.summary.issuer) }}</dd>\n </template>\n <template v-if=\"it.summary.notBefore || it.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ it.summary.notBefore ?? '?' }} → {{ it.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(it.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(it.summary) }}</dd>\n </template>\n <template v-if=\"it.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ it.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"it.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"cert__mono\">{{ it.summary.serialHex }}</dd>\n </template>\n <template v-if=\"it.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ it.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.keyUsages.length\">\n <dt>密钥用法</dt>\n <dd>{{ it.summary.keyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.extKeyUsages.length\">\n <dt>扩展用法</dt>\n <dd>{{ it.summary.extKeyUsages.join(',') }}</dd>\n </template>\n <template v-if=\"it.summary.isCa != null\">\n <dt>CA</dt>\n <dd>\n {{ it.summary.isCa ? '是' : '否'\n }}{{ it.summary.pathLen != null ? `(pathLen ${it.summary.pathLen})` : '' }}\n </dd>\n </template>\n <template v-if=\"fingerprints[i]\">\n <dt>SHA-256</dt>\n <dd class=\"cert__mono cert__fp\">{{ fingerprints[i] }}</dd>\n </template>\n </dl>\n\n <details class=\"cert__tree\">\n <summary class=\"cert__treehead\">ASN.1 结构</summary>\n <div class=\"cert__rows\">\n <button\n v-for=\"row in treeRows[i]\"\n :key=\"row.path\"\n type=\"button\"\n class=\"cert__row\"\n :style=\"{ paddingLeft: `${8 + row.depth * 16}px` }\"\n @click=\"row.hasChildren && toggleNode(row.path)\"\n >\n <span class=\"cert__twisty\">\n <Icon\n v-if=\"row.hasChildren\"\n name=\"chevronRight\"\n :size=\"12\"\n :class=\"{ 'cert__twisty--open': expanded.has(row.path) }\"\n />\n </span>\n <span class=\"cert__tag\">{{ tagName(row.node) }}</span>\n <span v-if=\"nodePreview(row.node)\" class=\"cert__val\">{{\n nodePreview(row.node)\n }}</span>\n </button>\n </div>\n </details>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cert {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.cert__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.cert__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.cert__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.cert__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.cert__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.cert__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n.cert__card {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.cert__cardhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.cert__kind {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n border-radius: 5px;\n padding: 2px 7px;\n}\n.cert__label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.cert__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.cert__badge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.cert__badge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.cert__badge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n\n.cert__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n background: color-mix(in srgb, var(--accent) 5%, transparent);\n border-bottom: 1px solid var(--border);\n}\n\n.cert__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 12px;\n}\n.cert__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.cert__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.cert__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.cert__fp {\n word-break: break-all;\n}\n\n.cert__tree {\n border-top: 1px solid var(--border);\n}\n.cert__treehead {\n padding: 8px 12px;\n font-size: 12px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n}\n.cert__treehead:hover {\n color: var(--accent);\n}\n.cert__rows {\n padding: 4px 0 10px;\n background: var(--code-bg);\n border-top: 1px solid var(--code-border);\n overflow-x: auto;\n}\n.cert__row {\n display: flex;\n align-items: center;\n gap: 7px;\n width: 100%;\n padding: 3px 12px 3px 8px;\n background: transparent;\n border: none;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text);\n text-align: left;\n white-space: nowrap;\n cursor: default;\n}\n.cert__row:hover {\n background: var(--surface-2);\n}\n.cert__twisty {\n flex-shrink: 0;\n display: inline-flex;\n width: 12px;\n color: var(--faint);\n}\n.cert__twisty--open {\n transform: rotate(90deg);\n transition: transform 0.12s;\n}\n.cert__tag {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cert__val {\n min-width: 0;\n color: var(--muted);\n}\n</style>\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./CodeView.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/CodeView.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-f432e526"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-b7b2f434"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"CodeView.js","names":[],"sources":["../../src/components/CodeView.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { escapeHtml, highlightCode, highlightLines } from '@/lib/highlight'\nimport { computeFoldRegions } from '@/lib/codefold'\nimport { canFormat, formatCode } from '@/lib/codeformat'\nimport { fileExt } from '@/lib/fileType'\nimport { langLabel, searchLangs } from '@/lib/highlightLangs'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * The side viewer's source-code view: a line-number gutter + syntax-highlighted code, with indentation-based\n * code FOLDING (click a chevron to collapse a block; see lib/codefold.ts), on-demand Prettier FORMATTING (the\n * 格式化 button, shown only for languages Prettier can parse — see lib/codeformat.ts; loaded lazily), and a\n * manual HIGHLIGHT-LANGUAGE picker (the 语言 button — auto-detection by extension/mime is often empty or wrong,\n * so any file can be re-colorized as JSON / XML / Python / … or forced to plain text; see lib/highlightLangs).\n * Used for every non-rendered file (plain text, code, or markdown/html/svg viewed as source). Highlighting is\n * split per line (see highlightLines) so rows can be hidden individually.\n *\n * Very large files (> {@link MAX_FOLD_LINES}) skip the per-line DOM and fall back to a single highlighted block\n * — folding a 10k-line file isn't worth tens of thousands of row elements, and such files mostly reach the\n * streaming log viewer anyway (see isLogFile).\n */\nconst props = defineProps<{ code: string; lang: string; name?: string }>()\n\nconst MAX_FOLD_LINES = 4000\n\n// Manual highlight-language override (null = use the auto-detected `props.lang`; '' = forced plain text). The\n// effective language feeds highlighting AND the 格式化 button (so \"force JSON, then format\" works). Reset when a\n// different file / detected language arrives (see the reset watch below).\nconst langOverride = ref<string | null>(null)\nconst effLang = computed(() => langOverride.value ?? props.lang)\n\n// Formatted output (null = showing the original). The view always renders `displayCode`.\nconst formatted = ref<string | null>(null)\nconst formatting = ref(false)\nconst formatErr = ref(false)\nconst displayCode = computed(() => formatted.value ?? props.code)\n\nconst ext = computed(() => fileExt(props.name ?? ''))\nconst canFmt = computed(() => canFormat(ext.value, effLang.value))\n\nconst rawLines = computed(() => displayCode.value.split('\\n'))\nconst lineCount = computed(() => rawLines.value.length)\nconst foldable = computed(() => lineCount.value <= MAX_FOLD_LINES)\n\nconst lineHtml = computed(() => highlightLines(displayCode.value, effLang.value))\nconst regions = computed(() => computeFoldRegions(displayCode.value))\nconst folded = reactive(new Set<number>())\n\n// A new file (or a re-detected language) resets the formatting toggle AND the manual language override; any\n// change to the shown code clears stale fold state.\nwatch(\n () => [props.code, props.lang],\n () => {\n formatted.value = null\n formatErr.value = false\n langOverride.value = null\n },\n)\nwatch(displayCode, () => folded.clear())\n\n// ── highlight-language picker ────────────────────────────────────────────────────────────────────\nconst langMenuOpen = ref(false)\nconst langMenuRef = ref<HTMLElement | null>(null)\nconst langQuery = ref('')\nconst langResults = computed(() => searchLangs(langQuery.value))\n\nfunction chooseLang(id: string): void {\n langOverride.value = id\n langMenuOpen.value = false\n langQuery.value = ''\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside langMenuRef, so a click on either is \"inside\" and never self-closes.\nfunction onLangDocPointer(e: MouseEvent): void {\n if (langMenuRef.value && !langMenuRef.value.contains(e.target as Node)) langMenuOpen.value = false\n}\nfunction onLangKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') langMenuOpen.value = false\n}\nwatch(langMenuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onLangDocPointer)\n document.addEventListener('keydown', onLangKey)\n } else {\n document.removeEventListener('click', onLangDocPointer)\n document.removeEventListener('keydown', onLangKey)\n }\n})\nonBeforeUnmount(() => {\n if (typeof document === 'undefined') return\n document.removeEventListener('click', onLangDocPointer)\n document.removeEventListener('keydown', onLangKey)\n})\n\nlet errTimer: ReturnType<typeof setTimeout> | undefined\nasync function toggleFormat(): Promise<void> {\n if (formatted.value != null) {\n formatted.value = null // revert to the original\n return\n }\n if (formatting.value) return\n formatting.value = true\n formatErr.value = false\n try {\n formatted.value = await formatCode(props.code, { ext: ext.value, lang: effLang.value })\n } catch {\n formatErr.value = true\n clearTimeout(errTimer)\n errTimer = setTimeout(() => (formatErr.value = false), 2200)\n } finally {\n formatting.value = false\n }\n}\n\n// Visible line indices, skipping the bodies of folded regions (and any nested folds within them).\nconst visible = computed(() => {\n const out: number[] = []\n const n = lineCount.value\n let i = 0\n while (i < n) {\n out.push(i)\n const end = folded.has(i) ? regions.value.get(i) : undefined\n i = end != null ? end + 1 : i + 1\n }\n return out\n})\n\nfunction toggle(i: number): void {\n if (folded.has(i)) folded.delete(i)\n else folded.add(i)\n}\n\n// Blob fallback for huge files: one highlighted block + a newline-joined gutter.\nconst blobHtml = computed(() =>\n effLang.value ? highlightCode(displayCode.value, effLang.value) : escapeHtml(displayCode.value),\n)\nconst blobGutter = computed(() =>\n Array.from({ length: lineCount.value }, (_, i) => i + 1).join('\\n'),\n)\n</script>\n\n<template>\n <div class=\"cv\">\n <!-- floating tools, top-right over the code: highlight-language picker + Prettier format toggle -->\n <div class=\"cv__tools\">\n <!-- highlight-language picker: force which grammar colorizes the source (auto-detect is often empty/wrong) -->\n <div ref=\"langMenuRef\" class=\"cv__lang\">\n <button\n type=\"button\"\n class=\"cv__toolbtn\"\n :class=\"{ 'cv__toolbtn--on': langMenuOpen }\"\n title=\"高亮语言 —— 选择用哪种语言着色(自动识别不准时手动指定)\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"langMenuOpen\"\n @click=\"langMenuOpen = !langMenuOpen\"\n >\n <Icon name=\"code\" :size=\"12\" /><span>{{ langLabel(effLang) }}</span>\n </button>\n <div v-if=\"langMenuOpen\" class=\"cv__langpop\" role=\"menu\">\n <input\n v-model=\"langQuery\"\n class=\"cv__langsearch\"\n type=\"text\"\n placeholder=\"搜索语言…\"\n spellcheck=\"false\"\n autocomplete=\"off\"\n />\n <div class=\"cv__langlist\">\n <button\n v-if=\"!langQuery\"\n type=\"button\"\n class=\"cv__langitem\"\n :class=\"{ 'cv__langitem--on': effLang === '' }\"\n role=\"menuitemradio\"\n :aria-checked=\"effLang === ''\"\n @click=\"chooseLang('')\"\n >\n <span class=\"cv__langitem-label\">纯文本(无高亮)</span>\n <Icon v-if=\"effLang === ''\" name=\"check\" :size=\"14\" class=\"cv__langitem-check\" />\n </button>\n <button\n v-for=\"l in langResults\"\n :key=\"l.id\"\n type=\"button\"\n class=\"cv__langitem\"\n :class=\"{ 'cv__langitem--on': effLang === l.id }\"\n role=\"menuitemradio\"\n :aria-checked=\"effLang === l.id\"\n @click=\"chooseLang(l.id)\"\n >\n <span class=\"cv__langitem-label\">{{ l.label }}</span>\n <Icon v-if=\"effLang === l.id\" name=\"check\" :size=\"14\" class=\"cv__langitem-check\" />\n </button>\n <p v-if=\"langQuery && !langResults.length\" class=\"cv__langempty\">无匹配语言</p>\n </div>\n </div>\n </div>\n\n <!-- format toggle (only for languages Prettier can parse) -->\n <button\n v-if=\"canFmt\"\n type=\"button\"\n class=\"cv__toolbtn cv__fmt\"\n :class=\"{ 'cv__toolbtn--on': formatted != null, 'cv__fmt--err': formatErr }\"\n :disabled=\"formatting\"\n :title=\"\n formatErr\n ? '格式化失败:源码可能无法解析'\n : formatted != null\n ? '还原为原始格式'\n : '用 Prettier 格式化'\n \"\n @click=\"toggleFormat\"\n >\n {{\n formatErr\n ? '格式化失败'\n : formatting\n ? '格式化中…'\n : formatted != null\n ? '还原'\n : '格式化'\n }}\n </button>\n </div>\n\n <!-- huge file: single highlighted block (no folding) -->\n <div v-if=\"!foldable\" class=\"cv__scroll cv__scroll--blob\">\n <div class=\"cv__codewrap\">\n <pre class=\"cv__gutter\" aria-hidden=\"true\">{{ blobGutter }}</pre>\n <div class=\"cv__codecol\">\n <pre class=\"cv__pre\"><code class=\"hljs\" v-html=\"blobHtml\" /></pre>\n </div>\n </div>\n </div>\n\n <!-- foldable: one row per visible line, with a fold chevron on block headers -->\n <div v-else class=\"cv__scroll\">\n <div class=\"cv__rows\">\n <div v-for=\"i in visible\" :key=\"i\" class=\"cv__row\">\n <span class=\"cv__gut\">\n <button\n v-if=\"regions.has(i)\"\n type=\"button\"\n class=\"cv__fold\"\n :title=\"folded.has(i) ? '展开' : '折叠'\"\n @click=\"toggle(i)\"\n >\n <Icon name=\"chevronRight\" :size=\"12\" :class=\"{ 'cv__fold--open': !folded.has(i) }\" />\n </button>\n <span v-else class=\"cv__fold cv__fold--empty\" />\n <span class=\"cv__ln\">{{ i + 1 }}</span>\n </span>\n <code class=\"cv__code hljs\" v-html=\"lineHtml[i]\" />\n <button\n v-if=\"folded.has(i)\"\n type=\"button\"\n class=\"cv__ellipsis\"\n :title=\"`展开折叠的 ${(regions.get(i) ?? i) - i} 行`\"\n @click=\"toggle(i)\"\n >\n ⋯\n </button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cv {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n\n/* Floating tools bar — top-right, above the code (highlight-language picker + format toggle). */\n.cv__tools {\n position: absolute;\n top: 8px;\n right: 14px;\n z-index: 3;\n display: flex;\n align-items: flex-start;\n gap: 6px;\n}\n\n/* Shared small pill used by both floating tools. */\n.cv__toolbtn {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n max-width: 190px;\n padding: 3px 10px;\n font-family: inherit;\n font-size: 11.5px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n box-shadow: var(--shadow);\n transition:\n background 0.15s,\n color 0.15s,\n border-color 0.15s;\n}\n.cv__toolbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.cv__toolbtn span {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cv__toolbtn--on {\n color: #fff;\n background: var(--accent);\n border-color: var(--accent);\n}\n.cv__toolbtn--on:hover {\n color: #fff;\n}\n\n/* Format toggle specifics (disabled + error states). */\n.cv__fmt:disabled {\n cursor: default;\n opacity: 0.7;\n}\n.cv__fmt--err {\n color: var(--danger);\n border-color: var(--danger);\n}\n\n/* Highlight-language picker dropdown (anchored under its pill, right-aligned). */\n.cv__lang {\n position: relative;\n display: inline-flex;\n}\n.cv__langpop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 4;\n width: 224px;\n padding: 7px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.cv__langsearch {\n width: 100%;\n box-sizing: border-box;\n padding: 6px 9px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n outline: none;\n}\n.cv__langsearch:focus {\n border-color: var(--accent);\n}\n.cv__langlist {\n max-height: 288px;\n overflow-y: auto;\n overscroll-behavior: contain;\n margin-top: 6px;\n}\n.cv__langitem {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 6px 8px;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.cv__langitem:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.cv__langitem--on {\n color: var(--accent);\n}\n.cv__langitem-label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cv__langitem-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cv__langempty {\n margin: 0;\n padding: 10px 8px;\n font-size: 12px;\n color: var(--faint);\n text-align: center;\n}\n\n.cv__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--code-bg);\n}\n\n/* Foldable per-line view -------------------------------------------------------------------------- */\n.cv__rows {\n min-width: max-content;\n padding: 14px 0;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n}\n.cv__row {\n display: flex;\n align-items: flex-start;\n}\n/* Gutter (fold chevron + line number) sticks to the left so it stays put while long lines scroll. */\n.cv__gut {\n position: sticky;\n left: 0;\n z-index: 1;\n flex-shrink: 0;\n display: flex;\n align-items: flex-start;\n background: var(--code-bg);\n user-select: none;\n}\n.cv__fold {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 16px;\n height: var(--fv-code-lh, 21px);\n padding: 0;\n border: none;\n background: transparent;\n color: var(--faint);\n cursor: pointer;\n}\n.cv__fold--empty {\n cursor: default;\n}\n.cv__fold :deep(svg) {\n transition: transform 0.12s;\n}\n.cv__fold--open {\n transform: rotate(90deg);\n}\n.cv__fold:hover :deep(svg) {\n color: var(--accent);\n}\n.cv__ln {\n flex-shrink: 0;\n width: 48px;\n padding: 0 12px 0 4px;\n text-align: right;\n color: var(--faint);\n}\n.cv__code {\n flex: 1 1 auto;\n padding: 0 18px 0 14px;\n white-space: pre;\n color: var(--text);\n background: transparent;\n border: none;\n font-family: inherit;\n tab-size: 2;\n}\n.cv__ellipsis {\n align-self: center;\n margin: 0 0 0 2px;\n padding: 0 7px;\n font-family: inherit;\n line-height: 1.3;\n color: var(--muted);\n background: var(--surface-2);\n border: 1px solid var(--border-2);\n border-radius: 5px;\n cursor: pointer;\n}\n.cv__ellipsis:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Huge-file blob fallback (mirrors the panel's original source layout) ----------------------------- */\n.cv__scroll--blob {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.cv__codewrap {\n display: flex;\n align-items: stretch;\n min-height: 100%;\n padding: 14px 0;\n}\n.cv__gutter {\n flex-shrink: 0;\n margin: 0;\n padding: 0 12px 0 16px;\n border-right: 1px solid var(--code-border);\n text-align: right;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n color: var(--faint);\n user-select: none;\n white-space: pre;\n}\n.cv__codecol {\n flex: 1 1 auto;\n min-width: 0;\n overflow-x: auto;\n}\n.cv__pre {\n margin: 0;\n padding: 0 18px 0 14px;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n color: var(--text);\n white-space: pre;\n tab-size: 2;\n}\n.cv__pre code {\n font-family: inherit;\n background: transparent;\n border: none;\n padding: 0;\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"CodeView.js","names":[],"sources":["../../src/components/CodeView.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'\nimport { escapeHtml, highlightCode, highlightLines } from '@/lib/highlight'\nimport { computeFoldRegions } from '@/lib/codefold'\nimport { canFormat, formatCode } from '@/lib/codeformat'\nimport { fileExt } from '@/lib/fileType'\nimport { hasAnsiSequences, parseAnsi } from '@/lib/ansi'\nimport { langLabel, searchLangs } from '@/lib/highlightLangs'\nimport Icon from '@/components/icons/Icon.vue'\nimport AnsiText from '@/components/AnsiText'\n\n/**\n * The side viewer's source-code view: a line-number gutter + syntax-highlighted code, with indentation-based\n * code FOLDING (click a chevron to collapse a block; see lib/codefold.ts), on-demand Prettier FORMATTING (the\n * 格式化 button, shown only for languages Prettier can parse — see lib/codeformat.ts; loaded lazily), and a\n * manual HIGHLIGHT-LANGUAGE picker (the 语言 button — auto-detection by extension/mime is often empty or wrong,\n * so any file can be re-colorized as JSON / XML / Python / … or forced to plain text; see lib/highlightLangs).\n * Used for every non-rendered file (plain text, code, or markdown/html/svg viewed as source). Highlighting is\n * split per line (see highlightLines) so rows can be hidden individually.\n *\n * Very large files (> {@link MAX_FOLD_LINES}) skip the per-line DOM and fall back to a single highlighted block\n * — folding a 10k-line file isn't worth tens of thousands of row elements, and such files mostly reach the\n * streaming log viewer anyway (see isLogFile).\n */\nconst props = defineProps<{ code: string; lang: string; name?: string }>()\n\nconst MAX_FOLD_LINES = 4000\n\n// Manual highlight-language override (null = use the auto-detected `props.lang`; '' = forced plain text). The\n// effective language feeds highlighting AND the 格式化 button (so \"force JSON, then format\" works). Reset when a\n// different file / detected language arrives (see the reset watch below).\nconst langOverride = ref<string | null>(null)\nconst effLang = computed(() => langOverride.value ?? props.lang)\n\n// Formatted output (null = showing the original). The view always renders `displayCode`.\nconst formatted = ref<string | null>(null)\nconst formatting = ref(false)\nconst formatErr = ref(false)\nconst displayCode = computed(() => formatted.value ?? props.code)\n\nconst ext = computed(() => fileExt(props.name ?? ''))\n// ANSI presentation is meaningful only in plain-text mode. Selecting a syntax language deliberately returns to\n// highlight.js; forcing \"纯文本\" on an otherwise-highlighted file enables ANSI restoration when controls exist.\nconst ansiDocument = computed(() =>\n !effLang.value && hasAnsiSequences(displayCode.value) ? parseAnsi(displayCode.value) : null,\n)\nconst ansiMode = computed(() => ansiDocument.value !== null)\nconst plainDisplayCode = computed(() => ansiDocument.value?.text ?? displayCode.value)\nconst canFmt = computed(() => !ansiMode.value && canFormat(ext.value, effLang.value))\n\nconst rawLines = computed(() => plainDisplayCode.value.split('\\n'))\nconst lineCount = computed(() => rawLines.value.length)\nconst foldable = computed(() => lineCount.value <= MAX_FOLD_LINES)\n\nconst lineHtml = computed(() =>\n ansiMode.value ? [] : highlightLines(displayCode.value, effLang.value),\n)\nconst regions = computed(() => computeFoldRegions(plainDisplayCode.value))\nconst folded = reactive(new Set<number>())\n\n// A new file (or a re-detected language) resets the formatting toggle AND the manual language override; any\n// change to the shown code clears stale fold state.\nwatch(\n () => [props.code, props.lang],\n () => {\n formatted.value = null\n formatErr.value = false\n langOverride.value = null\n },\n)\nwatch(displayCode, () => folded.clear())\n\n// ── highlight-language picker ────────────────────────────────────────────────────────────────────\nconst langMenuOpen = ref(false)\nconst langMenuRef = ref<HTMLElement | null>(null)\nconst langQuery = ref('')\nconst langResults = computed(() => searchLangs(langQuery.value))\n\nfunction chooseLang(id: string): void {\n langOverride.value = id\n langMenuOpen.value = false\n langQuery.value = ''\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside langMenuRef, so a click on either is \"inside\" and never self-closes.\nfunction onLangDocPointer(e: MouseEvent): void {\n if (langMenuRef.value && !langMenuRef.value.contains(e.target as Node)) langMenuOpen.value = false\n}\nfunction onLangKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') langMenuOpen.value = false\n}\nwatch(langMenuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onLangDocPointer)\n document.addEventListener('keydown', onLangKey)\n } else {\n document.removeEventListener('click', onLangDocPointer)\n document.removeEventListener('keydown', onLangKey)\n }\n})\nonBeforeUnmount(() => {\n if (typeof document === 'undefined') return\n document.removeEventListener('click', onLangDocPointer)\n document.removeEventListener('keydown', onLangKey)\n})\n\nlet errTimer: ReturnType<typeof setTimeout> | undefined\nasync function toggleFormat(): Promise<void> {\n if (formatted.value != null) {\n formatted.value = null // revert to the original\n return\n }\n if (formatting.value) return\n formatting.value = true\n formatErr.value = false\n try {\n formatted.value = await formatCode(props.code, { ext: ext.value, lang: effLang.value })\n } catch {\n formatErr.value = true\n clearTimeout(errTimer)\n errTimer = setTimeout(() => (formatErr.value = false), 2200)\n } finally {\n formatting.value = false\n }\n}\n\n// Visible line indices, skipping the bodies of folded regions (and any nested folds within them).\nconst visible = computed(() => {\n const out: number[] = []\n const n = lineCount.value\n let i = 0\n while (i < n) {\n out.push(i)\n const end = folded.has(i) ? regions.value.get(i) : undefined\n i = end != null ? end + 1 : i + 1\n }\n return out\n})\n\nfunction toggle(i: number): void {\n if (folded.has(i)) folded.delete(i)\n else folded.add(i)\n}\n\n// Blob fallback for huge files: one highlighted block + a newline-joined gutter.\nconst blobHtml = computed(() =>\n effLang.value ? highlightCode(displayCode.value, effLang.value) : escapeHtml(displayCode.value),\n)\nconst blobGutter = computed(() =>\n Array.from({ length: lineCount.value }, (_, i) => i + 1).join('\\n'),\n)\n</script>\n\n<template>\n <div class=\"cv\">\n <!-- floating tools, top-right over the code: highlight-language picker + Prettier format toggle -->\n <div class=\"cv__tools\">\n <!-- highlight-language picker: force which grammar colorizes the source (auto-detect is often empty/wrong) -->\n <div ref=\"langMenuRef\" class=\"cv__lang\">\n <button\n type=\"button\"\n class=\"cv__toolbtn\"\n :class=\"{ 'cv__toolbtn--on': langMenuOpen }\"\n title=\"高亮语言 —— 选择用哪种语言着色(自动识别不准时手动指定)\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"langMenuOpen\"\n @click=\"langMenuOpen = !langMenuOpen\"\n >\n <Icon name=\"code\" :size=\"12\" /><span>{{ langLabel(effLang) }}</span>\n </button>\n <div v-if=\"langMenuOpen\" class=\"cv__langpop\" role=\"menu\">\n <input\n v-model=\"langQuery\"\n class=\"cv__langsearch\"\n type=\"text\"\n placeholder=\"搜索语言…\"\n spellcheck=\"false\"\n autocomplete=\"off\"\n />\n <div class=\"cv__langlist\">\n <button\n v-if=\"!langQuery\"\n type=\"button\"\n class=\"cv__langitem\"\n :class=\"{ 'cv__langitem--on': effLang === '' }\"\n role=\"menuitemradio\"\n :aria-checked=\"effLang === ''\"\n @click=\"chooseLang('')\"\n >\n <span class=\"cv__langitem-label\">纯文本(无高亮)</span>\n <Icon v-if=\"effLang === ''\" name=\"check\" :size=\"14\" class=\"cv__langitem-check\" />\n </button>\n <button\n v-for=\"l in langResults\"\n :key=\"l.id\"\n type=\"button\"\n class=\"cv__langitem\"\n :class=\"{ 'cv__langitem--on': effLang === l.id }\"\n role=\"menuitemradio\"\n :aria-checked=\"effLang === l.id\"\n @click=\"chooseLang(l.id)\"\n >\n <span class=\"cv__langitem-label\">{{ l.label }}</span>\n <Icon v-if=\"effLang === l.id\" name=\"check\" :size=\"14\" class=\"cv__langitem-check\" />\n </button>\n <p v-if=\"langQuery && !langResults.length\" class=\"cv__langempty\">无匹配语言</p>\n </div>\n </div>\n </div>\n\n <!-- format toggle (only for languages Prettier can parse) -->\n <button\n v-if=\"canFmt\"\n type=\"button\"\n class=\"cv__toolbtn cv__fmt\"\n :class=\"{ 'cv__toolbtn--on': formatted != null, 'cv__fmt--err': formatErr }\"\n :disabled=\"formatting\"\n :title=\"\n formatErr\n ? '格式化失败:源码可能无法解析'\n : formatted != null\n ? '还原为原始格式'\n : '用 Prettier 格式化'\n \"\n @click=\"toggleFormat\"\n >\n {{\n formatErr\n ? '格式化失败'\n : formatting\n ? '格式化中…'\n : formatted != null\n ? '还原'\n : '格式化'\n }}\n </button>\n </div>\n\n <!-- huge file: single highlighted block (no folding) -->\n <div v-if=\"!foldable\" class=\"cv__scroll cv__scroll--blob\">\n <div class=\"cv__codewrap\">\n <pre class=\"cv__gutter\" aria-hidden=\"true\">{{ blobGutter }}</pre>\n <div class=\"cv__codecol\">\n <pre\n class=\"cv__pre\"\n ><code v-if=\"ansiDocument\" class=\"hljs\"><AnsiText :lines=\"ansiDocument.lines\" /></code><code v-else class=\"hljs\" v-html=\"blobHtml\" /></pre>\n </div>\n </div>\n </div>\n\n <!-- foldable: one row per visible line, with a fold chevron on block headers -->\n <div v-else class=\"cv__scroll\">\n <div class=\"cv__rows\">\n <div v-for=\"i in visible\" :key=\"i\" class=\"cv__row\">\n <span class=\"cv__gut\">\n <button\n v-if=\"regions.has(i)\"\n type=\"button\"\n class=\"cv__fold\"\n :title=\"folded.has(i) ? '展开' : '折叠'\"\n @click=\"toggle(i)\"\n >\n <Icon name=\"chevronRight\" :size=\"12\" :class=\"{ 'cv__fold--open': !folded.has(i) }\" />\n </button>\n <span v-else class=\"cv__fold cv__fold--empty\" />\n <span class=\"cv__ln\">{{ i + 1 }}</span>\n </span>\n <code class=\"cv__code hljs\">\n <AnsiText v-if=\"ansiDocument\" :line=\"ansiDocument.lines[i] ?? ''\" />\n <span v-else v-html=\"lineHtml[i]\" />\n </code>\n <button\n v-if=\"folded.has(i)\"\n type=\"button\"\n class=\"cv__ellipsis\"\n :title=\"`展开折叠的 ${(regions.get(i) ?? i) - i} 行`\"\n @click=\"toggle(i)\"\n >\n ⋯\n </button>\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.cv {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n\n/* Floating tools bar — top-right, above the code (highlight-language picker + format toggle). */\n.cv__tools {\n position: absolute;\n top: 8px;\n right: 14px;\n z-index: 3;\n display: flex;\n align-items: flex-start;\n gap: 6px;\n}\n\n/* Shared small pill used by both floating tools. */\n.cv__toolbtn {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n max-width: 190px;\n padding: 3px 10px;\n font-family: inherit;\n font-size: 11.5px;\n color: var(--muted);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n box-shadow: var(--shadow);\n transition:\n background 0.15s,\n color 0.15s,\n border-color 0.15s;\n}\n.cv__toolbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.cv__toolbtn span {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cv__toolbtn--on {\n color: #fff;\n background: var(--accent);\n border-color: var(--accent);\n}\n.cv__toolbtn--on:hover {\n color: #fff;\n}\n\n/* Format toggle specifics (disabled + error states). */\n.cv__fmt:disabled {\n cursor: default;\n opacity: 0.7;\n}\n.cv__fmt--err {\n color: var(--danger);\n border-color: var(--danger);\n}\n\n/* Highlight-language picker dropdown (anchored under its pill, right-aligned). */\n.cv__lang {\n position: relative;\n display: inline-flex;\n}\n.cv__langpop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 4;\n width: 224px;\n padding: 7px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.cv__langsearch {\n width: 100%;\n box-sizing: border-box;\n padding: 6px 9px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n outline: none;\n}\n.cv__langsearch:focus {\n border-color: var(--accent);\n}\n.cv__langlist {\n max-height: 288px;\n overflow-y: auto;\n overscroll-behavior: contain;\n margin-top: 6px;\n}\n.cv__langitem {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 6px 8px;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.cv__langitem:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.cv__langitem--on {\n color: var(--accent);\n}\n.cv__langitem-label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cv__langitem-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.cv__langempty {\n margin: 0;\n padding: 10px 8px;\n font-size: 12px;\n color: var(--faint);\n text-align: center;\n}\n\n.cv__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n background: var(--code-bg);\n}\n\n/* Foldable per-line view -------------------------------------------------------------------------- */\n.cv__rows {\n min-width: max-content;\n padding: 14px 0;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n}\n.cv__row {\n display: flex;\n align-items: flex-start;\n}\n/* Gutter (fold chevron + line number) sticks to the left so it stays put while long lines scroll. */\n.cv__gut {\n position: sticky;\n left: 0;\n z-index: 1;\n flex-shrink: 0;\n display: flex;\n align-items: flex-start;\n background: var(--code-bg);\n user-select: none;\n}\n.cv__fold {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 16px;\n height: var(--fv-code-lh, 21px);\n padding: 0;\n border: none;\n background: transparent;\n color: var(--faint);\n cursor: pointer;\n}\n.cv__fold--empty {\n cursor: default;\n}\n.cv__fold :deep(svg) {\n transition: transform 0.12s;\n}\n.cv__fold--open {\n transform: rotate(90deg);\n}\n.cv__fold:hover :deep(svg) {\n color: var(--accent);\n}\n.cv__ln {\n flex-shrink: 0;\n width: 48px;\n padding: 0 12px 0 4px;\n text-align: right;\n color: var(--faint);\n}\n.cv__code {\n flex: 1 1 auto;\n padding: 0 18px 0 14px;\n white-space: pre;\n color: var(--text);\n background: transparent;\n border: none;\n font-family: inherit;\n tab-size: 2;\n}\n.cv__ellipsis {\n align-self: center;\n margin: 0 0 0 2px;\n padding: 0 7px;\n font-family: inherit;\n line-height: 1.3;\n color: var(--muted);\n background: var(--surface-2);\n border: 1px solid var(--border-2);\n border-radius: 5px;\n cursor: pointer;\n}\n.cv__ellipsis:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* Huge-file blob fallback (mirrors the panel's original source layout) ----------------------------- */\n.cv__scroll--blob {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.cv__codewrap {\n display: flex;\n align-items: stretch;\n min-height: 100%;\n padding: 14px 0;\n}\n.cv__gutter {\n flex-shrink: 0;\n margin: 0;\n padding: 0 12px 0 16px;\n border-right: 1px solid var(--code-border);\n text-align: right;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n color: var(--faint);\n user-select: none;\n white-space: pre;\n}\n.cv__codecol {\n flex: 1 1 auto;\n min-width: 0;\n overflow-x: auto;\n}\n.cv__pre {\n margin: 0;\n padding: 0 18px 0 14px;\n font-family: var(--font-mono);\n font-size: var(--fv-code-fs, 12.5px);\n line-height: var(--fv-code-lh, 21px);\n color: var(--text);\n white-space: pre;\n tab-size: 2;\n}\n.cv__pre code {\n font-family: inherit;\n background: transparent;\n border: none;\n padding: 0;\n}\n</style>\n"],"mappings":""}