@file-viewer/renderer-text 2.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +160 -0
- package/README.en.md +40 -0
- package/README.md +40 -0
- package/dist/code.d.ts +11 -0
- package/dist/code.js +243 -0
- package/dist/gitBundle.d.ts +2 -0
- package/dist/gitBundle.js +580 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +25 -0
- package/dist/markdown.d.ts +2 -0
- package/dist/markdown.js +81 -0
- package/dist/patch.d.ts +2 -0
- package/dist/patch.js +123 -0
- package/package.json +74 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { createFileViewerZoomChangeEmitter as createZoomChangeEmitter, registerFileViewerZoomProvider, unregisterFileViewerZoomProvider } from '@file-viewer/core';
|
|
2
|
+
import { Inflate } from 'pako';
|
|
3
|
+
const textDecoder = new TextDecoder('utf-8');
|
|
4
|
+
const asciiDecoder = new TextDecoder('latin1');
|
|
5
|
+
const bundleStyle = `
|
|
6
|
+
.git-bundle-viewer{display:grid;height:100%;min-height:420px;grid-template-rows:auto minmax(0,1fr);--bundle-bg:#f6f8fa;--bundle-surface:#fff;--bundle-border:rgba(31,35,40,.12);--bundle-text:#24292f;--bundle-muted:#57606a;--bundle-accent:#0f766e;--bundle-code:#0d1117;--bundle-code-text:#e6edf3;--bundle-font-size:13px;background:var(--bundle-bg);color:var(--bundle-text);box-sizing:border-box}
|
|
7
|
+
.git-bundle-toolbar{position:sticky;top:0;z-index:2;display:flex;min-height:46px;align-items:center;justify-content:space-between;gap:12px;padding:8px 16px;border-bottom:1px solid var(--bundle-border);background:rgba(255,255,255,.92);backdrop-filter:blur(12px);box-sizing:border-box}
|
|
8
|
+
.git-bundle-toolbar span,.git-bundle-toolbar strong{color:var(--bundle-muted);font-size:12px;font-weight:800;letter-spacing:0}
|
|
9
|
+
.git-bundle-layout{display:grid;min-height:0;grid-template-columns:minmax(260px,340px) minmax(260px,360px) minmax(0,1fr);gap:12px;padding:12px;box-sizing:border-box}
|
|
10
|
+
.git-bundle-panel{min-height:0;overflow:auto;border:1px solid var(--bundle-border);border-radius:8px;background:var(--bundle-surface)}
|
|
11
|
+
.git-bundle-panel h3{position:sticky;top:0;z-index:1;margin:0;padding:11px 12px;border-bottom:1px solid var(--bundle-border);background:var(--bundle-surface);font-size:13px}
|
|
12
|
+
.git-bundle-list{margin:0;padding:8px;list-style:none}
|
|
13
|
+
.git-bundle-list button{display:block;width:100%;margin:0 0 6px;padding:9px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--bundle-text);font:inherit;font-size:var(--bundle-font-size);text-align:left;cursor:pointer}
|
|
14
|
+
.git-bundle-list button:hover,.git-bundle-list button.active{border-color:rgba(15,118,110,.28);background:rgba(15,118,110,.08)}
|
|
15
|
+
.git-bundle-list small{display:block;margin-top:4px;color:var(--bundle-muted);font-size:.86em;line-height:1.35}
|
|
16
|
+
.git-bundle-meta{display:grid;gap:1px;background:var(--bundle-border)}
|
|
17
|
+
.git-bundle-meta div{padding:10px 12px;background:var(--bundle-surface)}
|
|
18
|
+
.git-bundle-meta span{display:block;color:var(--bundle-muted);font-size:11px;font-weight:800}
|
|
19
|
+
.git-bundle-meta strong{display:block;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
20
|
+
.git-bundle-tree{padding:10px 12px;font-size:var(--bundle-font-size)}
|
|
21
|
+
.git-bundle-tree button{display:block;width:100%;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:var(--bundle-text);font:inherit;text-align:left;cursor:pointer}
|
|
22
|
+
.git-bundle-tree button:hover,.git-bundle-tree button.active{background:rgba(15,118,110,.08);color:var(--bundle-accent)}
|
|
23
|
+
.git-bundle-file{display:grid;min-height:0;grid-template-rows:auto minmax(0,1fr)}
|
|
24
|
+
.git-bundle-file-header{padding:11px 12px;border-bottom:1px solid var(--bundle-border);font-size:12px;font-weight:800;color:var(--bundle-muted)}
|
|
25
|
+
.git-bundle-code{margin:0;overflow:auto;padding:16px 18px;background:var(--bundle-code);color:var(--bundle-code-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace;font-size:var(--bundle-font-size);line-height:1.65;white-space:pre}
|
|
26
|
+
.git-bundle-notice{margin:8px;padding:10px 12px;border:1px solid rgba(245,158,11,.35);border-radius:8px;background:rgba(245,158,11,.12);color:#92400e;font-size:12px;line-height:1.55}
|
|
27
|
+
.file-viewer[data-viewer-theme='dark'] .git-bundle-viewer{--bundle-bg:#0d1117;--bundle-surface:#161b22;--bundle-border:rgba(139,148,158,.24);--bundle-text:#e6edf3;--bundle-muted:#8b949e;--bundle-code:#010409;--bundle-code-text:#e6edf3}
|
|
28
|
+
.file-viewer[data-viewer-theme='dark'] .git-bundle-toolbar{background:rgba(13,17,23,.92)}
|
|
29
|
+
@media (prefers-color-scheme:dark){.file-viewer[data-viewer-theme='system'] .git-bundle-viewer{--bundle-bg:#0d1117;--bundle-surface:#161b22;--bundle-border:rgba(139,148,158,.24);--bundle-text:#e6edf3;--bundle-muted:#8b949e;--bundle-code:#010409;--bundle-code-text:#e6edf3}.file-viewer[data-viewer-theme='system'] .git-bundle-toolbar{background:rgba(13,17,23,.92)}}
|
|
30
|
+
@media (max-width:980px){.git-bundle-layout{grid-template-columns:1fr}.git-bundle-panel{min-height:240px}}
|
|
31
|
+
`;
|
|
32
|
+
const createElement = (documentRef, tagName, className, text) => {
|
|
33
|
+
const element = documentRef.createElement(tagName);
|
|
34
|
+
if (className) {
|
|
35
|
+
element.className = className;
|
|
36
|
+
}
|
|
37
|
+
if (typeof text === 'string') {
|
|
38
|
+
element.textContent = text;
|
|
39
|
+
}
|
|
40
|
+
return element;
|
|
41
|
+
};
|
|
42
|
+
const createStyle = (documentRef) => {
|
|
43
|
+
const style = documentRef.createElement('style');
|
|
44
|
+
style.textContent = bundleStyle;
|
|
45
|
+
return style;
|
|
46
|
+
};
|
|
47
|
+
const toHex = (bytes) => {
|
|
48
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
49
|
+
};
|
|
50
|
+
const readUInt32 = (bytes, offset) => {
|
|
51
|
+
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
|
52
|
+
};
|
|
53
|
+
const concatBytes = (items) => {
|
|
54
|
+
const total = items.reduce((sum, item) => sum + item.byteLength, 0);
|
|
55
|
+
const merged = new Uint8Array(total);
|
|
56
|
+
let offset = 0;
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
merged.set(item, offset);
|
|
59
|
+
offset += item.byteLength;
|
|
60
|
+
}
|
|
61
|
+
return merged;
|
|
62
|
+
};
|
|
63
|
+
const parseHeader = (bytes) => {
|
|
64
|
+
const lines = [];
|
|
65
|
+
let offset = 0;
|
|
66
|
+
while (offset < bytes.length) {
|
|
67
|
+
const next = bytes.indexOf(10, offset);
|
|
68
|
+
if (next < 0) {
|
|
69
|
+
throw new Error('Git bundle header is incomplete.');
|
|
70
|
+
}
|
|
71
|
+
const rawLine = bytes.subarray(offset, next);
|
|
72
|
+
const line = asciiDecoder.decode(rawLine).replace(/\r$/, '');
|
|
73
|
+
offset = next + 1;
|
|
74
|
+
if (line === '') {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
lines.push(line);
|
|
78
|
+
}
|
|
79
|
+
const signature = lines.shift() || '';
|
|
80
|
+
if (!signature.startsWith('# v') || !signature.includes('git bundle')) {
|
|
81
|
+
throw new Error('当前文件不是标准 Git bundle。');
|
|
82
|
+
}
|
|
83
|
+
const capabilities = [];
|
|
84
|
+
const refs = [];
|
|
85
|
+
const prerequisites = [];
|
|
86
|
+
let objectFormat = 'sha1';
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (line.startsWith('@')) {
|
|
89
|
+
capabilities.push(line.slice(1));
|
|
90
|
+
const objectFormatMatch = line.match(/^@object-format=(sha1|sha256)$/);
|
|
91
|
+
if (objectFormatMatch) {
|
|
92
|
+
objectFormat = objectFormatMatch[1];
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (line.startsWith('-')) {
|
|
97
|
+
const [oid = '', ...subject] = line.slice(1).split(/\s+/);
|
|
98
|
+
prerequisites.push({ oid, subject: subject.join(' ') });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const [oid = '', ...name] = line.split(/\s+/);
|
|
102
|
+
if (oid) {
|
|
103
|
+
refs.push({ oid, name: name.join(' ') || oid });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
signature,
|
|
108
|
+
capabilities,
|
|
109
|
+
refs,
|
|
110
|
+
prerequisites,
|
|
111
|
+
packOffset: offset,
|
|
112
|
+
objectFormat
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const readPackObjectHeader = (bytes, start) => {
|
|
116
|
+
let offset = start;
|
|
117
|
+
let byte = bytes[offset++];
|
|
118
|
+
const typeCode = (byte >> 4) & 0x07;
|
|
119
|
+
let size = byte & 0x0f;
|
|
120
|
+
let shift = 4;
|
|
121
|
+
while (byte & 0x80) {
|
|
122
|
+
byte = bytes[offset++];
|
|
123
|
+
size |= (byte & 0x7f) << shift;
|
|
124
|
+
shift += 7;
|
|
125
|
+
}
|
|
126
|
+
return { typeCode, size, offset };
|
|
127
|
+
};
|
|
128
|
+
const readOfsDeltaBase = (bytes, start) => {
|
|
129
|
+
let offset = start;
|
|
130
|
+
let value = bytes[offset++] & 0x7f;
|
|
131
|
+
while (bytes[offset - 1] & 0x80) {
|
|
132
|
+
value += 1;
|
|
133
|
+
value = (value << 7) + (bytes[offset++] & 0x7f);
|
|
134
|
+
}
|
|
135
|
+
return { value, offset };
|
|
136
|
+
};
|
|
137
|
+
const inflateObject = (bytes, start) => {
|
|
138
|
+
const inflator = new Inflate();
|
|
139
|
+
let offset = start;
|
|
140
|
+
while (!inflator.ended && offset < bytes.length) {
|
|
141
|
+
inflator.push(bytes.subarray(offset, offset + 1), false);
|
|
142
|
+
offset += 1;
|
|
143
|
+
if (inflator.err) {
|
|
144
|
+
throw new Error(inflator.msg || 'Git pack object inflate failed.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!inflator.ended || !(inflator.result instanceof Uint8Array)) {
|
|
148
|
+
throw new Error('Git pack object is incomplete.');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
content: inflator.result,
|
|
152
|
+
nextOffset: offset
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
const kindName = (typeCode) => {
|
|
156
|
+
if (typeCode === 1)
|
|
157
|
+
return 'commit';
|
|
158
|
+
if (typeCode === 2)
|
|
159
|
+
return 'tree';
|
|
160
|
+
if (typeCode === 3)
|
|
161
|
+
return 'blob';
|
|
162
|
+
if (typeCode === 4)
|
|
163
|
+
return 'tag';
|
|
164
|
+
if (typeCode === 6)
|
|
165
|
+
return 'ofs-delta';
|
|
166
|
+
if (typeCode === 7)
|
|
167
|
+
return 'ref-delta';
|
|
168
|
+
return 'unknown';
|
|
169
|
+
};
|
|
170
|
+
const hashObjectId = async (format, kind, content) => {
|
|
171
|
+
if (format === 'unknown') {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const header = new TextEncoder().encode(`${kind} ${content.byteLength}\0`);
|
|
175
|
+
const digest = await globalThis.crypto.subtle.digest(format === 'sha256' ? 'SHA-256' : 'SHA-1', concatBytes([header, content]));
|
|
176
|
+
return toHex(new Uint8Array(digest));
|
|
177
|
+
};
|
|
178
|
+
const readDeltaSize = (bytes, start) => {
|
|
179
|
+
let offset = start;
|
|
180
|
+
let size = 0;
|
|
181
|
+
let shift = 0;
|
|
182
|
+
let byte = 0;
|
|
183
|
+
do {
|
|
184
|
+
byte = bytes[offset++];
|
|
185
|
+
size |= (byte & 0x7f) << shift;
|
|
186
|
+
shift += 7;
|
|
187
|
+
} while (byte & 0x80);
|
|
188
|
+
return { size, offset };
|
|
189
|
+
};
|
|
190
|
+
const applyPackDelta = (base, delta) => {
|
|
191
|
+
const baseSize = readDeltaSize(delta, 0);
|
|
192
|
+
const targetSize = readDeltaSize(delta, baseSize.offset);
|
|
193
|
+
let offset = targetSize.offset;
|
|
194
|
+
const chunks = [];
|
|
195
|
+
if (baseSize.size !== base.byteLength) {
|
|
196
|
+
throw new Error('Git delta base size does not match the resolved object.');
|
|
197
|
+
}
|
|
198
|
+
while (offset < delta.byteLength) {
|
|
199
|
+
const opcode = delta[offset++];
|
|
200
|
+
if (opcode & 0x80) {
|
|
201
|
+
let copyOffset = 0;
|
|
202
|
+
let copySize = 0;
|
|
203
|
+
if (opcode & 0x01)
|
|
204
|
+
copyOffset = delta[offset++];
|
|
205
|
+
if (opcode & 0x02)
|
|
206
|
+
copyOffset |= delta[offset++] << 8;
|
|
207
|
+
if (opcode & 0x04)
|
|
208
|
+
copyOffset |= delta[offset++] << 16;
|
|
209
|
+
if (opcode & 0x08)
|
|
210
|
+
copyOffset |= delta[offset++] << 24;
|
|
211
|
+
if (opcode & 0x10)
|
|
212
|
+
copySize = delta[offset++];
|
|
213
|
+
if (opcode & 0x20)
|
|
214
|
+
copySize |= delta[offset++] << 8;
|
|
215
|
+
if (opcode & 0x40)
|
|
216
|
+
copySize |= delta[offset++] << 16;
|
|
217
|
+
if (copySize === 0)
|
|
218
|
+
copySize = 0x10000;
|
|
219
|
+
chunks.push(base.subarray(copyOffset, copyOffset + copySize));
|
|
220
|
+
}
|
|
221
|
+
else if (opcode) {
|
|
222
|
+
chunks.push(delta.subarray(offset, offset + opcode));
|
|
223
|
+
offset += opcode;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw new Error('Git delta contains a reserved opcode.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const result = concatBytes(chunks);
|
|
230
|
+
if (result.byteLength !== targetSize.size) {
|
|
231
|
+
throw new Error('Git delta target size does not match decoded content.');
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
};
|
|
235
|
+
const resolvePackDeltas = async (objects, format) => {
|
|
236
|
+
const byOffset = new Map();
|
|
237
|
+
const byOid = new Map();
|
|
238
|
+
objects.forEach(object => {
|
|
239
|
+
byOffset.set(object.offset, object);
|
|
240
|
+
if (object.oid) {
|
|
241
|
+
byOid.set(object.oid, object);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
for (let pass = 0; pass < objects.length; pass += 1) {
|
|
245
|
+
let resolvedThisPass = 0;
|
|
246
|
+
for (const object of objects) {
|
|
247
|
+
if (!object.deltaKind || object.oid || !object.content) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const base = typeof object.baseOffset === 'number'
|
|
251
|
+
? byOffset.get(object.baseOffset)
|
|
252
|
+
: object.baseOid
|
|
253
|
+
? byOid.get(object.baseOid)
|
|
254
|
+
: undefined;
|
|
255
|
+
if (!(base === null || base === void 0 ? void 0 : base.content) || base.deltaKind || base.kind === 'ofs-delta' || base.kind === 'ref-delta') {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
object.content = applyPackDelta(base.content, object.content);
|
|
259
|
+
object.kind = base.kind;
|
|
260
|
+
object.oid = await hashObjectId(format, object.kind, object.content);
|
|
261
|
+
if (object.oid) {
|
|
262
|
+
byOid.set(object.oid, object);
|
|
263
|
+
}
|
|
264
|
+
resolvedThisPass += 1;
|
|
265
|
+
}
|
|
266
|
+
if (!resolvedThisPass) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const parsePack = async (bytes, header) => {
|
|
272
|
+
const offset = header.packOffset;
|
|
273
|
+
if (asciiDecoder.decode(bytes.subarray(offset, offset + 4)) !== 'PACK') {
|
|
274
|
+
throw new Error('Git bundle header 后未找到 PACK 数据。');
|
|
275
|
+
}
|
|
276
|
+
const version = readUInt32(bytes, offset + 4);
|
|
277
|
+
const count = readUInt32(bytes, offset + 8);
|
|
278
|
+
const objects = [];
|
|
279
|
+
let cursor = offset + 12;
|
|
280
|
+
const maxObjects = Math.min(count, 2500);
|
|
281
|
+
for (let index = 0; index < maxObjects && cursor < bytes.length; index += 1) {
|
|
282
|
+
const objectOffset = cursor;
|
|
283
|
+
const parsedHeader = readPackObjectHeader(bytes, cursor);
|
|
284
|
+
cursor = parsedHeader.offset;
|
|
285
|
+
const kind = kindName(parsedHeader.typeCode);
|
|
286
|
+
let baseOffset;
|
|
287
|
+
let baseOid;
|
|
288
|
+
if (kind === 'ofs-delta') {
|
|
289
|
+
const parsedBase = readOfsDeltaBase(bytes, cursor);
|
|
290
|
+
baseOffset = objectOffset - parsedBase.value;
|
|
291
|
+
cursor = parsedBase.offset;
|
|
292
|
+
}
|
|
293
|
+
else if (kind === 'ref-delta') {
|
|
294
|
+
const oidLength = header.objectFormat === 'sha256' ? 32 : 20;
|
|
295
|
+
baseOid = toHex(bytes.subarray(cursor, cursor + oidLength));
|
|
296
|
+
cursor += oidLength;
|
|
297
|
+
}
|
|
298
|
+
const inflated = inflateObject(bytes, cursor);
|
|
299
|
+
cursor = inflated.nextOffset;
|
|
300
|
+
const item = {
|
|
301
|
+
kind,
|
|
302
|
+
size: parsedHeader.size,
|
|
303
|
+
offset: objectOffset,
|
|
304
|
+
content: inflated.content,
|
|
305
|
+
baseOffset,
|
|
306
|
+
baseOid,
|
|
307
|
+
deltaKind: kind === 'ofs-delta' || kind === 'ref-delta' ? kind : undefined
|
|
308
|
+
};
|
|
309
|
+
if (kind !== 'ofs-delta' && kind !== 'ref-delta' && kind !== 'unknown') {
|
|
310
|
+
item.oid = await hashObjectId(header.objectFormat, kind, inflated.content);
|
|
311
|
+
}
|
|
312
|
+
objects.push(item);
|
|
313
|
+
}
|
|
314
|
+
await resolvePackDeltas(objects, header.objectFormat);
|
|
315
|
+
return {
|
|
316
|
+
version,
|
|
317
|
+
declaredCount: count,
|
|
318
|
+
objects,
|
|
319
|
+
parsedCount: objects.length
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
const parseCommit = (object, refsByOid) => {
|
|
323
|
+
if (object.kind !== 'commit' || !object.oid || !object.content) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const text = textDecoder.decode(object.content);
|
|
327
|
+
const [headers = '', ...messageParts] = text.split(/\n\n/);
|
|
328
|
+
const parents = [];
|
|
329
|
+
let tree;
|
|
330
|
+
let author;
|
|
331
|
+
let committer;
|
|
332
|
+
for (const line of headers.split(/\n/)) {
|
|
333
|
+
if (line.startsWith('tree ')) {
|
|
334
|
+
tree = line.slice(5);
|
|
335
|
+
}
|
|
336
|
+
else if (line.startsWith('parent ')) {
|
|
337
|
+
parents.push(line.slice(7));
|
|
338
|
+
}
|
|
339
|
+
else if (line.startsWith('author ')) {
|
|
340
|
+
author = line.slice(7);
|
|
341
|
+
}
|
|
342
|
+
else if (line.startsWith('committer ')) {
|
|
343
|
+
committer = line.slice(10);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
oid: object.oid,
|
|
348
|
+
tree,
|
|
349
|
+
parents,
|
|
350
|
+
author,
|
|
351
|
+
committer,
|
|
352
|
+
message: messageParts.join('\n\n').trim() || '(no message)',
|
|
353
|
+
refs: refsByOid.get(object.oid) || []
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
const parseTree = (content) => {
|
|
357
|
+
const entries = [];
|
|
358
|
+
let offset = 0;
|
|
359
|
+
while (offset < content.byteLength) {
|
|
360
|
+
const modeEnd = content.indexOf(32, offset);
|
|
361
|
+
if (modeEnd < 0)
|
|
362
|
+
break;
|
|
363
|
+
const nameEnd = content.indexOf(0, modeEnd + 1);
|
|
364
|
+
if (nameEnd < 0 || nameEnd + 21 > content.byteLength)
|
|
365
|
+
break;
|
|
366
|
+
entries.push({
|
|
367
|
+
mode: asciiDecoder.decode(content.subarray(offset, modeEnd)),
|
|
368
|
+
name: textDecoder.decode(content.subarray(modeEnd + 1, nameEnd)),
|
|
369
|
+
oid: toHex(content.subarray(nameEnd + 1, nameEnd + 21))
|
|
370
|
+
});
|
|
371
|
+
offset = nameEnd + 21;
|
|
372
|
+
}
|
|
373
|
+
return entries;
|
|
374
|
+
};
|
|
375
|
+
const isMostlyText = (bytes) => {
|
|
376
|
+
const sample = bytes.subarray(0, Math.min(bytes.byteLength, 4096));
|
|
377
|
+
if (!sample.length) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
let printable = 0;
|
|
381
|
+
for (const byte of sample) {
|
|
382
|
+
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte < 127) || byte >= 128) {
|
|
383
|
+
printable += 1;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return printable / sample.length > 0.86;
|
|
387
|
+
};
|
|
388
|
+
const previewBlob = (content) => {
|
|
389
|
+
if (!isMostlyText(content)) {
|
|
390
|
+
return `[binary blob: ${content.byteLength} bytes]`;
|
|
391
|
+
}
|
|
392
|
+
return textDecoder.decode(content.subarray(0, Math.min(content.byteLength, 12000)));
|
|
393
|
+
};
|
|
394
|
+
const collectTree = (objectMap, treeOid, basePath = '', depth = 0) => {
|
|
395
|
+
if (!treeOid || depth > 24) {
|
|
396
|
+
return { treeEntries: [], files: [] };
|
|
397
|
+
}
|
|
398
|
+
const treeObject = objectMap.get(treeOid);
|
|
399
|
+
if ((treeObject === null || treeObject === void 0 ? void 0 : treeObject.kind) !== 'tree' || !treeObject.content) {
|
|
400
|
+
return { treeEntries: [], files: [] };
|
|
401
|
+
}
|
|
402
|
+
const treeEntries = [];
|
|
403
|
+
const files = [];
|
|
404
|
+
for (const entry of parseTree(treeObject.content)) {
|
|
405
|
+
const path = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
406
|
+
treeEntries.push({ path, entry });
|
|
407
|
+
const object = objectMap.get(entry.oid);
|
|
408
|
+
if ((object === null || object === void 0 ? void 0 : object.kind) === 'tree') {
|
|
409
|
+
const child = collectTree(objectMap, entry.oid, path, depth + 1);
|
|
410
|
+
treeEntries.push(...child.treeEntries);
|
|
411
|
+
files.push(...child.files);
|
|
412
|
+
}
|
|
413
|
+
else if ((object === null || object === void 0 ? void 0 : object.kind) === 'blob' && object.content) {
|
|
414
|
+
files.push({
|
|
415
|
+
path,
|
|
416
|
+
oid: entry.oid,
|
|
417
|
+
size: object.content.byteLength,
|
|
418
|
+
preview: previewBlob(object.content)
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { treeEntries, files };
|
|
423
|
+
};
|
|
424
|
+
const parseBundleModel = async (buffer) => {
|
|
425
|
+
const bytes = new Uint8Array(buffer);
|
|
426
|
+
const header = parseHeader(bytes);
|
|
427
|
+
const pack = await parsePack(bytes, header);
|
|
428
|
+
const objectMap = new Map(pack.objects.flatMap(object => object.oid ? [[object.oid, object]] : []));
|
|
429
|
+
const refsByOid = new Map();
|
|
430
|
+
header.refs.forEach(ref => {
|
|
431
|
+
refsByOid.set(ref.oid, [...(refsByOid.get(ref.oid) || []), ref.name]);
|
|
432
|
+
});
|
|
433
|
+
const commits = pack.objects
|
|
434
|
+
.map(object => parseCommit(object, refsByOid))
|
|
435
|
+
.filter((commit) => Boolean(commit));
|
|
436
|
+
const selectedCommit = header.refs.map(ref => refsByOid.has(ref.oid) ? commits.find(commit => commit.oid === ref.oid) : null)
|
|
437
|
+
.find(Boolean) || commits[0];
|
|
438
|
+
const tree = collectTree(objectMap, selectedCommit === null || selectedCommit === void 0 ? void 0 : selectedCommit.tree);
|
|
439
|
+
return {
|
|
440
|
+
header,
|
|
441
|
+
objects: pack.objects,
|
|
442
|
+
commits,
|
|
443
|
+
files: tree.files,
|
|
444
|
+
treeEntries: tree.treeEntries,
|
|
445
|
+
deltaCount: pack.objects.filter(object => object.deltaKind).length
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
const shortOid = (oid) => oid ? oid.slice(0, 12) : '-';
|
|
449
|
+
const commitTitle = (commit) => commit.message.split(/\r?\n/)[0] || '(no message)';
|
|
450
|
+
const clampZoom = (value) => {
|
|
451
|
+
return Math.min(2.2, Math.max(0.65, Number(value.toFixed(2))));
|
|
452
|
+
};
|
|
453
|
+
const renderMeta = (documentRef, panel, model) => {
|
|
454
|
+
const meta = createElement(documentRef, 'div', 'git-bundle-meta');
|
|
455
|
+
const counts = new Map();
|
|
456
|
+
model.objects.forEach(object => {
|
|
457
|
+
counts.set(object.kind, (counts.get(object.kind) || 0) + 1);
|
|
458
|
+
});
|
|
459
|
+
const items = [
|
|
460
|
+
['Bundle', model.header.signature],
|
|
461
|
+
['Refs', String(model.header.refs.length)],
|
|
462
|
+
['Commits', String(model.commits.length)],
|
|
463
|
+
['Objects', String(model.objects.length)],
|
|
464
|
+
['Deltas', String(model.deltaCount)],
|
|
465
|
+
['Object format', model.header.objectFormat],
|
|
466
|
+
['Object types', Array.from(counts).map(([kind, count]) => `${kind}:${count}`).join(' · ') || '-']
|
|
467
|
+
];
|
|
468
|
+
items.forEach(([label, value]) => {
|
|
469
|
+
const row = createElement(documentRef, 'div');
|
|
470
|
+
row.append(createElement(documentRef, 'span', undefined, label), createElement(documentRef, 'strong', undefined, value));
|
|
471
|
+
meta.appendChild(row);
|
|
472
|
+
});
|
|
473
|
+
panel.appendChild(meta);
|
|
474
|
+
if (model.deltaCount > 0) {
|
|
475
|
+
panel.appendChild(createElement(documentRef, 'div', 'git-bundle-notice', '当前 bundle 包含 delta 压缩对象。预览器已在浏览器端解析常规 OFS_DELTA / REF_DELTA;若仍有缺失文件,通常是包体过大、对象过多或依赖外部 prerequisite。'));
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
export default async function renderGitBundle(buffer, target, type = 'bundle') {
|
|
479
|
+
const documentRef = target.ownerDocument || document;
|
|
480
|
+
const model = await parseBundleModel(buffer);
|
|
481
|
+
let zoom = 1;
|
|
482
|
+
const zoomEmitter = createZoomChangeEmitter();
|
|
483
|
+
const root = createElement(documentRef, 'div', 'git-bundle-viewer');
|
|
484
|
+
root.dataset.viewerZoomProvider = 'git-bundle';
|
|
485
|
+
const toolbar = createElement(documentRef, 'div', 'git-bundle-toolbar');
|
|
486
|
+
toolbar.append(createElement(documentRef, 'span', undefined, type.toUpperCase()), createElement(documentRef, 'strong', undefined, `${model.commits.length} commits · ${model.files.length} files`));
|
|
487
|
+
const layout = createElement(documentRef, 'div', 'git-bundle-layout');
|
|
488
|
+
const historyPanel = createElement(documentRef, 'section', 'git-bundle-panel');
|
|
489
|
+
historyPanel.appendChild(createElement(documentRef, 'h3', undefined, '历史记录'));
|
|
490
|
+
renderMeta(documentRef, historyPanel, model);
|
|
491
|
+
const historyList = createElement(documentRef, 'ul', 'git-bundle-list');
|
|
492
|
+
historyPanel.appendChild(historyList);
|
|
493
|
+
const treePanel = createElement(documentRef, 'section', 'git-bundle-panel');
|
|
494
|
+
treePanel.appendChild(createElement(documentRef, 'h3', undefined, '文件树'));
|
|
495
|
+
const tree = createElement(documentRef, 'div', 'git-bundle-tree');
|
|
496
|
+
treePanel.appendChild(tree);
|
|
497
|
+
const filePanel = createElement(documentRef, 'section', 'git-bundle-panel git-bundle-file');
|
|
498
|
+
const fileHeader = createElement(documentRef, 'div', 'git-bundle-file-header', '选择文件查看内容');
|
|
499
|
+
const fileCode = createElement(documentRef, 'pre', 'git-bundle-code', '');
|
|
500
|
+
filePanel.append(fileHeader, fileCode);
|
|
501
|
+
layout.append(historyPanel, treePanel, filePanel);
|
|
502
|
+
root.append(toolbar, layout);
|
|
503
|
+
target.replaceChildren(createStyle(documentRef), root);
|
|
504
|
+
const renderFiles = (files) => {
|
|
505
|
+
tree.replaceChildren();
|
|
506
|
+
if (!files.length) {
|
|
507
|
+
tree.appendChild(createElement(documentRef, 'div', 'git-bundle-notice', '当前 bundle 的 tree/blob 可能被 delta 压缩,暂未解析到可展开文件。'));
|
|
508
|
+
fileHeader.textContent = '未解析到文件';
|
|
509
|
+
fileCode.textContent = '';
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
files.forEach((file, index) => {
|
|
513
|
+
const button = createElement(documentRef, 'button');
|
|
514
|
+
button.type = 'button';
|
|
515
|
+
button.textContent = `${file.path} · ${file.size} B`;
|
|
516
|
+
button.addEventListener('click', () => {
|
|
517
|
+
tree.querySelectorAll('button').forEach(item => item.classList.remove('active'));
|
|
518
|
+
button.classList.add('active');
|
|
519
|
+
fileHeader.textContent = `${file.path} · ${shortOid(file.oid)}`;
|
|
520
|
+
fileCode.textContent = file.preview;
|
|
521
|
+
});
|
|
522
|
+
tree.appendChild(button);
|
|
523
|
+
if (index === 0) {
|
|
524
|
+
button.click();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
model.commits.forEach((commit, index) => {
|
|
529
|
+
const button = createElement(documentRef, 'button');
|
|
530
|
+
button.type = 'button';
|
|
531
|
+
button.innerHTML = '';
|
|
532
|
+
button.append(documentRef.createTextNode(commit.refs[0] || commitTitle(commit)), createElement(documentRef, 'small', undefined, `${shortOid(commit.oid)} · ${commitTitle(commit)}`));
|
|
533
|
+
button.addEventListener('click', () => {
|
|
534
|
+
historyList.querySelectorAll('button').forEach(item => item.classList.remove('active'));
|
|
535
|
+
button.classList.add('active');
|
|
536
|
+
const objectMap = new Map(model.objects.flatMap(object => object.oid ? [[object.oid, object]] : []));
|
|
537
|
+
const commitTree = collectTree(objectMap, commit.tree);
|
|
538
|
+
renderFiles(commitTree.files);
|
|
539
|
+
});
|
|
540
|
+
historyList.appendChild(button);
|
|
541
|
+
if (index === 0) {
|
|
542
|
+
button.click();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
if (!model.commits.length) {
|
|
546
|
+
historyList.appendChild(createElement(documentRef, 'li', 'git-bundle-notice', '当前 bundle 未解析到 commit 对象,仅展示 refs 和 pack 摘要。'));
|
|
547
|
+
renderFiles(model.files);
|
|
548
|
+
}
|
|
549
|
+
const getZoomState = () => ({
|
|
550
|
+
scale: zoom,
|
|
551
|
+
label: `${Math.round(zoom * 100)}%`,
|
|
552
|
+
canZoomIn: zoom < 2.2,
|
|
553
|
+
canZoomOut: zoom > 0.65,
|
|
554
|
+
canReset: zoom !== 1,
|
|
555
|
+
minScale: 0.65,
|
|
556
|
+
maxScale: 2.2
|
|
557
|
+
});
|
|
558
|
+
const setZoom = (scale) => {
|
|
559
|
+
zoom = clampZoom(scale);
|
|
560
|
+
root.style.setProperty('--bundle-font-size', `${13 * zoom}px`);
|
|
561
|
+
zoomEmitter.emit();
|
|
562
|
+
return getZoomState();
|
|
563
|
+
};
|
|
564
|
+
setZoom(1);
|
|
565
|
+
registerFileViewerZoomProvider(root, {
|
|
566
|
+
zoomIn: () => setZoom(zoom + 0.1),
|
|
567
|
+
zoomOut: () => setZoom(zoom - 0.1),
|
|
568
|
+
resetZoom: () => setZoom(1),
|
|
569
|
+
setZoom,
|
|
570
|
+
getState: getZoomState,
|
|
571
|
+
subscribe: zoomEmitter.subscribe
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
$el: root,
|
|
575
|
+
unmount() {
|
|
576
|
+
unregisterFileViewerZoomProvider(root);
|
|
577
|
+
target.replaceChildren();
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type FileRenderHandler, type FileViewerRenderedInstance, type FileViewerRendererPlugin, type RendererDefinition } from '@file-viewer/core';
|
|
2
|
+
export declare const textRendererDefinitions: RendererDefinition[];
|
|
3
|
+
export declare const renderFileViewerCode: FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>;
|
|
4
|
+
export declare const renderFileViewerMarkdown: FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>;
|
|
5
|
+
export declare const textRenderer: FileViewerRendererPlugin<FileRenderHandler<FileViewerRenderedInstance, HTMLDivElement>>;
|
|
6
|
+
export default textRenderer;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DEFAULT_RENDERER_DEFINITIONS, } from '@file-viewer/core';
|
|
2
|
+
const textRendererIds = ['code', 'markdown'];
|
|
3
|
+
const textDefinitions = DEFAULT_RENDERER_DEFINITIONS.filter(definition => textRendererIds.includes(definition.id));
|
|
4
|
+
if (textDefinitions.length !== textRendererIds.length) {
|
|
5
|
+
throw new Error('@file-viewer/renderer-text could not locate the shared code/markdown format definitions.');
|
|
6
|
+
}
|
|
7
|
+
export const textRendererDefinitions = textDefinitions;
|
|
8
|
+
export const renderFileViewerCode = (buffer, target, type) => import('./code.js').then(({ default: renderCode }) => renderCode(buffer, target, type));
|
|
9
|
+
export const renderFileViewerMarkdown = (buffer, target) => import('./markdown.js').then(({ default: renderMarkdown }) => renderMarkdown(buffer, target));
|
|
10
|
+
export const textRenderer = {
|
|
11
|
+
id: 'file-viewer-renderer-text',
|
|
12
|
+
label: 'Flyfish File Viewer text renderer',
|
|
13
|
+
definitions: textRendererDefinitions,
|
|
14
|
+
handlers: [
|
|
15
|
+
{
|
|
16
|
+
rendererId: 'code',
|
|
17
|
+
handler: renderFileViewerCode,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
rendererId: 'markdown',
|
|
21
|
+
handler: renderFileViewerMarkdown,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
export default textRenderer;
|