@youtyan/code-viewer 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +1 -1
- package/web/app.js +875 -109
- package/web/index.html +45 -8
- package/web/style.css +414 -72
- package/web-src/server/git.ts +90 -29
- package/web-src/server/preview.ts +301 -49
- package/web-src/server/range.ts +228 -0
- package/web-src/server/runtime.d.ts +10 -0
- package/web-src/server/search.ts +10 -7
- package/web-src/types.ts +16 -1
package/web-src/server/range.ts
CHANGED
|
@@ -3,6 +3,234 @@ export type DiffRange = {
|
|
|
3
3
|
to?: string;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
+
export type ByteRange = {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ByteRangeParseResult =
|
|
12
|
+
| { kind: 'range'; range: ByteRange }
|
|
13
|
+
| { kind: 'invalid' }
|
|
14
|
+
| { kind: 'unsatisfiable' };
|
|
15
|
+
|
|
16
|
+
export type LineRangeResult = {
|
|
17
|
+
lines: string[];
|
|
18
|
+
total: number;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LineOffsetIndex = {
|
|
23
|
+
size: number;
|
|
24
|
+
total: number;
|
|
25
|
+
newlines: Uint32Array | Float64Array;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type IndexedLineByteRange = {
|
|
29
|
+
start: number;
|
|
30
|
+
endExclusive: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type BytesWithLineOffsetIndex = {
|
|
34
|
+
bytes: Uint8Array;
|
|
35
|
+
index: LineOffsetIndex;
|
|
36
|
+
};
|
|
37
|
+
|
|
6
38
|
export function isSameWorktreeRange(range: DiffRange): boolean {
|
|
7
39
|
return range.from === 'worktree' && range.to === 'worktree';
|
|
8
40
|
}
|
|
41
|
+
|
|
42
|
+
export function parseHttpByteRange(header: string | null, size: number): ByteRangeParseResult {
|
|
43
|
+
if (!header) return { kind: 'invalid' };
|
|
44
|
+
if (size < 1) return { kind: 'unsatisfiable' };
|
|
45
|
+
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
46
|
+
if (!match) return { kind: 'invalid' };
|
|
47
|
+
const [, rawStart, rawEnd] = match;
|
|
48
|
+
if (!rawStart && !rawEnd) return { kind: 'invalid' };
|
|
49
|
+
let start: number;
|
|
50
|
+
let end: number;
|
|
51
|
+
if (!rawStart) {
|
|
52
|
+
const suffixLength = Number(rawEnd);
|
|
53
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength < 1) return { kind: 'unsatisfiable' };
|
|
54
|
+
start = Math.max(0, size - suffixLength);
|
|
55
|
+
end = size - 1;
|
|
56
|
+
} else {
|
|
57
|
+
start = Number(rawStart);
|
|
58
|
+
end = rawEnd ? Number(rawEnd) : size - 1;
|
|
59
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return { kind: 'invalid' };
|
|
60
|
+
if (end >= size) end = size - 1;
|
|
61
|
+
}
|
|
62
|
+
if (start < 0 || end < start || start >= size) return { kind: 'unsatisfiable' };
|
|
63
|
+
return { kind: 'range', range: { start, end } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function collectLineRangeFromStream(stream: ReadableStream<Uint8Array>, start: number, end: number): Promise<LineRangeResult> {
|
|
67
|
+
const reader = stream.getReader();
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
let lineNo = 1;
|
|
71
|
+
let pending = '';
|
|
72
|
+
let hasMore = false;
|
|
73
|
+
|
|
74
|
+
const pushLine = (line: string) => {
|
|
75
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
76
|
+
if (lineNo >= start && lineNo <= end) lines.push(line);
|
|
77
|
+
else if (lineNo > end) hasMore = true;
|
|
78
|
+
lineNo++;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
while (!hasMore) {
|
|
82
|
+
const chunk = await reader.read();
|
|
83
|
+
if (chunk.done) break;
|
|
84
|
+
pending += decoder.decode(chunk.value, { stream: true });
|
|
85
|
+
let newline = pending.indexOf('\n');
|
|
86
|
+
while (newline !== -1) {
|
|
87
|
+
pushLine(pending.slice(0, newline));
|
|
88
|
+
pending = pending.slice(newline + 1);
|
|
89
|
+
if (hasMore) break;
|
|
90
|
+
newline = pending.indexOf('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (hasMore) {
|
|
94
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
95
|
+
return { lines, total: lineNo - 1, complete: false };
|
|
96
|
+
}
|
|
97
|
+
pending += decoder.decode();
|
|
98
|
+
if (pending.length > 0) pushLine(pending);
|
|
99
|
+
if (hasMore) return { lines, total: lineNo - 1, complete: false };
|
|
100
|
+
return { lines, total: Math.max(0, lineNo - 1), complete: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildLineOffsetIndex(bytes: Uint8Array): LineOffsetIndex {
|
|
104
|
+
const builder = createLineOffsetIndexBuilder(bytes.length);
|
|
105
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
106
|
+
if (bytes[index] === 10) builder.push(index);
|
|
107
|
+
}
|
|
108
|
+
const lastByte = bytes.length > 0 ? bytes[bytes.length - 1] : -1;
|
|
109
|
+
return builder.finish(bytes.length, bytes.length > 0 && lastByte !== 10);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function buildLineOffsetIndexFromStream(stream: ReadableStream<Uint8Array>, size: number): Promise<LineOffsetIndex> {
|
|
113
|
+
const reader = stream.getReader();
|
|
114
|
+
const builder = createLineOffsetIndexBuilder(size);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
let lastByte = -1;
|
|
117
|
+
while (true) {
|
|
118
|
+
const chunk = await reader.read();
|
|
119
|
+
if (chunk.done) break;
|
|
120
|
+
const bytes = chunk.value;
|
|
121
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
122
|
+
const byte = bytes[index];
|
|
123
|
+
if (byte === 10) builder.push(offset + index);
|
|
124
|
+
lastByte = byte;
|
|
125
|
+
}
|
|
126
|
+
offset += bytes.length;
|
|
127
|
+
}
|
|
128
|
+
return builder.finish(offset, offset > 0 && lastByte !== 10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function collectByteRangeFromStream(stream: ReadableStream<Uint8Array>, start: number, endExclusive: number): Promise<Uint8Array> {
|
|
132
|
+
const reader = stream.getReader();
|
|
133
|
+
const chunks: Uint8Array[] = [];
|
|
134
|
+
let offset = 0;
|
|
135
|
+
let total = 0;
|
|
136
|
+
while (offset < endExclusive) {
|
|
137
|
+
const chunk = await reader.read();
|
|
138
|
+
if (chunk.done) break;
|
|
139
|
+
const chunkStart = offset;
|
|
140
|
+
const chunkEnd = offset + chunk.value.byteLength;
|
|
141
|
+
if (chunkEnd > start && chunkStart < endExclusive) {
|
|
142
|
+
const sliceStart = Math.max(0, start - chunkStart);
|
|
143
|
+
const sliceEnd = Math.min(chunk.value.byteLength, endExclusive - chunkStart);
|
|
144
|
+
const slice = chunk.value.subarray(sliceStart, sliceEnd);
|
|
145
|
+
chunks.push(slice);
|
|
146
|
+
total += slice.byteLength;
|
|
147
|
+
}
|
|
148
|
+
offset = chunkEnd;
|
|
149
|
+
}
|
|
150
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
151
|
+
if (chunks.length === 1) return chunks[0];
|
|
152
|
+
const bytes = new Uint8Array(total);
|
|
153
|
+
let writeOffset = 0;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
bytes.set(chunk, writeOffset);
|
|
156
|
+
writeOffset += chunk.byteLength;
|
|
157
|
+
}
|
|
158
|
+
return bytes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function collectBytesWithLineOffsetIndexFromStream(stream: ReadableStream<Uint8Array>, sizeHint: number): Promise<BytesWithLineOffsetIndex> {
|
|
162
|
+
const reader = stream.getReader();
|
|
163
|
+
const builder = createLineOffsetIndexBuilder(sizeHint);
|
|
164
|
+
const chunks: Uint8Array[] = [];
|
|
165
|
+
let offset = 0;
|
|
166
|
+
let lastByte = -1;
|
|
167
|
+
while (true) {
|
|
168
|
+
const chunk = await reader.read();
|
|
169
|
+
if (chunk.done) break;
|
|
170
|
+
const bytes = chunk.value;
|
|
171
|
+
chunks.push(bytes);
|
|
172
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
173
|
+
const byte = bytes[index];
|
|
174
|
+
if (byte === 10) builder.push(offset + index);
|
|
175
|
+
lastByte = byte;
|
|
176
|
+
}
|
|
177
|
+
offset += bytes.length;
|
|
178
|
+
}
|
|
179
|
+
const collected = new Uint8Array(offset);
|
|
180
|
+
let writeOffset = 0;
|
|
181
|
+
for (const chunk of chunks) {
|
|
182
|
+
collected.set(chunk, writeOffset);
|
|
183
|
+
writeOffset += chunk.byteLength;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
bytes: collected,
|
|
187
|
+
index: builder.finish(offset, offset > 0 && lastByte !== 10),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createLineOffsetIndexBuilder(size: number) {
|
|
192
|
+
const useFloat64 = size > 0xffffffff;
|
|
193
|
+
let capacity = 1024;
|
|
194
|
+
let length = 0;
|
|
195
|
+
let offsets: Uint32Array | Float64Array = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
196
|
+
const grow = () => {
|
|
197
|
+
capacity *= 2;
|
|
198
|
+
const next = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
199
|
+
next.set(offsets);
|
|
200
|
+
offsets = next;
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
push(offset: number) {
|
|
204
|
+
if (length >= capacity) grow();
|
|
205
|
+
offsets[length++] = offset;
|
|
206
|
+
},
|
|
207
|
+
finish(totalSize: number, hasTrailingLine: boolean): LineOffsetIndex {
|
|
208
|
+
return {
|
|
209
|
+
size: totalSize,
|
|
210
|
+
total: length + (hasTrailingLine ? 1 : 0),
|
|
211
|
+
newlines: offsets.slice(0, length) as Uint32Array | Float64Array,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function lineByteRangeForIndex(index: LineOffsetIndex, start: number, end: number): IndexedLineByteRange | null {
|
|
218
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
219
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
220
|
+
if (normalizedStart > index.total) return null;
|
|
221
|
+
const lastLine = Math.min(normalizedEnd, index.total);
|
|
222
|
+
const byteStart = normalizedStart <= 1 ? 0 : index.newlines[normalizedStart - 2] + 1;
|
|
223
|
+
const byteEnd = lastLine <= index.newlines.length ? index.newlines[lastLine - 1] : index.size;
|
|
224
|
+
return { start: byteStart, endExclusive: byteEnd };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function collectLineRangeFromIndexedText(text: string, index: LineOffsetIndex, start: number, end: number): LineRangeResult {
|
|
228
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
229
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
230
|
+
if (normalizedStart > index.total) return { lines: [], total: index.total, complete: true };
|
|
231
|
+
const expectedLines = Math.min(normalizedEnd, index.total) - normalizedStart + 1;
|
|
232
|
+
const lines = text.length
|
|
233
|
+
? text.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line)
|
|
234
|
+
: Array.from({ length: expectedLines }, () => '');
|
|
235
|
+
return { lines, total: index.total, complete: end >= index.total };
|
|
236
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
interface BunFile extends globalThis.Blob {
|
|
2
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
3
|
+
slice(start?: number, end?: number, contentType?: string): BunFile;
|
|
4
|
+
stream(): ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
5
|
+
text(): Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
declare const Bun: {
|
|
9
|
+
file(path: string): BunFile;
|
|
2
10
|
spawn(args: string[], opts?: Record<string, unknown>): {
|
|
3
11
|
kill(signal?: string): void;
|
|
4
12
|
exited: Promise<number>;
|
|
13
|
+
stdout?: ReadableStream<Uint8Array>;
|
|
14
|
+
stderr?: ReadableStream<Uint8Array>;
|
|
5
15
|
};
|
|
6
16
|
spawnSync(args: string[], opts?: Record<string, unknown>): {
|
|
7
17
|
exitCode: number;
|
package/web-src/server/search.ts
CHANGED
|
@@ -12,10 +12,11 @@ export function normalizeGrepMax(value: string | null): number {
|
|
|
12
12
|
return Math.min(parsed, GREP_ABSOLUTE_MAX);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function isSkippableSearchPath(path: string): boolean {
|
|
15
|
+
export function isSkippableSearchPath(path: string, omitDirNames: string[] = []): boolean {
|
|
16
|
+
const omitDirs = new Set(omitDirNames.map(name => name.toLowerCase()));
|
|
16
17
|
return path.split(/[\\/]+/).some(part => {
|
|
17
18
|
const lower = part.toLowerCase();
|
|
18
|
-
return lower === '.git' || lower
|
|
19
|
+
return lower === '.git' || omitDirs.has(lower);
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -51,8 +52,9 @@ export function buildFileSearchList(ref: string, generation: number, entries: Gi
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
export function buildRgArgs(query: string, max: number, paths: string[], regex = false): string[] {
|
|
55
|
+
export function buildRgArgs(query: string, max: number, paths: string[], regex = false, omitDirNames: string[] = []): string[] {
|
|
55
56
|
const safePaths = paths.length ? paths : ['.'];
|
|
57
|
+
const omitGlobs = omitDirNames.flatMap(name => ['--glob', `!${name}/**`, '--glob', `!**/${name}/**`]);
|
|
56
58
|
const args = [
|
|
57
59
|
'rg',
|
|
58
60
|
'--no-config',
|
|
@@ -66,6 +68,7 @@ export function buildRgArgs(query: string, max: number, paths: string[], regex =
|
|
|
66
68
|
String(max),
|
|
67
69
|
'--max-filesize',
|
|
68
70
|
'2M',
|
|
71
|
+
...omitGlobs,
|
|
69
72
|
'-e',
|
|
70
73
|
query,
|
|
71
74
|
'--',
|
|
@@ -75,7 +78,7 @@ export function buildRgArgs(query: string, max: number, paths: string[], regex =
|
|
|
75
78
|
return args;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
81
|
+
export function parseRgOutput(stdout: string, max: number, omitDirNames: string[] = []): GrepMatch[] {
|
|
79
82
|
const matches: GrepMatch[] = [];
|
|
80
83
|
for (const line of stdout.split('\n')) {
|
|
81
84
|
if (!line || matches.length >= max) continue;
|
|
@@ -85,17 +88,17 @@ export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
|
85
88
|
const lineNo = Number(parsed[2]);
|
|
86
89
|
const column = Number(parsed[3]);
|
|
87
90
|
const preview = parsed[4];
|
|
88
|
-
if (!path || !lineNo || !column || isSkippableSearchPath(path)) continue;
|
|
91
|
+
if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames)) continue;
|
|
89
92
|
matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
|
|
90
93
|
}
|
|
91
94
|
return matches;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
export function parseGitGrepOutput(stdout: string, ref: string, max: number): GrepMatch[] {
|
|
97
|
+
export function parseGitGrepOutput(stdout: string, ref: string, max: number, omitDirNames: string[] = []): GrepMatch[] {
|
|
95
98
|
const prefix = ref + ':';
|
|
96
99
|
const normalized = stdout
|
|
97
100
|
.split('\n')
|
|
98
101
|
.map(line => line.startsWith(prefix) ? line.slice(prefix.length) : line)
|
|
99
102
|
.join('\n');
|
|
100
|
-
return parseRgOutput(normalized, max);
|
|
103
|
+
return parseRgOutput(normalized, max, omitDirNames);
|
|
101
104
|
}
|
package/web-src/types.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type RepoTreeEntry = {
|
|
|
38
38
|
path: string;
|
|
39
39
|
type: 'tree' | 'blob' | 'commit';
|
|
40
40
|
children_omitted?: true;
|
|
41
|
-
children_omitted_reason?: '
|
|
41
|
+
children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
export type RepoTreeResponse = {
|
|
@@ -54,6 +54,15 @@ export type RepoTreeResponse = {
|
|
|
54
54
|
} | null;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
export type SettingsResponse = {
|
|
58
|
+
project: string;
|
|
59
|
+
scope: {
|
|
60
|
+
omit_dirs_effective: string[];
|
|
61
|
+
omit_dirs_built_in: string[];
|
|
62
|
+
max_entries: number;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
57
66
|
export type FileSearchListResponse = {
|
|
58
67
|
ref: string;
|
|
59
68
|
generation: number;
|
|
@@ -97,7 +106,13 @@ export type FileRangeResponse = {
|
|
|
97
106
|
start: number;
|
|
98
107
|
end: number;
|
|
99
108
|
lines: string[];
|
|
109
|
+
/**
|
|
110
|
+
* When complete is true, total is the file's total line count.
|
|
111
|
+
* When complete is false, total is only the highest line number the server
|
|
112
|
+
* had to scan to prove more lines exist.
|
|
113
|
+
*/
|
|
100
114
|
total: number;
|
|
115
|
+
complete?: boolean;
|
|
101
116
|
generation?: number;
|
|
102
117
|
};
|
|
103
118
|
|