@youtyan/code-viewer 0.1.11 → 0.1.12
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/package.json +1 -1
- package/web/app.js +711 -9
- package/web/style.css +184 -0
- package/web-src/routes.ts +43 -7
- package/web-src/server/preview.ts +109 -2
- package/web-src/server/search.ts +101 -0
- package/web-src/types.ts +24 -0
package/web/style.css
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
--danger: #cf222e;
|
|
30
30
|
--danger-emph: #cf222e;
|
|
31
31
|
--attn: #9a6700;
|
|
32
|
+
--line-hit-bg: #fff8c5;
|
|
33
|
+
--line-hit-border:var(--accent);
|
|
32
34
|
--done: #8250df;
|
|
33
35
|
/* diff colors (GitHub exact) */
|
|
34
36
|
--diff-add-bg: #dafbe1;
|
|
@@ -74,6 +76,8 @@
|
|
|
74
76
|
--danger: #f85149;
|
|
75
77
|
--danger-emph: #da3633;
|
|
76
78
|
--attn: #d29922;
|
|
79
|
+
--line-hit-bg: rgba(187,128,9,0.18);
|
|
80
|
+
--line-hit-border:var(--accent);
|
|
77
81
|
--done: #a371f7;
|
|
78
82
|
--diff-add-bg: rgba(46,160,67,0.15);
|
|
79
83
|
--diff-add-num-bg: rgba(46,160,67,0.30);
|
|
@@ -106,6 +110,140 @@ html, body {
|
|
|
106
110
|
/* Window scrolls; global header + topbar + sidebar are fixed */
|
|
107
111
|
#app { display: block; }
|
|
108
112
|
|
|
113
|
+
.gdp-palette-backdrop {
|
|
114
|
+
position: fixed;
|
|
115
|
+
inset: 0;
|
|
116
|
+
z-index: 500;
|
|
117
|
+
background: rgba(31, 35, 40, 0.18);
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: flex-start;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
padding-top: min(12vh, 96px);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
[data-theme="dark"] .gdp-palette-backdrop {
|
|
125
|
+
background: rgba(1, 4, 9, 0.45);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.gdp-palette {
|
|
129
|
+
width: min(760px, calc(100vw - 32px));
|
|
130
|
+
max-height: min(620px, calc(100vh - 48px));
|
|
131
|
+
display: grid;
|
|
132
|
+
grid-template-rows: auto auto auto auto 1fr;
|
|
133
|
+
overflow: hidden;
|
|
134
|
+
background: var(--bg);
|
|
135
|
+
border: 1px solid var(--border);
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
box-shadow: 0 16px 48px rgba(31, 35, 40, 0.28);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.gdp-palette-label {
|
|
141
|
+
padding: 12px 16px 0;
|
|
142
|
+
color: var(--fg-muted);
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.gdp-palette-input {
|
|
149
|
+
width: 100%;
|
|
150
|
+
border: 0;
|
|
151
|
+
border-bottom: 1px solid var(--border);
|
|
152
|
+
padding: 10px 16px 14px;
|
|
153
|
+
background: transparent;
|
|
154
|
+
color: var(--fg);
|
|
155
|
+
font-size: 24px;
|
|
156
|
+
line-height: 1.25;
|
|
157
|
+
outline: none;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.gdp-palette-status {
|
|
161
|
+
min-height: 28px;
|
|
162
|
+
padding: 6px 16px;
|
|
163
|
+
color: var(--fg-muted);
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
border-bottom: 1px solid var(--border-muted);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.gdp-palette-controls {
|
|
169
|
+
min-height: 34px;
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 6px;
|
|
173
|
+
padding: 6px 16px 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.gdp-palette-mode-button {
|
|
177
|
+
height: 24px;
|
|
178
|
+
padding: 0 8px;
|
|
179
|
+
border: 1px solid var(--border);
|
|
180
|
+
border-radius: 6px;
|
|
181
|
+
background: var(--bg);
|
|
182
|
+
color: var(--fg-muted);
|
|
183
|
+
font-size: 12px;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.gdp-palette-mode-button[aria-pressed="true"] {
|
|
189
|
+
border-color: var(--accent);
|
|
190
|
+
background: var(--accent-subtle);
|
|
191
|
+
color: var(--accent);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.gdp-palette-mode-hint {
|
|
195
|
+
color: var(--fg-muted);
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.gdp-palette-list {
|
|
200
|
+
overflow: auto;
|
|
201
|
+
padding: 6px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.gdp-palette-row {
|
|
205
|
+
width: 100%;
|
|
206
|
+
min-height: 52px;
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-rows: auto auto;
|
|
209
|
+
gap: 2px;
|
|
210
|
+
padding: 8px 10px;
|
|
211
|
+
border: 0;
|
|
212
|
+
border-radius: 6px;
|
|
213
|
+
background: transparent;
|
|
214
|
+
color: var(--fg);
|
|
215
|
+
text-align: left;
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.gdp-palette-row[aria-selected="true"] {
|
|
220
|
+
background: var(--accent-subtle);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.gdp-palette-row-title {
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
text-overflow: ellipsis;
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
font-size: 14px;
|
|
228
|
+
font-weight: 600;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.gdp-palette-row-detail {
|
|
232
|
+
overflow: hidden;
|
|
233
|
+
text-overflow: ellipsis;
|
|
234
|
+
white-space: nowrap;
|
|
235
|
+
color: var(--fg-muted);
|
|
236
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
237
|
+
font-size: 12px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.gdp-palette-row-detail mark {
|
|
241
|
+
background: var(--attn);
|
|
242
|
+
color: var(--fg-onemphasis);
|
|
243
|
+
border-radius: 2px;
|
|
244
|
+
padding: 0 1px;
|
|
245
|
+
}
|
|
246
|
+
|
|
109
247
|
/* ===== Global header ===== */
|
|
110
248
|
#global-header {
|
|
111
249
|
position: fixed;
|
|
@@ -1286,6 +1424,17 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1286
1424
|
width: 1.5em;
|
|
1287
1425
|
padding-left: 4px;
|
|
1288
1426
|
}
|
|
1427
|
+
|
|
1428
|
+
table.d2h-diff-table tr.gdp-diff-line-target > td,
|
|
1429
|
+
table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-line,
|
|
1430
|
+
table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-side-line,
|
|
1431
|
+
table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-line-ctn {
|
|
1432
|
+
background: var(--line-hit-bg) !important;
|
|
1433
|
+
background-color: var(--line-hit-bg) !important;
|
|
1434
|
+
}
|
|
1435
|
+
table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
|
|
1436
|
+
box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
|
|
1437
|
+
}
|
|
1289
1438
|
/* Stack height = number of buttons * 20px. With 1 button = 20px row,
|
|
1290
1439
|
* with 2 (↑+↓) = 40px row, matching GitHub. */
|
|
1291
1440
|
.gdp-expand-stack {
|
|
@@ -1602,6 +1751,33 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1602
1751
|
font-size: 12px;
|
|
1603
1752
|
line-height: 20px;
|
|
1604
1753
|
}
|
|
1754
|
+
.gdp-source-table tr.gdp-source-line-target {
|
|
1755
|
+
background: var(--line-hit-bg);
|
|
1756
|
+
}
|
|
1757
|
+
.gdp-source-table tr.gdp-source-line-target .gdp-source-line-number,
|
|
1758
|
+
.gdp-source-table tr.gdp-source-line-target .gdp-source-line-code,
|
|
1759
|
+
.gdp-source-virtual-row.gdp-source-line-target {
|
|
1760
|
+
background: var(--line-hit-bg) !important;
|
|
1761
|
+
background-color: var(--line-hit-bg) !important;
|
|
1762
|
+
}
|
|
1763
|
+
.gdp-source-table tr.gdp-source-line-target > td.gdp-source-line-number,
|
|
1764
|
+
.gdp-source-virtual-row.gdp-source-line-target > .gdp-source-virtual-line-number {
|
|
1765
|
+
background: var(--line-hit-bg) !important;
|
|
1766
|
+
background-color: var(--line-hit-bg) !important;
|
|
1767
|
+
}
|
|
1768
|
+
.gdp-source-table tr.gdp-source-line-target .gdp-source-line-number,
|
|
1769
|
+
.gdp-source-virtual-row.gdp-source-line-target .gdp-source-virtual-line-number {
|
|
1770
|
+
box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
|
|
1771
|
+
}
|
|
1772
|
+
.gdp-source-line-target .gdp-source-line-code,
|
|
1773
|
+
.gdp-source-line-target .gdp-source-virtual-line-code {
|
|
1774
|
+
background: var(--line-hit-bg) !important;
|
|
1775
|
+
background-color: var(--line-hit-bg) !important;
|
|
1776
|
+
}
|
|
1777
|
+
.gdp-source-line-target .gdp-source-line-code.shiki span,
|
|
1778
|
+
.gdp-source-line-target .gdp-source-virtual-line-code.hljs span {
|
|
1779
|
+
background: transparent !important;
|
|
1780
|
+
}
|
|
1605
1781
|
.gdp-standalone-source .gdp-source-table {
|
|
1606
1782
|
display: block;
|
|
1607
1783
|
overflow: auto;
|
|
@@ -1660,6 +1836,11 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1660
1836
|
.gdp-source-line-code.hljs {
|
|
1661
1837
|
background: var(--bg);
|
|
1662
1838
|
}
|
|
1839
|
+
.gdp-source-line-target .gdp-source-line-code.hljs,
|
|
1840
|
+
.gdp-source-line-target .gdp-source-line-code.shiki,
|
|
1841
|
+
.gdp-source-line-target .gdp-source-virtual-line-code.hljs {
|
|
1842
|
+
background: var(--line-hit-bg);
|
|
1843
|
+
}
|
|
1663
1844
|
.gdp-source-line-code.shiki,
|
|
1664
1845
|
.gdp-source-line-code.shiki span {
|
|
1665
1846
|
color: var(--shiki-light) !important;
|
|
@@ -1733,6 +1914,8 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1733
1914
|
font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1734
1915
|
font-size: 12px;
|
|
1735
1916
|
line-height: 20px;
|
|
1917
|
+
cursor: pointer;
|
|
1918
|
+
user-select: none;
|
|
1736
1919
|
}
|
|
1737
1920
|
.gdp-source-virtual-spacer {
|
|
1738
1921
|
position: relative;
|
|
@@ -1760,6 +1943,7 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1760
1943
|
background: var(--bg);
|
|
1761
1944
|
box-shadow: inset -1px 0 0 var(--border-muted);
|
|
1762
1945
|
font-variant-numeric: tabular-nums;
|
|
1946
|
+
cursor: pointer;
|
|
1763
1947
|
user-select: none;
|
|
1764
1948
|
}
|
|
1765
1949
|
.gdp-source-virtual-line-code {
|
package/web-src/routes.ts
CHANGED
|
@@ -3,6 +3,13 @@ export type DiffRange = {
|
|
|
3
3
|
to: string;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
+
export type SourceLineRange = {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type SourceLineTarget = number | SourceLineRange;
|
|
12
|
+
|
|
6
13
|
export type SourceFileTarget = {
|
|
7
14
|
path: string;
|
|
8
15
|
ref: string;
|
|
@@ -10,8 +17,8 @@ export type SourceFileTarget = {
|
|
|
10
17
|
|
|
11
18
|
export type AppRoute =
|
|
12
19
|
| { screen: 'repo'; ref: string; path: string; range: DiffRange }
|
|
13
|
-
| { screen: 'diff'; range: DiffRange }
|
|
14
|
-
| { screen: 'file'; path: string; ref: string; range: DiffRange; view?: 'blob' | 'detail' }
|
|
20
|
+
| { screen: 'diff'; range: DiffRange; path?: string; line?: SourceLineTarget }
|
|
21
|
+
| { screen: 'file'; path: string; ref: string; range: DiffRange; view?: 'blob' | 'detail'; line?: SourceLineTarget }
|
|
15
22
|
| { screen: 'unknown'; reason: 'unknown-pathname' | 'missing-path'; rawPathname: string; rawSearch: string; range: DiffRange };
|
|
16
23
|
|
|
17
24
|
export const SPA_PATHS = ['/todif', '/todiff', '/file'] as const;
|
|
@@ -31,6 +38,25 @@ function parseLegacyRange(value: string | null | undefined, fallback: DiffRange)
|
|
|
31
38
|
};
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
function parseLineTarget(value: string | null | undefined): SourceLineTarget | undefined {
|
|
42
|
+
const raw = value || '';
|
|
43
|
+
const range = /^(\d+)-(\d+)$/.exec(raw);
|
|
44
|
+
if (range) {
|
|
45
|
+
const a = Number(range[1]);
|
|
46
|
+
const b = Number(range[2]);
|
|
47
|
+
const start = Math.min(a, b);
|
|
48
|
+
const end = Math.max(a, b);
|
|
49
|
+
if (start > 0) return { start, end };
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const line = Number(raw);
|
|
53
|
+
return Number.isInteger(line) && line > 0 ? line : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatLineTarget(line: SourceLineTarget): string {
|
|
57
|
+
return typeof line === 'number' ? String(line) : line.start + '-' + line.end;
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
export function parseRoute(pathname: string, search: string, fallbackRange: DiffRange): AppRoute {
|
|
35
61
|
const params = new URLSearchParams(search);
|
|
36
62
|
const legacyRange = parseLegacyRange(params.get('range'), fallbackRange);
|
|
@@ -49,13 +75,19 @@ export function parseRoute(pathname: string, search: string, fallbackRange: Diff
|
|
|
49
75
|
};
|
|
50
76
|
case '/todif':
|
|
51
77
|
case '/todiff':
|
|
52
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
screen: 'diff',
|
|
80
|
+
range,
|
|
81
|
+
...(params.get('path') ? { path: params.get('path') || '' } : {}),
|
|
82
|
+
...(parseLineTarget(params.get('line')) ? { line: parseLineTarget(params.get('line')) } : {}),
|
|
83
|
+
};
|
|
53
84
|
case '/file': {
|
|
54
85
|
const path = params.get('path') || '';
|
|
55
86
|
const target = params.get('target') || '';
|
|
56
87
|
const ref = target || params.get('ref') || 'worktree';
|
|
88
|
+
const line = parseLineTarget(params.get('line'));
|
|
57
89
|
if (!path) return { screen: 'unknown', reason: 'missing-path', rawPathname: pathname, rawSearch: search, range };
|
|
58
|
-
return { screen: 'file', path, ref, range, view: target ? 'blob' : 'detail' };
|
|
90
|
+
return { screen: 'file', path, ref, range, view: target ? 'blob' : 'detail', ...(line ? { line } : {}) };
|
|
59
91
|
}
|
|
60
92
|
default:
|
|
61
93
|
return { screen: 'unknown', reason: 'unknown-pathname', rawPathname: pathname, rawSearch: search, range };
|
|
@@ -74,15 +106,19 @@ export function buildRoute(route: AppRoute): string {
|
|
|
74
106
|
case 'file':
|
|
75
107
|
if (route.view === 'blob') {
|
|
76
108
|
return '/file?path=' + encodeURIComponent(route.path) +
|
|
77
|
-
'&target=' + encodeURIComponent(route.ref || 'worktree')
|
|
109
|
+
'&target=' + encodeURIComponent(route.ref || 'worktree') +
|
|
110
|
+
(route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
|
|
78
111
|
}
|
|
79
112
|
return '/file?path=' + encodeURIComponent(route.path) +
|
|
80
113
|
'&ref=' + encodeURIComponent(route.ref || 'worktree') +
|
|
81
114
|
'&from=' + encodeURIComponent(route.range.from || '') +
|
|
82
|
-
'&to=' + encodeURIComponent(route.range.to || 'worktree')
|
|
115
|
+
'&to=' + encodeURIComponent(route.range.to || 'worktree') +
|
|
116
|
+
(route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
|
|
83
117
|
case 'diff':
|
|
84
118
|
return '/todif?from=' + encodeURIComponent(route.range.from || '') +
|
|
85
|
-
'&to=' + encodeURIComponent(route.range.to || 'worktree')
|
|
119
|
+
'&to=' + encodeURIComponent(route.range.to || 'worktree') +
|
|
120
|
+
(route.path ? '&path=' + encodeURIComponent(route.path) : '') +
|
|
121
|
+
(route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
|
|
86
122
|
case 'unknown':
|
|
87
123
|
return '/todif?from=' + encodeURIComponent(route.range.from || '') +
|
|
88
124
|
'&to=' + encodeURIComponent(route.range.to || 'worktree');
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { closeSync, constants, existsSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, extname, join, normalize, relative } from 'node:path';
|
|
5
5
|
import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
|
|
6
|
-
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
|
|
6
|
+
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } from '../types';
|
|
7
7
|
import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
|
|
8
8
|
import { startDevAssetReload } from './dev-assets';
|
|
9
9
|
import * as git from './git';
|
|
10
10
|
import { isSameWorktreeRange } from './range';
|
|
11
|
+
import {
|
|
12
|
+
GREP_MAX_FILE_BYTES,
|
|
13
|
+
buildFileSearchList,
|
|
14
|
+
buildRgArgs,
|
|
15
|
+
fixedStringLineMatches,
|
|
16
|
+
isSkippableSearchPath,
|
|
17
|
+
normalizeGrepMax,
|
|
18
|
+
parseGitGrepOutput,
|
|
19
|
+
parseRgOutput,
|
|
20
|
+
} from './search';
|
|
11
21
|
|
|
12
22
|
const ROOT = normalize(join(import.meta.dir, '..', '..'));
|
|
13
23
|
const WEB_ROOT = join(ROOT, 'web');
|
|
@@ -35,11 +45,13 @@ let cliArgs = DEFAULT_ARGS;
|
|
|
35
45
|
let listenPort = 0;
|
|
36
46
|
let allowUpload = false;
|
|
37
47
|
let uploadAllowedByCli = false;
|
|
48
|
+
let rgAvailableCache: boolean | null = null;
|
|
38
49
|
|
|
39
50
|
const enc = new TextEncoder();
|
|
40
51
|
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
41
52
|
const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
|
|
42
53
|
const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
|
|
54
|
+
const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
|
|
43
55
|
|
|
44
56
|
function parseCli() {
|
|
45
57
|
const rest: string[] = [];
|
|
@@ -395,6 +407,98 @@ function handleTree(url: URL) {
|
|
|
395
407
|
} satisfies RepoTreeResponse);
|
|
396
408
|
}
|
|
397
409
|
|
|
410
|
+
function handleFiles(url: URL) {
|
|
411
|
+
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
412
|
+
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
413
|
+
const key = target || 'worktree';
|
|
414
|
+
const cached = fileListCache.get(key);
|
|
415
|
+
if (cached && cached.generation === generation) return json(cached.body);
|
|
416
|
+
const entries = git.listTree(key, '', cwd, { recursive: true }).entries;
|
|
417
|
+
const body = buildFileSearchList(key, generation, entries);
|
|
418
|
+
fileListCache.set(key, { generation, body });
|
|
419
|
+
return json(body);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseGrepPaths(url: URL): string[] {
|
|
423
|
+
return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function rgAvailable(): boolean {
|
|
427
|
+
if (rgAvailableCache !== null) return rgAvailableCache;
|
|
428
|
+
const proc = Bun.spawnSync(['rg', '--version'], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
429
|
+
rgAvailableCache = proc.exitCode === 0;
|
|
430
|
+
return rgAvailableCache;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
|
|
434
|
+
const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
|
|
435
|
+
const matches: GrepMatch[] = [];
|
|
436
|
+
for (const path of candidates) {
|
|
437
|
+
if (matches.length >= max) break;
|
|
438
|
+
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
|
|
439
|
+
const full = safeWorktreePath(path);
|
|
440
|
+
if (!full) continue;
|
|
441
|
+
let stat;
|
|
442
|
+
try {
|
|
443
|
+
stat = lstatSync(full);
|
|
444
|
+
} catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > GREP_MAX_FILE_BYTES) continue;
|
|
448
|
+
let data: Buffer;
|
|
449
|
+
try {
|
|
450
|
+
data = readFileSync(full);
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (data.subarray(0, 8192).includes(0)) continue;
|
|
455
|
+
matches.push(...fixedStringLineMatches(path, data.toString('utf8'), query, max - matches.length));
|
|
456
|
+
}
|
|
457
|
+
return matches;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function grepWorktree(query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
461
|
+
if (rgAvailable()) {
|
|
462
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
|
|
463
|
+
const args = buildRgArgs(query, max, safePaths, regex);
|
|
464
|
+
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
465
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
466
|
+
const matches = parseRgOutput(stdout, max)
|
|
467
|
+
.filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !!safeWorktreePath(match.path));
|
|
468
|
+
return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
|
|
469
|
+
}
|
|
470
|
+
if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
|
|
471
|
+
const matches = grepWorktreeFallback(query, max, paths);
|
|
472
|
+
return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
476
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path));
|
|
477
|
+
const args = [
|
|
478
|
+
'git', '-c', 'core.quotepath=false', 'grep',
|
|
479
|
+
'-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
|
|
480
|
+
'-e', query,
|
|
481
|
+
ref, '--',
|
|
482
|
+
...safePaths,
|
|
483
|
+
];
|
|
484
|
+
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
485
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
486
|
+
const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
|
|
487
|
+
return { ref, engine: 'git', truncated: matches.length >= max, matches };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function handleGrep(url: URL) {
|
|
491
|
+
const query = url.searchParams.get('q') || '';
|
|
492
|
+
const ref = url.searchParams.get('ref') || 'worktree';
|
|
493
|
+
const max = normalizeGrepMax(url.searchParams.get('max'));
|
|
494
|
+
const paths = parseGrepPaths(url);
|
|
495
|
+
const regex = url.searchParams.get('regex') === '1';
|
|
496
|
+
if (!query.trim()) return json({ ref, engine: ref === 'worktree' ? 'fallback' : 'git', truncated: false, matches: [] } satisfies GrepResponse);
|
|
497
|
+
if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex));
|
|
498
|
+
if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
|
|
499
|
+
return json(grepTreeRef(ref, query, max, paths, regex));
|
|
500
|
+
}
|
|
501
|
+
|
|
398
502
|
function handleFileDiff(url: URL) {
|
|
399
503
|
const path = url.searchParams.get('path') || '';
|
|
400
504
|
if (!safePath(path)) return text('invalid path', 400);
|
|
@@ -739,6 +843,8 @@ const server = Bun.serve({
|
|
|
739
843
|
if (staticResponse) return staticResponse;
|
|
740
844
|
if (url.pathname === '/diff.json') return handleDiffJson(url);
|
|
741
845
|
if (url.pathname === '/_tree') return handleTree(url);
|
|
846
|
+
if (url.pathname === '/_files') return handleFiles(url);
|
|
847
|
+
if (url.pathname === '/_grep') return handleGrep(url);
|
|
742
848
|
if (url.pathname === '/file_diff') return handleFileDiff(url);
|
|
743
849
|
if (url.pathname === '/file_range') return handleFileRange(url);
|
|
744
850
|
if (url.pathname === '/_file') return handleRawFile(req, url);
|
|
@@ -750,6 +856,7 @@ const server = Bun.serve({
|
|
|
750
856
|
generation++;
|
|
751
857
|
fileCache.clear();
|
|
752
858
|
metaCache.clear();
|
|
859
|
+
fileListCache.clear();
|
|
753
860
|
sendSse('update');
|
|
754
861
|
return json({ ok: true, generation });
|
|
755
862
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { FileSearchListResponse, GrepMatch } from '../types';
|
|
2
|
+
import type { GitTreeEntry } from './git';
|
|
3
|
+
|
|
4
|
+
export const GREP_DEFAULT_MAX = 200;
|
|
5
|
+
export const GREP_ABSOLUTE_MAX = 500;
|
|
6
|
+
export const GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
export const FILE_SEARCH_ABSOLUTE_MAX = 50000;
|
|
8
|
+
|
|
9
|
+
export function normalizeGrepMax(value: string | null): number {
|
|
10
|
+
const parsed = Number(value || '');
|
|
11
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return GREP_DEFAULT_MAX;
|
|
12
|
+
return Math.min(parsed, GREP_ABSOLUTE_MAX);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSkippableSearchPath(path: string): boolean {
|
|
16
|
+
return path.split(/[\\/]+/).some(part => {
|
|
17
|
+
const lower = part.toLowerCase();
|
|
18
|
+
return lower === '.git' || lower === 'node_modules';
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function fixedStringLineMatches(path: string, text: string, query: string, max: number): GrepMatch[] {
|
|
23
|
+
const needle = query.toLowerCase();
|
|
24
|
+
if (!needle) return [];
|
|
25
|
+
const matches: GrepMatch[] = [];
|
|
26
|
+
const lines = text.split('\n');
|
|
27
|
+
for (let i = 0; i < lines.length && matches.length < max; i++) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
const column = line.toLowerCase().indexOf(needle);
|
|
30
|
+
if (column < 0) continue;
|
|
31
|
+
matches.push({
|
|
32
|
+
path,
|
|
33
|
+
line: i + 1,
|
|
34
|
+
column: column + 1,
|
|
35
|
+
preview: line.slice(0, 500),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return matches;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildFileSearchList(ref: string, generation: number, entries: GitTreeEntry[]): FileSearchListResponse {
|
|
42
|
+
const files = entries
|
|
43
|
+
.filter((entry): entry is GitTreeEntry & { type: 'blob' | 'commit' } => entry.type === 'blob' || entry.type === 'commit')
|
|
44
|
+
.slice(0, FILE_SEARCH_ABSOLUTE_MAX)
|
|
45
|
+
.map(entry => ({ path: entry.path, type: entry.type }));
|
|
46
|
+
return {
|
|
47
|
+
ref,
|
|
48
|
+
generation,
|
|
49
|
+
files,
|
|
50
|
+
truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildRgArgs(query: string, max: number, paths: string[], regex = false): string[] {
|
|
55
|
+
const safePaths = paths.length ? paths : ['.'];
|
|
56
|
+
const args = [
|
|
57
|
+
'rg',
|
|
58
|
+
'--no-config',
|
|
59
|
+
'--line-number',
|
|
60
|
+
'--column',
|
|
61
|
+
'--no-heading',
|
|
62
|
+
'--color',
|
|
63
|
+
'never',
|
|
64
|
+
'--smart-case',
|
|
65
|
+
'--max-count',
|
|
66
|
+
String(max),
|
|
67
|
+
'--max-filesize',
|
|
68
|
+
'2M',
|
|
69
|
+
'-e',
|
|
70
|
+
query,
|
|
71
|
+
'--',
|
|
72
|
+
...safePaths,
|
|
73
|
+
];
|
|
74
|
+
if (!regex) args.splice(8, 0, '--fixed-strings');
|
|
75
|
+
return args;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
79
|
+
const matches: GrepMatch[] = [];
|
|
80
|
+
for (const line of stdout.split('\n')) {
|
|
81
|
+
if (!line || matches.length >= max) continue;
|
|
82
|
+
const parsed = /^(.*):(\d+):(\d+):(.*)$/.exec(line);
|
|
83
|
+
if (!parsed) continue;
|
|
84
|
+
const path = parsed[1];
|
|
85
|
+
const lineNo = Number(parsed[2]);
|
|
86
|
+
const column = Number(parsed[3]);
|
|
87
|
+
const preview = parsed[4];
|
|
88
|
+
if (!path || !lineNo || !column || isSkippableSearchPath(path)) continue;
|
|
89
|
+
matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
|
|
90
|
+
}
|
|
91
|
+
return matches;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseGitGrepOutput(stdout: string, ref: string, max: number): GrepMatch[] {
|
|
95
|
+
const prefix = ref + ':';
|
|
96
|
+
const normalized = stdout
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map(line => line.startsWith(prefix) ? line.slice(prefix.length) : line)
|
|
99
|
+
.join('\n');
|
|
100
|
+
return parseRgOutput(normalized, max);
|
|
101
|
+
}
|
package/web-src/types.ts
CHANGED
|
@@ -53,6 +53,30 @@ export type RepoTreeResponse = {
|
|
|
53
53
|
} | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
export type FileSearchListResponse = {
|
|
57
|
+
ref: string;
|
|
58
|
+
generation: number;
|
|
59
|
+
files: {
|
|
60
|
+
path: string;
|
|
61
|
+
type: 'blob' | 'commit';
|
|
62
|
+
}[];
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type GrepMatch = {
|
|
67
|
+
path: string;
|
|
68
|
+
line: number;
|
|
69
|
+
column: number;
|
|
70
|
+
preview: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type GrepResponse = {
|
|
74
|
+
ref: string;
|
|
75
|
+
engine: 'rg' | 'git' | 'fallback';
|
|
76
|
+
truncated: boolean;
|
|
77
|
+
matches: GrepMatch[];
|
|
78
|
+
};
|
|
79
|
+
|
|
56
80
|
export type FileDiffResponse = {
|
|
57
81
|
path: string;
|
|
58
82
|
old_path?: string;
|