querysub 0.497.0 → 0.499.0

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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ process.argv.push("--local");
4
+
5
+ require("typenode");
6
+ require("../src/diagnostics/logs/errorTickets/autoFixerEntry.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.497.0",
3
+ "version": "0.499.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,6 +24,7 @@
24
24
  "mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
25
25
  "mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
26
26
  "mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/",
27
+ "autofixer": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
27
28
  "ssh-a-claude": "ssh root@a.querysubtest.com"
28
29
  },
29
30
  "bin": {
@@ -47,7 +48,8 @@
47
48
  "error-watch-public": "./bin/error-watch-public.js",
48
49
  "audit-imports": "./bin/audit-imports.js",
49
50
  "audit-disk-values": "./bin/audit-disk-values.js",
50
- "mcp-indexed-logs": "./bin/mcp-indexed-logs.js"
51
+ "mcp-indexed-logs": "./bin/mcp-indexed-logs.js",
52
+ "autofixer": "./bin/autofixer.js"
51
53
  },
52
54
  "dependencies": {
53
55
  "@types/fs-ext": "^2.0.3",
@@ -18,6 +18,18 @@ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
18
18
  import { timeInHour } from "socket-function/src/misc";
19
19
  import { ATag, URLOverride } from "../../../library-components/ATag";
20
20
  import { Button } from "../../../library-components/Button";
21
+ import { createTicketForError, ticketIdURL } from "../errorTickets/TicketPage";
22
+
23
+ function openTicket(datum: LogDatum, pattern: string | undefined) {
24
+ Querysub.onCommitFinished(async () => {
25
+ let id = await createTicketForError(datum, pattern);
26
+ Querysub.commit(() => {
27
+ showingManagementURL.value = true;
28
+ managementPageURL.value = "TicketPage";
29
+ ticketIdURL.value = id;
30
+ });
31
+ });
32
+ }
21
33
 
22
34
  let getMatcher = cacheLimited(100, (pattern: string) =>
23
35
  createMatchesPattern(Buffer.from(pattern), false)
@@ -154,6 +166,18 @@ export class LogDatumRenderer extends qreact.Component<{
154
166
  >
155
167
  Not a Bug
156
168
  </Button>
169
+ <Button
170
+ hue={210}
171
+ onClick={(e) => {
172
+ if (this.props.inlineMode) {
173
+ e.preventDefault();
174
+ e.stopPropagation();
175
+ }
176
+ openTicket(datum, timedOutMatches[0].pattern);
177
+ }}
178
+ >
179
+ Ticket
180
+ </Button>
157
181
  </div>
158
182
  )}
159
183
 
@@ -412,6 +436,19 @@ class SuppressionItem extends qreact.Component<{
412
436
  >
413
437
  Not a Bug
414
438
  </Button>
439
+ <Button
440
+ hue={210}
441
+ onClick={() => {
442
+ let datum = this.props.matchData?.examples[0] || {
443
+ time: Date.now(),
444
+ __LOG_TYPE: "error",
445
+ param0: suppression.pattern,
446
+ };
447
+ openTicket(datum, suppression.pattern);
448
+ }}
449
+ >
450
+ Ticket
451
+ </Button>
415
452
  <Button
416
453
  hue={30}
417
454
  onClick={() => {
@@ -22,6 +22,7 @@ import { createLink } from "../../../library-components/ATag";
22
22
  import { showingManagementURL, managementPageURL } from "../../managementPages";
23
23
  import { atomic } from "../../../2-proxy/PathValueProxyWatcher";
24
24
  import { magenta } from "socket-function/src/formatting/logColors";
25
+ import { exposeTicketService } from "../errorTickets/tickets";
25
26
 
26
27
 
27
28
  export type SuppressionEntry = {
@@ -306,6 +307,7 @@ const trySendDiscordNotificationsBase = batchFunction({ delay: 1000 * 3 }, async
306
307
 
307
308
  export async function exposeErrorWatchService() {
308
309
  SocketFunction.expose(ErrorNotificationServiceBase);
310
+ exposeTicketService();
309
311
 
310
312
  let errorLogs = await getErrorLogs();
311
313
  for await (let error of watchAllValues(errorLogs)) {
@@ -0,0 +1,518 @@
1
+ import { qreact } from "../../../4-dom/qreact";
2
+ import { t } from "../../../2-proxy/schema2";
3
+ import { css } from "typesafecss";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+ import { lazy } from "socket-function/src/caching";
6
+ import { nextId, sort } from "socket-function/src/misc";
7
+ import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
8
+ import { Querysub } from "../../../4-querysub/Querysub";
9
+ import { URLParam } from "../../../library-components/URLParam";
10
+ import { ATag } from "../../../library-components/ATag";
11
+ import { Button } from "../../../library-components/Button";
12
+ import { LogDatum } from "../diskLogger";
13
+ import { TicketsController } from "./tickets";
14
+ import { Ticket, TicketComment, TicketPatchFile, TicketState, TICKET_STATES } from "./ticketTypes";
15
+
16
+ export const ticketIdURL = new URLParam("ticketid", "");
17
+
18
+ const TITLE_MAX_LENGTH = 200;
19
+
20
+ const STATE_HUES: Record<TicketState, number> = {
21
+ "investigation": 45,
22
+ "code-change": 210,
23
+ "fixed": 120,
24
+ "not-a-bug": 280,
25
+ "timed-out": 0,
26
+ };
27
+
28
+ function getController() {
29
+ return TicketsController(SocketFunction.browserNodeId());
30
+ }
31
+
32
+ function resetTicketData() {
33
+ let controller = getController();
34
+ controller.getTickets.resetAll();
35
+ controller.getTicket.resetAll();
36
+ }
37
+
38
+ export async function createTicketForError(datum: LogDatum, suppressionPattern?: string): Promise<string> {
39
+ let now = Date.now();
40
+ let title = (datum.param0 && String(datum.param0) || "(no message)").slice(0, TITLE_MAX_LENGTH);
41
+ let ticket: Ticket = {
42
+ id: nextId(),
43
+ title,
44
+ state: "investigation",
45
+ createdTime: now,
46
+ lastUpdatedTime: now,
47
+ errorDatum: datum,
48
+ suppressionPattern,
49
+ comments: [],
50
+ };
51
+ await getController().createTicket.promise(ticket);
52
+ resetTicketData();
53
+ return ticket.id;
54
+ }
55
+
56
+ const HLJS_VERSION = "11.9.0";
57
+ let ensureHljsLoaded = lazy(async (): Promise<void> => {
58
+ if ((window as any).hljs) return;
59
+ let link = document.createElement("link");
60
+ link.rel = "stylesheet";
61
+ link.href = `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${HLJS_VERSION}/styles/github-dark.min.css`;
62
+ document.head.appendChild(link);
63
+ await new Promise<void>((resolve, reject) => {
64
+ let script = document.createElement("script");
65
+ script.src = `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${HLJS_VERSION}/highlight.min.js`;
66
+ script.onload = () => resolve();
67
+ script.onerror = () => reject(new Error("Failed to load highlight.js"));
68
+ document.head.appendChild(script);
69
+ });
70
+ });
71
+
72
+ class HighlightedCode extends qreact.Component<{
73
+ code: string;
74
+ file?: string;
75
+ }> {
76
+ state = t.state({
77
+ html: t.atomic<string>(""),
78
+ });
79
+
80
+ componentDidMount() {
81
+ void (async () => {
82
+ try {
83
+ await ensureHljsLoaded();
84
+ let hljs = (window as any).hljs;
85
+ let extension = this.props.file?.split(".").pop() || "";
86
+ let html: string;
87
+ if (extension && hljs.getLanguage(extension)) {
88
+ html = hljs.highlight(this.props.code, { language: extension }).value;
89
+ } else {
90
+ html = hljs.highlightAuto(this.props.code).value;
91
+ }
92
+ Querysub.commit(() => {
93
+ this.state.html = html;
94
+ });
95
+ } catch (e) {
96
+ console.error(`Failed to highlight code:`, (e as Error).stack ?? e);
97
+ }
98
+ })();
99
+ }
100
+
101
+ render() {
102
+ let html = this.state.html;
103
+ return <pre className={
104
+ css.pad2(8).hsl(220, 15, 15).colorhsl(0, 0, 90).overflowAuto.maxHeight(400)
105
+ .fontSize(12).whiteSpace("pre-wrap").margin(0).fillWidth
106
+ + " hljs"
107
+ }>
108
+ <code
109
+ key={html && "highlighted" || "plain"}
110
+ ref2={(element: HTMLElement | undefined) => {
111
+ if (!element) return;
112
+ if (html) {
113
+ element.innerHTML = html;
114
+ }
115
+ }}
116
+ >{!html && this.props.code || undefined}</code>
117
+ </pre>;
118
+ }
119
+ }
120
+
121
+ function StateBadge(props: { state: TicketState }) {
122
+ let hue = STATE_HUES[props.state] ?? 0;
123
+ return <span className={
124
+ css.pad2(6, 2).hsl(hue, 60, 85).colorhsl(hue, 80, 25).bord2(hue, 60, 60)
125
+ .fontSize(12).boldStyle.whiteSpace("nowrap")
126
+ }>
127
+ {props.state}
128
+ </span>;
129
+ }
130
+ qreact.inline(StateBadge);
131
+
132
+ export class TicketPage extends qreact.Component {
133
+ render() {
134
+ if (ticketIdURL.value) {
135
+ return <TicketDetail ticketId={ticketIdURL.value} />;
136
+ }
137
+ return <TicketList />;
138
+ }
139
+ }
140
+
141
+ class TicketList extends qreact.Component {
142
+ render() {
143
+ let tickets = getController().getTickets();
144
+ if (!tickets) {
145
+ return <div className={css.vbox(16).pad2(16)}>
146
+ <h2>Tickets</h2>
147
+ <div>Loading tickets...</div>
148
+ </div>;
149
+ }
150
+
151
+ let sorted = [...tickets];
152
+ sort(sorted, x => -x.lastUpdatedTime);
153
+
154
+ return <div className={css.vbox(16).pad2(16).fillWidth}>
155
+ <div className={css.hbox(16)}>
156
+ <h2>Tickets ({sorted.length})</h2>
157
+ <Button
158
+ hue={200}
159
+ onClick={() => {
160
+ resetTicketData();
161
+ }}
162
+ >
163
+ Refresh
164
+ </Button>
165
+ </div>
166
+ {sorted.length === 0 && <div>No tickets. Create one from the Error Notifications page with the "Ticket" button.</div>}
167
+ <div className={css.vbox(8)}>
168
+ {sorted.map(ticket => (
169
+ <ATag
170
+ key={ticket.id}
171
+ values={[ticketIdURL.getOverride(ticket.id)]}
172
+ className={css.hbox(16).pad2(12).bord2(200, 30, 70).hsl(0, 0, 96).alignItems("center").textDecoration("none")}
173
+ >
174
+ <StateBadge state={ticket.state} />
175
+ <span className={css.ellipsis.flexFillWidth.colorhsl(0, 0, 10).boldStyle}>{ticket.title}</span>
176
+ <span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
177
+ {ticket.comments.length} comments
178
+ </span>
179
+ <span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
180
+ Created {formatDateTime(ticket.createdTime)}
181
+ </span>
182
+ <span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
183
+ Updated {formatTime(Date.now() - ticket.lastUpdatedTime)} ago
184
+ </span>
185
+ </ATag>
186
+ ))}
187
+ </div>
188
+ </div>;
189
+ }
190
+ }
191
+
192
+ class TicketDetail extends qreact.Component<{ ticketId: string }> {
193
+ state = t.state({
194
+ newCommentText: t.atomic<string>(""),
195
+ showingErrorInfo: t.atomic<boolean>(false),
196
+ });
197
+
198
+ render() {
199
+ let ticket = getController().getTicket(this.props.ticketId);
200
+ if (!ticket) {
201
+ return <div className={css.vbox(16).pad2(16)}>
202
+ <ATag values={[ticketIdURL.getOverride("")]}>← All Tickets</ATag>
203
+ <div>Loading ticket...</div>
204
+ </div>;
205
+ }
206
+
207
+ let sortedComments = [...ticket.comments];
208
+ sort(sortedComments, x => x.time);
209
+
210
+ return <div className={css.vbox(16).pad2(16).fillWidth.maxWidth("100%")}>
211
+ <div className={css.hbox(16).alignItems("center")}>
212
+ <ATag values={[ticketIdURL.getOverride("")]}>← All Tickets</ATag>
213
+ <StateBadge state={ticket.state} />
214
+ <h2 className={css.margin(0).ellipsis}>{ticket.title}</h2>
215
+ </div>
216
+
217
+ <div className={css.hbox(8).alignItems("center").wrap}>
218
+ <span>Set state:</span>
219
+ {TICKET_STATES.map(state => (
220
+ <Button
221
+ key={state}
222
+ hue={STATE_HUES[state]}
223
+ disabled={state === ticket!.state}
224
+ onClick={() => {
225
+ Querysub.onCommitFinished(async () => {
226
+ await getController().setTicketState.promise(this.props.ticketId, state);
227
+ resetTicketData();
228
+ });
229
+ }}
230
+ >
231
+ {state}
232
+ </Button>
233
+ ))}
234
+ <Button
235
+ hue={0}
236
+ onClick={() => {
237
+ if (!confirm(`Delete this ticket "${ticket!.title}"?`)) return;
238
+ Querysub.onCommitFinished(async () => {
239
+ await getController().deleteTicket.promise(this.props.ticketId);
240
+ resetTicketData();
241
+ Querysub.commit(() => {
242
+ ticketIdURL.value = "";
243
+ });
244
+ });
245
+ }}
246
+ >
247
+ Delete Ticket
248
+ </Button>
249
+ </div>
250
+
251
+ <div className={css.vbox(8).pad2(12).bord2(0, 50, 70).hsl(0, 30, 96)}>
252
+ <div
253
+ className={css.hbox(8).button}
254
+ onClick={() => {
255
+ this.state.showingErrorInfo = !this.state.showingErrorInfo;
256
+ }}
257
+ >
258
+ <span>{this.state.showingErrorInfo && "▼" || "▶"}</span>
259
+ <strong>Error Information</strong>
260
+ <span className={css.colorhsl(0, 0, 40)}>{formatDateTime(ticket.errorDatum.time)}</span>
261
+ </div>
262
+ {ticket.suppressionPattern && (
263
+ <div className={css.hbox(8)}>
264
+ <strong>Suppression Pattern:</strong>
265
+ <span>{ticket.suppressionPattern}</span>
266
+ </div>
267
+ )}
268
+ {this.state.showingErrorInfo && (
269
+ <div className={css.vbox(4).pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
270
+ {Object.entries(ticket.errorDatum).map(([key, value]) => (
271
+ <div key={key} className={css.hbox(8)}>
272
+ <strong className={css.minWidth(120)}>{key}:</strong>
273
+ <div className={css.flexGrow(1).margin(0).whiteSpace("pre-wrap").overflowWrap("break-word")}>{JSON.stringify(value, undefined, 2)}</div>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ )}
278
+ </div>
279
+
280
+ <div className={css.vbox(12)}>
281
+ <h3>Comments ({sortedComments.length})</h3>
282
+ {sortedComments.map(comment => (
283
+ <TicketCommentItem key={comment.id} ticketId={this.props.ticketId} comment={comment} />
284
+ ))}
285
+ </div>
286
+
287
+ <div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 96)}>
288
+ <strong>Add Comment</strong>
289
+ <textarea
290
+ className={css.minHeight(80).fillWidth.pad2(8)}
291
+ value={this.state.newCommentText}
292
+ onInput={e => {
293
+ this.state.newCommentText = (e.target as HTMLTextAreaElement).value;
294
+ }}
295
+ />
296
+ <div className={css.hbox(8)}>
297
+ <Button
298
+ hue={120}
299
+ onClick={() => {
300
+ let text = this.state.newCommentText.trim();
301
+ if (!text) return;
302
+ Querysub.onCommitFinished(async () => {
303
+ let comment: TicketComment = {
304
+ id: nextId(),
305
+ time: Date.now(),
306
+ author: "user",
307
+ kind: "text",
308
+ text,
309
+ };
310
+ await getController().addComment.promise(this.props.ticketId, comment);
311
+ resetTicketData();
312
+ Querysub.commit(() => {
313
+ this.state.newCommentText = "";
314
+ });
315
+ });
316
+ }}
317
+ >
318
+ Add Comment
319
+ </Button>
320
+ </div>
321
+ </div>
322
+ </div>;
323
+ }
324
+ }
325
+
326
+ class TicketCommentItem extends qreact.Component<{
327
+ ticketId: string;
328
+ comment: TicketComment;
329
+ }> {
330
+ state = t.state({
331
+ editing: t.atomic<boolean>(false),
332
+ editText: t.atomic<string>(""),
333
+ expandedTool: t.atomic<boolean>(false),
334
+ });
335
+
336
+ render() {
337
+ let comment = this.props.comment;
338
+
339
+ let hue = comment.author === "user" && 120 || 210;
340
+ if (comment.kind === "tool-call") {
341
+ hue = 0;
342
+ }
343
+
344
+ return <div className={css.vbox(8).pad2(12).bord2(hue, 30, 70).hsl(hue, 20, 97)}>
345
+ <div className={css.hbox(12).alignItems("center")}>
346
+ <strong>{comment.author}</strong>
347
+ <span className={css.colorhsl(0, 0, 40).fontSize(12)}>{formatDateTime(comment.time)}</span>
348
+ {comment.kind === "patch" && (
349
+ <span className={css.pad2(6, 2).hsl(210, 60, 85).colorhsl(210, 80, 25).fontSize(12).boldStyle}>PATCH</span>
350
+ )}
351
+ {comment.kind === "tool-call" && (
352
+ <span className={css.pad2(6, 2).hsl(0, 0, 85).colorhsl(0, 0, 25).fontSize(12).boldStyle}>TOOL</span>
353
+ )}
354
+ <div className={css.flexFillWidth} />
355
+ {comment.kind === "text" && !this.state.editing && (
356
+ <Button
357
+ hue={200}
358
+ flavor="small"
359
+ onClick={() => {
360
+ this.state.editing = true;
361
+ this.state.editText = comment.text;
362
+ }}
363
+ >
364
+ Edit
365
+ </Button>
366
+ )}
367
+ <Button
368
+ hue={0}
369
+ flavor="small"
370
+ onClick={() => {
371
+ if (!confirm(`Delete this comment?`)) return;
372
+ Querysub.onCommitFinished(async () => {
373
+ await getController().deleteComment.promise(this.props.ticketId, comment.id);
374
+ resetTicketData();
375
+ });
376
+ }}
377
+ >
378
+ Delete
379
+ </Button>
380
+ </div>
381
+
382
+ {comment.kind === "tool-call" && (
383
+ <div className={css.vbox(8)}>
384
+ <div
385
+ className={css.hbox(8).button}
386
+ onClick={() => {
387
+ this.state.expandedTool = !this.state.expandedTool;
388
+ }}
389
+ >
390
+ <span>{this.state.expandedTool && "▼" || "▶"}</span>
391
+ <strong>{comment.toolName}</strong>
392
+ {comment.toolDurationMs !== undefined && (
393
+ <span className={css.colorhsl(0, 0, 40).fontSize(12)}>{formatTime(comment.toolDurationMs)}</span>
394
+ )}
395
+ <span className={css.ellipsis.colorhsl(0, 0, 40).fontSize(12)}>{comment.toolInput}</span>
396
+ </div>
397
+ {this.state.expandedTool && (
398
+ <div className={css.vbox(8)}>
399
+ <strong>Input</strong>
400
+ <pre className={css.pad2(8).hsl(0, 0, 92).whiteSpace("pre-wrap").overflowWrap("break-word").margin(0).fontSize(12).maxHeight(200).overflowAuto}>{comment.toolInput}</pre>
401
+ <strong>Output</strong>
402
+ <pre className={css.pad2(8).hsl(0, 0, 92).whiteSpace("pre-wrap").overflowWrap("break-word").margin(0).fontSize(12).maxHeight(400).overflowAuto}>{comment.toolOutput}</pre>
403
+ </div>
404
+ )}
405
+ </div>
406
+ )}
407
+
408
+ {comment.kind !== "tool-call" && !this.state.editing && (
409
+ <div className={css.whiteSpace("pre-wrap")}>{comment.text}</div>
410
+ )}
411
+
412
+ {this.state.editing && (
413
+ <div className={css.vbox(8)}>
414
+ <textarea
415
+ className={css.minHeight(80).fillWidth.pad2(8)}
416
+ value={this.state.editText}
417
+ onInput={e => {
418
+ this.state.editText = (e.target as HTMLTextAreaElement).value;
419
+ }}
420
+ />
421
+ <div className={css.hbox(8)}>
422
+ <Button
423
+ hue={120}
424
+ onClick={() => {
425
+ let text = this.state.editText.trim();
426
+ Querysub.onCommitFinished(async () => {
427
+ await getController().updateCommentText.promise(this.props.ticketId, comment.id, text);
428
+ resetTicketData();
429
+ Querysub.commit(() => {
430
+ this.state.editing = false;
431
+ });
432
+ });
433
+ }}
434
+ >
435
+ Save
436
+ </Button>
437
+ <Button
438
+ onClick={() => {
439
+ this.state.editing = false;
440
+ }}
441
+ >
442
+ Cancel
443
+ </Button>
444
+ </div>
445
+ </div>
446
+ )}
447
+
448
+ {comment.kind === "patch" && comment.patchFiles && (
449
+ <div className={css.vbox(12)}>
450
+ {comment.patchFiles.map((patchFile, idx) => (
451
+ <PatchFileView key={idx} patchFile={patchFile} />
452
+ ))}
453
+ <div className={css.hbox(12).alignItems("center")}>
454
+ {comment.patchStatus === "pending" && (
455
+ <>
456
+ <Button
457
+ hue={120}
458
+ onClick={() => {
459
+ Querysub.onCommitFinished(async () => {
460
+ try {
461
+ await getController().applyPatch.promise(this.props.ticketId, comment.id);
462
+ } catch (e) {
463
+ alert(`Failed to apply patch: ${(e as Error).message}`);
464
+ }
465
+ resetTicketData();
466
+ });
467
+ }}
468
+ >
469
+ Apply Patch
470
+ </Button>
471
+ <Button
472
+ hue={0}
473
+ onClick={() => {
474
+ Querysub.onCommitFinished(async () => {
475
+ await getController().setPatchStatus.promise(this.props.ticketId, comment.id, "rejected");
476
+ resetTicketData();
477
+ });
478
+ }}
479
+ >
480
+ Reject Patch
481
+ </Button>
482
+ </>
483
+ )}
484
+ {comment.patchStatus === "applied" && (
485
+ <span className={css.colorhsl(120, 80, 25).boldStyle}>
486
+ Applied {comment.patchStatusTime && formatDateTime(comment.patchStatusTime) || ""}
487
+ </span>
488
+ )}
489
+ {comment.patchStatus === "rejected" && (
490
+ <span className={css.colorhsl(0, 80, 40).boldStyle}>
491
+ Rejected {comment.patchStatusTime && formatDateTime(comment.patchStatusTime) || ""}
492
+ </span>
493
+ )}
494
+ </div>
495
+ </div>
496
+ )}
497
+ </div>;
498
+ }
499
+ }
500
+
501
+ class PatchFileView extends qreact.Component<{ patchFile: TicketPatchFile }> {
502
+ render() {
503
+ let patchFile = this.props.patchFile;
504
+ return <div className={css.vbox(4)}>
505
+ <strong className={css.fontSize(13)}>{patchFile.file}</strong>
506
+ {patchFile.oldText && (
507
+ <div className={css.vbox(2)}>
508
+ <span className={css.colorhsl(0, 70, 40).fontSize(12).boldStyle}>- Old</span>
509
+ <HighlightedCode code={patchFile.oldText} file={patchFile.file} />
510
+ </div>
511
+ )}
512
+ <div className={css.vbox(2)}>
513
+ <span className={css.colorhsl(120, 70, 30).fontSize(12).boldStyle}>+ New{!patchFile.oldText && " (file created)" || ""}</span>
514
+ <HighlightedCode code={patchFile.newText} file={patchFile.file} />
515
+ </div>
516
+ </div>;
517
+ }
518
+ }