@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.
@@ -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;
@@ -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 === 'node_modules';
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?: 'ignored' | 'internal';
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