@youtyan/code-viewer 0.1.10 → 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 +826 -29
- package/web/shiki.js +2 -1
- package/web/style.css +192 -0
- package/web-src/routes.ts +43 -7
- package/web-src/server/cache.ts +51 -0
- package/web-src/server/preview.ts +119 -9
- package/web-src/server/search.ts +101 -0
- package/web-src/types.ts +24 -0
package/web/shiki.js
CHANGED
|
@@ -13178,5 +13178,6 @@ var createHighlighter = /* @__PURE__ */ createBundledHighlighter({
|
|
|
13178
13178
|
engine: () => (0, engine_oniguruma_exports.createOnigurumaEngine)(Promise.resolve().then(() => (init_wasm2(), exports_wasm2)))
|
|
13179
13179
|
});
|
|
13180
13180
|
export {
|
|
13181
|
-
createHighlighter
|
|
13181
|
+
createHighlighter,
|
|
13182
|
+
bundledLanguages
|
|
13182
13183
|
};
|
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,19 @@ 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
|
+
}
|
|
1844
|
+
.gdp-source-line-code.shiki,
|
|
1845
|
+
.gdp-source-line-code.shiki span {
|
|
1846
|
+
color: var(--shiki-light) !important;
|
|
1847
|
+
}
|
|
1848
|
+
[data-theme="dark"] .gdp-source-line-code.shiki,
|
|
1849
|
+
[data-theme="dark"] .gdp-source-line-code.shiki span {
|
|
1850
|
+
color: var(--shiki-dark) !important;
|
|
1851
|
+
}
|
|
1663
1852
|
.gdp-source-virtual {
|
|
1664
1853
|
display: grid;
|
|
1665
1854
|
grid-template-rows: auto minmax(0, 1fr);
|
|
@@ -1725,6 +1914,8 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1725
1914
|
font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1726
1915
|
font-size: 12px;
|
|
1727
1916
|
line-height: 20px;
|
|
1917
|
+
cursor: pointer;
|
|
1918
|
+
user-select: none;
|
|
1728
1919
|
}
|
|
1729
1920
|
.gdp-source-virtual-spacer {
|
|
1730
1921
|
position: relative;
|
|
@@ -1752,6 +1943,7 @@ table.d2h-diff-table .d2h-code-line-prefix {
|
|
|
1752
1943
|
background: var(--bg);
|
|
1753
1944
|
box-shadow: inset -1px 0 0 var(--border-muted);
|
|
1754
1945
|
font-variant-numeric: tabular-nums;
|
|
1946
|
+
cursor: pointer;
|
|
1755
1947
|
user-select: none;
|
|
1756
1948
|
}
|
|
1757
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');
|
package/web-src/server/cache.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { lstatSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
1
4
|
// Short enough that a browser reload self-heals stale git data, while still
|
|
2
5
|
// coalescing bursts from one render pass.
|
|
3
6
|
export const CACHE_TTL_MS = 1500;
|
|
7
|
+
export const MAX_TIMED_CACHE_ENTRIES = 200;
|
|
4
8
|
|
|
5
9
|
export type TimedCacheEntry<T> = T & { storedAt: number };
|
|
6
10
|
|
|
@@ -11,3 +15,50 @@ export function cacheFresh<T>(
|
|
|
11
15
|
): cached is TimedCacheEntry<T> {
|
|
12
16
|
return !!cached && now - cached.storedAt <= ttlMs;
|
|
13
17
|
}
|
|
18
|
+
|
|
19
|
+
export function setTimedCacheEntry<T>(
|
|
20
|
+
cache: Map<string, TimedCacheEntry<T>>,
|
|
21
|
+
key: string,
|
|
22
|
+
value: T,
|
|
23
|
+
now = Date.now(),
|
|
24
|
+
maxEntries = MAX_TIMED_CACHE_ENTRIES,
|
|
25
|
+
): void {
|
|
26
|
+
cache.set(key, { ...value, storedAt: now });
|
|
27
|
+
while (cache.size > maxEntries) {
|
|
28
|
+
const oldest = cache.keys().next().value;
|
|
29
|
+
if (oldest === undefined) break;
|
|
30
|
+
cache.delete(oldest);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function worktreeFileSignature(path: string, cwd: string): string {
|
|
35
|
+
try {
|
|
36
|
+
const stats = lstatSync(join(cwd, path));
|
|
37
|
+
const inode = 'ino' in stats ? stats.ino : 0;
|
|
38
|
+
return `state:file|size:${stats.size}|mtime:${stats.mtimeMs}|ctime:${stats.ctimeMs}|ino:${inode}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return 'state:missing';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fileDiffCacheKey(options: {
|
|
45
|
+
path: string;
|
|
46
|
+
oldPath?: string | null;
|
|
47
|
+
isUntracked: boolean;
|
|
48
|
+
range: { from?: string; to?: string };
|
|
49
|
+
extras: string[];
|
|
50
|
+
args: string[];
|
|
51
|
+
cwd: string;
|
|
52
|
+
}): string {
|
|
53
|
+
const worktreeTarget = options.range.from === 'worktree' || !options.range.to || options.range.to === 'worktree';
|
|
54
|
+
if (options.isUntracked && !worktreeTarget) {
|
|
55
|
+
throw new Error('untracked file diffs require a worktree range');
|
|
56
|
+
}
|
|
57
|
+
const signature = worktreeTarget
|
|
58
|
+
? `\0${worktreeFileSignature(options.path, options.cwd)}`
|
|
59
|
+
: '';
|
|
60
|
+
if (options.isUntracked) {
|
|
61
|
+
return `u\0${options.path}${signature}\0${options.extras.join('\0')}`;
|
|
62
|
+
}
|
|
63
|
+
return `t\0${options.path}\0${options.oldPath || ''}${signature}\0${[...options.extras, ...options.args].join('\0')}`;
|
|
64
|
+
}
|
|
@@ -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';
|
|
7
|
-
import { cacheFresh, type TimedCacheEntry } from './cache';
|
|
6
|
+
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } from '../types';
|
|
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[] = [];
|
|
@@ -273,14 +285,14 @@ function handleDiffJson(url: URL) {
|
|
|
273
285
|
fileCache.clear();
|
|
274
286
|
}
|
|
275
287
|
const body = JSON.stringify(payload);
|
|
276
|
-
metaCache
|
|
288
|
+
setTimedCacheEntry(metaCache, key, { body, sig });
|
|
277
289
|
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
278
290
|
}
|
|
279
291
|
const cached = metaCache.get(key);
|
|
280
292
|
if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
281
293
|
const payload = computePayload(extras, range);
|
|
282
294
|
const body = JSON.stringify(payload);
|
|
283
|
-
metaCache
|
|
295
|
+
setTimedCacheEntry(metaCache, key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
|
|
284
296
|
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
285
297
|
}
|
|
286
298
|
|
|
@@ -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);
|
|
@@ -420,9 +524,12 @@ function handleFileDiff(url: URL) {
|
|
|
420
524
|
}
|
|
421
525
|
const { args } = buildRangeArgs(range);
|
|
422
526
|
const oldPath = url.searchParams.get('old_path');
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
527
|
+
let cacheKey: string;
|
|
528
|
+
try {
|
|
529
|
+
cacheKey = fileDiffCacheKey({ path, oldPath, isUntracked, range, extras, args, cwd });
|
|
530
|
+
} catch {
|
|
531
|
+
return text('invalid diff range', 400);
|
|
532
|
+
}
|
|
426
533
|
const cached = fileCache.get(cacheKey);
|
|
427
534
|
let diffText: string;
|
|
428
535
|
let errText = '';
|
|
@@ -436,7 +543,7 @@ function handleFileDiff(url: URL) {
|
|
|
436
543
|
diffText = res.stdout || '';
|
|
437
544
|
if (res.code !== 0) errText = res.stderr;
|
|
438
545
|
}
|
|
439
|
-
fileCache
|
|
546
|
+
setTimedCacheEntry(fileCache, cacheKey, { diffText });
|
|
440
547
|
}
|
|
441
548
|
const mode = url.searchParams.get('mode') || 'full';
|
|
442
549
|
const truncated = mode === 'preview'
|
|
@@ -736,6 +843,8 @@ const server = Bun.serve({
|
|
|
736
843
|
if (staticResponse) return staticResponse;
|
|
737
844
|
if (url.pathname === '/diff.json') return handleDiffJson(url);
|
|
738
845
|
if (url.pathname === '/_tree') return handleTree(url);
|
|
846
|
+
if (url.pathname === '/_files') return handleFiles(url);
|
|
847
|
+
if (url.pathname === '/_grep') return handleGrep(url);
|
|
739
848
|
if (url.pathname === '/file_diff') return handleFileDiff(url);
|
|
740
849
|
if (url.pathname === '/file_range') return handleFileRange(url);
|
|
741
850
|
if (url.pathname === '/_file') return handleRawFile(req, url);
|
|
@@ -747,6 +856,7 @@ const server = Bun.serve({
|
|
|
747
856
|
generation++;
|
|
748
857
|
fileCache.clear();
|
|
749
858
|
metaCache.clear();
|
|
859
|
+
fileListCache.clear();
|
|
750
860
|
sendSse('update');
|
|
751
861
|
return json({ ok: true, generation });
|
|
752
862
|
}
|