pinokiod 7.3.0 → 7.3.3

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.
Files changed (125) hide show
  1. package/kernel/api/github/index.js +444 -0
  2. package/kernel/api/index.js +199 -11
  3. package/kernel/api/process/index.js +124 -44
  4. package/kernel/api/shell_run_template.js +273 -0
  5. package/kernel/api/uri/index.js +51 -0
  6. package/kernel/bin/{conda-python.js → conda-pins.js} +23 -0
  7. package/kernel/bin/conda.js +15 -5
  8. package/kernel/bin/git.js +9 -10
  9. package/kernel/bin/huggingface.js +1 -1
  10. package/kernel/bin/index.js +5 -2
  11. package/kernel/bin/zip.js +9 -1
  12. package/kernel/connect/providers/github/README.md +5 -4
  13. package/kernel/environment.js +195 -92
  14. package/kernel/git.js +98 -19
  15. package/kernel/gitconfig_template +7 -0
  16. package/kernel/gpu/amd.js +72 -0
  17. package/kernel/gpu/apple.js +8 -0
  18. package/kernel/gpu/common.js +12 -0
  19. package/kernel/gpu/intel.js +47 -0
  20. package/kernel/gpu/nvidia.js +8 -0
  21. package/kernel/index.js +11 -1
  22. package/kernel/managed_skills.js +871 -0
  23. package/kernel/plugin.js +6 -58
  24. package/kernel/plugin_sources.js +316 -0
  25. package/kernel/resource_usage/gpu.js +349 -0
  26. package/kernel/resource_usage/index.js +322 -0
  27. package/kernel/resource_usage/macos_footprint.js +197 -0
  28. package/kernel/resource_usage/preferences.js +92 -0
  29. package/kernel/resource_usage/process_tree.js +303 -0
  30. package/kernel/scripts/git/create +4 -4
  31. package/kernel/scripts/git/fork +7 -8
  32. package/kernel/shell.js +23 -2
  33. package/kernel/shells.js +41 -0
  34. package/kernel/sysinfo.js +62 -9
  35. package/kernel/util.js +60 -0
  36. package/package.json +1 -1
  37. package/server/index.js +984 -156
  38. package/server/lib/app_log_report.js +543 -0
  39. package/server/lib/content_validation.js +55 -33
  40. package/server/lib/launcher_instruction_bootstrap.js +4 -96
  41. package/server/lib/terminal_session_helpers.js +0 -3
  42. package/server/public/common.js +77 -31
  43. package/server/public/create-launcher.js +4 -32
  44. package/server/public/logs.js +1428 -0
  45. package/server/public/nav.js +7 -0
  46. package/server/public/plugin-detail.js +93 -10
  47. package/server/public/privacy_filter_worker.js +391 -0
  48. package/server/public/style.css +1104 -154
  49. package/server/public/task-launcher.js +8 -29
  50. package/server/public/universal-launcher.css +8 -6
  51. package/server/public/universal-launcher.js +3 -27
  52. package/server/routes/apps.js +195 -1
  53. package/server/views/app.ejs +3041 -717
  54. package/server/views/autolaunch.ejs +917 -0
  55. package/server/views/bootstrap.ejs +7 -1
  56. package/server/views/d.ejs +408 -65
  57. package/server/views/editor.ejs +85 -19
  58. package/server/views/index.ejs +661 -111
  59. package/server/views/init/index.ejs +1 -1
  60. package/server/views/install.ejs +1 -1
  61. package/server/views/logs.ejs +164 -86
  62. package/server/views/net.ejs +7 -1
  63. package/server/views/partials/d_terminal_column.ejs +2 -2
  64. package/server/views/partials/d_terminal_options.ejs +0 -8
  65. package/server/views/partials/fs_status.ejs +47 -0
  66. package/server/views/partials/home_action_modal.ejs +86 -0
  67. package/server/views/partials/home_run_menu.ejs +87 -0
  68. package/server/views/partials/main_sidebar.ejs +2 -0
  69. package/server/views/partials/menu.ejs +1 -1
  70. package/server/views/plugin_detail.ejs +19 -4
  71. package/server/views/plugins.ejs +201 -3
  72. package/server/views/pre.ejs +1 -1
  73. package/server/views/pro.ejs +1 -1
  74. package/server/views/shell.ejs +40 -18
  75. package/server/views/skills.ejs +506 -0
  76. package/server/views/terminal.ejs +45 -19
  77. package/spec/INSTRUCTION_SYNC.md +20 -10
  78. package/system/plugin/antigravity-cli/antigravity.png +0 -0
  79. package/system/plugin/antigravity-cli/common.js +155 -0
  80. package/system/plugin/antigravity-cli/install.js +272 -0
  81. package/system/plugin/antigravity-cli/pinokio.js +13 -0
  82. package/system/plugin/antigravity-cli-auto/antigravity.png +0 -0
  83. package/system/plugin/antigravity-cli-auto/pinokio.js +13 -0
  84. package/system/plugin/claude/claude.png +0 -0
  85. package/system/plugin/claude/pinokio.js +47 -0
  86. package/system/plugin/claude-auto/claude.png +0 -0
  87. package/system/plugin/claude-auto/pinokio.js +58 -0
  88. package/system/plugin/claude-desktop/icon.jpeg +0 -0
  89. package/system/plugin/claude-desktop/pinokio.js +23 -0
  90. package/system/plugin/codex/openai.webp +0 -0
  91. package/system/plugin/codex/pinokio.js +42 -0
  92. package/system/plugin/codex-auto/openai.webp +0 -0
  93. package/system/plugin/codex-auto/pinokio.js +49 -0
  94. package/system/plugin/codex-desktop/icon.png +0 -0
  95. package/system/plugin/codex-desktop/pinokio.js +23 -0
  96. package/system/plugin/crush/crush.png +0 -0
  97. package/system/plugin/crush/pinokio.js +15 -0
  98. package/system/plugin/cursor/cursor.jpeg +0 -0
  99. package/system/plugin/cursor/pinokio.js +23 -0
  100. package/system/plugin/qwen/pinokio.js +34 -0
  101. package/system/plugin/qwen/qwen.png +0 -0
  102. package/system/plugin/vscode/pinokio.js +20 -0
  103. package/system/plugin/vscode/vscode.png +0 -0
  104. package/system/plugin/windsurf/pinokio.js +23 -0
  105. package/system/plugin/windsurf/windsurf.png +0 -0
  106. package/test/antigravity-cli-plugin.test.js +185 -0
  107. package/test/app-api.test.js +239 -0
  108. package/test/app-log-report.test.js +67 -0
  109. package/test/environment-cache-preflight.test.js +98 -0
  110. package/test/git-bin.test.js +59 -0
  111. package/test/git-defaults.test.js +97 -0
  112. package/test/github-api.test.js +158 -0
  113. package/test/github-connection.test.js +117 -0
  114. package/test/huggingface-bin.test.js +25 -0
  115. package/test/managed-skills.test.js +351 -0
  116. package/test/plugin-action-functions.test.js +337 -0
  117. package/test/plugin-dev-iframe.test.js +17 -0
  118. package/test/plugin-sources.test.js +203 -0
  119. package/test/privacy-filter-worker-heuristics.test.js +69 -0
  120. package/test/process-wait.test.js +169 -0
  121. package/test/script-api.test.js +97 -0
  122. package/test/shell-api.test.js +134 -0
  123. package/test/shell-run-template.test.js +209 -0
  124. package/test/storage-api.test.js +137 -0
  125. package/test/uri-api.test.js +100 -0
@@ -4,6 +4,23 @@
4
4
  const LOGS_SIDEBAR_WIDTH_KEY = 'pinokio.logs.sidebar-width'
5
5
  const LOGS_SIDEBAR_MIN_WIDTH = 220
6
6
  const LOGS_SIDEBAR_MAX_WIDTH = 560
7
+ const DRAFT_BODY_TARGET_BYTES = 750 * 1024
8
+ const DRAFT_IMPORT_FIELD_LIMIT_BYTES = 1024 * 1024
9
+ const DRAFT_TITLE_MAX_LENGTH = 120
10
+ const DRAFT_TITLE_DISPLAY_LENGTH = 96
11
+ const DRAFT_TITLE_RECENT_WINDOW_MS = 48 * 60 * 60 * 1000
12
+ const DRAFT_SECTION_MODES = [
13
+ { value: 'full', label: 'Full section' },
14
+ { value: 'last-2000', label: 'Last 2000 lines', lines: 2000 },
15
+ { value: 'last-1000', label: 'Last 1000 lines', lines: 1000 },
16
+ { value: 'last-500', label: 'Last 500 lines', lines: 500 },
17
+ { value: 'exclude', label: 'Exclude' }
18
+ ]
19
+ const DRAFT_TITLE_FAILURE_PATTERN = /\b(?:error|exception|failed|failure|fatal|cannot|can't|can not|not found|denied|timeout|timed out|refused|unavailable|invalid|missing|abort|aborted|panic|overflow|crash|crashed)\b/i
20
+ const DRAFT_TITLE_STRONG_PATTERN = /\b(?:[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception)|ERROR|FATAL|ERR!|exit code\s+\d+)\b/i
21
+ const DRAFT_TITLE_STACK_PATTERN = /^\s*(?:File\s+"[^"]+",\s+line\s+\d+|at\s+\S+|from\s+\S+\s+import\s+|return\s+|await\s+|sys\.exit\b)/i
22
+ const DRAFT_TITLE_NOISE_PATTERN = /^\s*(?:<<PINOKIO_SHELL>>|={6,}|-{6,}|\[api\s+local\.set\]|The default interactive shell is now|To update your account|For more details, please visit)/i
23
+ const textEncoder = typeof TextEncoder === 'function' ? new TextEncoder() : null
7
24
 
8
25
  const safeJsonParse = (value) => {
9
26
  try {
@@ -13,6 +30,130 @@
13
30
  }
14
31
  }
15
32
 
33
+ const humanBytes = (value) => {
34
+ const units = ['B', 'KB', 'MB', 'GB']
35
+ let size = Number(value) || 0
36
+ let index = 0
37
+ while (size >= 1024 && index < units.length - 1) {
38
+ size /= 1024
39
+ index += 1
40
+ }
41
+ return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
42
+ }
43
+
44
+ const textByteLength = (value) => {
45
+ const text = String(value || '')
46
+ if (textEncoder) {
47
+ return textEncoder.encode(text).length
48
+ }
49
+ return unescape(encodeURIComponent(text)).length
50
+ }
51
+
52
+ const withQueryParam = (href, key, value) => {
53
+ try {
54
+ const url = new URL(href, window.location.origin)
55
+ url.searchParams.set(key, value)
56
+ return `${url.pathname}${url.search}${url.hash}`
57
+ } catch (_) {
58
+ return href
59
+ }
60
+ }
61
+
62
+ class PrivacyFilterClient {
63
+ constructor() {
64
+ this.worker = null
65
+ this.nextId = 1
66
+ this.pending = new Map()
67
+ this.runtimePromise = null
68
+ }
69
+ getWorker() {
70
+ if (this.worker) {
71
+ return this.worker
72
+ }
73
+ try {
74
+ this.worker = new Worker('/privacy_filter_worker.js', { type: 'module' })
75
+ } catch (_) {
76
+ this.worker = new Worker('/privacy_filter_worker.js')
77
+ }
78
+ this.worker.addEventListener('message', (event) => this.handleMessage(event.data || {}))
79
+ this.worker.addEventListener('error', (event) => {
80
+ const error = new Error(event.message || 'Privacy filter worker failed.')
81
+ for (const pending of this.pending.values()) {
82
+ pending.reject(error)
83
+ }
84
+ this.pending.clear()
85
+ this.worker = null
86
+ })
87
+ return this.worker
88
+ }
89
+ handleMessage(message) {
90
+ if (message.type === 'download') {
91
+ for (const pending of this.pending.values()) {
92
+ pending.onProgress(message)
93
+ }
94
+ return
95
+ }
96
+ const pending = this.pending.get(message.id)
97
+ if (!pending) {
98
+ return
99
+ }
100
+ if (message.type === 'chunk') {
101
+ pending.onProgress(message)
102
+ } else if (message.type === 'fallback') {
103
+ pending.onProgress(message)
104
+ } else if (message.type === 'result') {
105
+ this.pending.delete(message.id)
106
+ pending.resolve(message)
107
+ } else if (message.type === 'error') {
108
+ this.pending.delete(message.id)
109
+ pending.reject(new Error(message.message || 'Privacy filter failed.'))
110
+ }
111
+ }
112
+ detectRuntime() {
113
+ if (this.runtimePromise) {
114
+ return this.runtimePromise
115
+ }
116
+ this.runtimePromise = (async () => {
117
+ try {
118
+ if (navigator.gpu && typeof navigator.gpu.requestAdapter === 'function') {
119
+ const adapter = await navigator.gpu.requestAdapter()
120
+ if (adapter) {
121
+ return {
122
+ device: 'webgpu',
123
+ dtype: adapter.features && adapter.features.has('shader-f16') ? 'q4f16' : 'q4'
124
+ }
125
+ }
126
+ }
127
+ } catch (_) {}
128
+ return { device: 'wasm', dtype: 'q8' }
129
+ })()
130
+ return this.runtimePromise
131
+ }
132
+ async filter(text, onProgress) {
133
+ const runtime = await this.detectRuntime()
134
+ const id = this.nextId
135
+ this.nextId += 1
136
+ const worker = this.getWorker()
137
+ if (typeof onProgress === 'function') {
138
+ onProgress({ type: 'runtime', ...runtime })
139
+ }
140
+ return new Promise((resolve, reject) => {
141
+ this.pending.set(id, {
142
+ resolve,
143
+ reject,
144
+ onProgress: typeof onProgress === 'function' ? onProgress : () => {}
145
+ })
146
+ worker.postMessage({
147
+ type: 'filter',
148
+ id,
149
+ text,
150
+ device: runtime.device,
151
+ dtype: runtime.dtype
152
+ })
153
+ })
154
+ }
155
+ }
156
+
16
157
  class LogsZipControls {
17
158
  constructor(options) {
18
159
  this.button = options.button
@@ -68,6 +209,1198 @@
68
209
  }
69
210
  }
70
211
 
212
+ class LogsLatestReport {
213
+ constructor(options) {
214
+ this.reportUrl = options.reportUrl || ''
215
+ this.rawReportUrl = this.reportUrl ? withQueryParam(this.reportUrl, 'redaction', 'none') : ''
216
+ this.draftUrl = options.draftUrl || ''
217
+ this.registryBase = options.registryBase || 'https://pinokio.co'
218
+ this.statusEl = options.statusEl
219
+ this.outputEl = options.outputEl
220
+ this.copyButton = options.copyButton
221
+ this.createDraftButton = options.createDraftButton
222
+ this.draftTitleInput = options.draftTitleInput
223
+ this.draftTitleNoteEl = options.draftTitleNoteEl
224
+ this.runFilterButton = options.runFilterButton
225
+ this.refreshButton = options.refreshButton
226
+ this.reportFilesEl = options.reportFilesEl
227
+ this.reportGeneratedEl = options.reportGeneratedEl
228
+ this.reportSectionsEl = options.reportSectionsEl
229
+ this.draftSizeBadgeEl = options.draftSizeBadgeEl
230
+ this.draftMeterFillEl = options.draftMeterFillEl
231
+ this.draftStatusEl = options.draftStatusEl
232
+ this.reviewListEl = options.reviewListEl
233
+ this.reviewFiltersEl = options.reviewFiltersEl
234
+ this.reviewCountEl = options.reviewCountEl
235
+ this.privacyFilter = options.privacyFilter || new PrivacyFilterClient()
236
+ this.report = null
237
+ this.rawMarkdown = ''
238
+ this.reviewMarkdown = ''
239
+ this.currentMarkdown = ''
240
+ this.sectionModes = new Map()
241
+ this.draftBodyBytes = 0
242
+ this.draftPayloadBytes = 0
243
+ this.draftOversized = false
244
+ this.importingDraft = false
245
+ this.draftTitleEdited = false
246
+ this.draftTitleSuggestion = null
247
+ this.redactionItems = []
248
+ this.renderedRedactionItems = []
249
+ this.filterChunks = 0
250
+ this.redactionHasRun = false
251
+ this.filtering = false
252
+ this.activeRedactionFilter = 'all'
253
+ this.selectedRedactionId = null
254
+ this.filterToken = 0
255
+ this.loading = false
256
+
257
+ if (this.copyButton) {
258
+ this.copyButton.addEventListener('click', () => this.copy())
259
+ }
260
+ if (this.createDraftButton) {
261
+ this.createDraftButton.addEventListener('click', () => this.createDraft())
262
+ }
263
+ if (this.draftTitleInput) {
264
+ this.draftTitleInput.addEventListener('input', () => {
265
+ this.draftTitleEdited = true
266
+ this.updateDraftTitleNote()
267
+ this.updateDraftSizeReview()
268
+ })
269
+ }
270
+ if (this.runFilterButton) {
271
+ this.runFilterButton.addEventListener('click', () => this.filterReport())
272
+ }
273
+ if (this.refreshButton) {
274
+ this.refreshButton.addEventListener('click', () => this.load(true))
275
+ }
276
+ }
277
+ setStatus(message, isError) {
278
+ if (!this.statusEl) return
279
+ this.statusEl.textContent = message || ''
280
+ this.statusEl.classList.toggle('is-error', Boolean(isError))
281
+ }
282
+ setBusy(isBusy) {
283
+ if (!this.refreshButton) return
284
+ this.refreshButton.disabled = Boolean(isBusy)
285
+ this.refreshButton.classList.toggle('is-busy', Boolean(isBusy))
286
+ }
287
+ formatGenerated(value) {
288
+ if (!value) return '--'
289
+ const date = new Date(value)
290
+ if (Number.isNaN(date.getTime())) return String(value)
291
+ return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
292
+ }
293
+ clearReportFiles() {
294
+ if (!this.reportFilesEl) return
295
+ while (this.reportFilesEl.firstChild) {
296
+ this.reportFilesEl.removeChild(this.reportFilesEl.firstChild)
297
+ }
298
+ }
299
+ sectionKey(section, index) {
300
+ return String((section && (section.file || section.script || section.source)) || `section-${index}`)
301
+ }
302
+ sectionMode(section, index) {
303
+ const key = this.sectionKey(section, index)
304
+ return this.sectionModes.get(key) || 'full'
305
+ }
306
+ setSectionMode(section, index, mode) {
307
+ const key = this.sectionKey(section, index)
308
+ const valid = DRAFT_SECTION_MODES.some((item) => item.value === mode)
309
+ this.sectionModes.set(key, valid ? mode : 'full')
310
+ this.rebuildDraftPreview(true)
311
+ this.renderReportFiles(this.report && this.report.sections)
312
+ }
313
+ ensureSectionModes(sections) {
314
+ const keys = new Set()
315
+ ;(sections || []).forEach((section, index) => {
316
+ const key = this.sectionKey(section, index)
317
+ keys.add(key)
318
+ if (!this.sectionModes.has(key)) {
319
+ this.sectionModes.set(key, 'full')
320
+ }
321
+ })
322
+ for (const key of Array.from(this.sectionModes.keys())) {
323
+ if (!keys.has(key)) {
324
+ this.sectionModes.delete(key)
325
+ }
326
+ }
327
+ }
328
+ prepareSection(section, index) {
329
+ const mode = this.sectionMode(section, index)
330
+ if (mode === 'exclude') {
331
+ return null
332
+ }
333
+ const config = DRAFT_SECTION_MODES.find((item) => item.value === mode)
334
+ const text = String((section && section.text) || '')
335
+ const lines = text ? text.split(/\r?\n/) : []
336
+ if (config && config.lines && lines.length > config.lines) {
337
+ const omitted = lines.length - config.lines
338
+ return {
339
+ text: `[Older ${omitted.toLocaleString()} lines omitted by user. Showing the last ${config.lines.toLocaleString()} lines.]\n${lines.slice(-config.lines).join('\n')}`,
340
+ includedLines: config.lines,
341
+ omittedLines: omitted,
342
+ mode
343
+ }
344
+ }
345
+ return {
346
+ text,
347
+ includedLines: lines.length,
348
+ omittedLines: 0,
349
+ mode
350
+ }
351
+ }
352
+ includedSectionCount(sections) {
353
+ return (sections || []).reduce((total, section, index) => {
354
+ return total + (this.prepareSection(section, index) ? 1 : 0)
355
+ }, 0)
356
+ }
357
+ updateSectionCount(sections) {
358
+ if (!this.reportSectionsEl) return
359
+ const total = Array.isArray(sections) ? sections.length : 0
360
+ const included = this.includedSectionCount(sections || [])
361
+ this.reportSectionsEl.textContent = included === total ? String(total) : `${included} / ${total}`
362
+ }
363
+ defaultDraftTitle() {
364
+ const payload = this.report || {}
365
+ const appTitle = payload.title || payload.app_id || 'Pinokio app'
366
+ return this.truncateDraftTitle(`Issue report: ${appTitle}`, DRAFT_TITLE_MAX_LENGTH)
367
+ }
368
+ truncateDraftTitle(value, maxLength = DRAFT_TITLE_DISPLAY_LENGTH) {
369
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
370
+ if (text.length <= maxLength) {
371
+ return text
372
+ }
373
+ return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`
374
+ }
375
+ draftTitleValue() {
376
+ if (this.draftTitleInput) {
377
+ return this.truncateDraftTitle(this.draftTitleInput.value, DRAFT_TITLE_MAX_LENGTH)
378
+ }
379
+ return (this.draftTitleSuggestion && this.draftTitleSuggestion.title) || this.defaultDraftTitle()
380
+ }
381
+ hasDraftTitle() {
382
+ return Boolean(this.draftTitleValue())
383
+ }
384
+ clearDraftTitle() {
385
+ this.draftTitleEdited = false
386
+ this.draftTitleSuggestion = null
387
+ if (this.draftTitleInput) {
388
+ this.draftTitleInput.value = ''
389
+ this.draftTitleInput.disabled = true
390
+ }
391
+ this.updateDraftTitleNote()
392
+ }
393
+ updateDraftTitleNote() {
394
+ if (!this.draftTitleNoteEl) return
395
+ let note = ''
396
+ if (this.draftTitleInput && !this.draftTitleInput.disabled && !this.draftTitleInput.value.trim()) {
397
+ note = 'Title required'
398
+ }
399
+ this.draftTitleNoteEl.textContent = note
400
+ }
401
+ updateDraftTitleSuggestion(force = false) {
402
+ this.draftTitleSuggestion = this.suggestDraftTitle()
403
+ if (this.draftTitleInput) {
404
+ if (force || !this.draftTitleEdited) {
405
+ this.draftTitleInput.value = this.draftTitleSuggestion.title
406
+ this.draftTitleEdited = false
407
+ }
408
+ this.draftTitleInput.disabled = !this.reviewMarkdown
409
+ }
410
+ this.updateDraftTitleNote()
411
+ }
412
+ suggestDraftTitle() {
413
+ const payload = this.report || {}
414
+ const appTitle = payload.title || payload.app_id || 'Pinokio app'
415
+ const sections = Array.isArray(payload.sections) ? payload.sections : []
416
+ const preparedSections = []
417
+ let newestModified = 0
418
+ sections.forEach((section, index) => {
419
+ const prepared = this.prepareSection(section, index)
420
+ if (!prepared) return
421
+ const modified = Date.parse(section.modified || '')
422
+ if (Number.isFinite(modified)) {
423
+ newestModified = Math.max(newestModified, modified)
424
+ }
425
+ preparedSections.push({ section, index, prepared, modified: Number.isFinite(modified) ? modified : 0 })
426
+ })
427
+
428
+ const candidates = []
429
+ for (const item of preparedSections) {
430
+ const candidate = this.bestTitleCandidateForSection(item.section, item.index, item.prepared)
431
+ if (!candidate) continue
432
+ let score = candidate.score
433
+ if (newestModified && item.modified && newestModified - item.modified > DRAFT_TITLE_RECENT_WINDOW_MS) {
434
+ score -= 35
435
+ }
436
+ candidates.push({
437
+ ...candidate,
438
+ score,
439
+ modified: item.modified,
440
+ file: item.section.file || item.section.script || 'log'
441
+ })
442
+ }
443
+
444
+ candidates.sort((a, b) => {
445
+ const scoreDelta = b.score - a.score
446
+ if (Math.abs(scoreDelta) > 10) return scoreDelta
447
+ if (a.modified && b.modified && Math.abs(a.modified - b.modified) > 60000) {
448
+ return a.modified - b.modified
449
+ }
450
+ return a.lineIndex - b.lineIndex
451
+ })
452
+
453
+ const best = candidates[0]
454
+ if (!best || best.score < 35) {
455
+ return { title: '', confidence: 'low', fallback: true }
456
+ }
457
+ return {
458
+ title: this.truncateDraftTitle(`${appTitle}: ${best.text}`, DRAFT_TITLE_MAX_LENGTH),
459
+ confidence: best.score >= 70 ? 'high' : 'medium',
460
+ source: best.file,
461
+ fallback: false
462
+ }
463
+ }
464
+ bestTitleCandidateForSection(section, index, prepared) {
465
+ const lines = String((prepared && prepared.text) || '').split(/\r?\n/)
466
+ let best = null
467
+ lines.forEach((line, lineIndex) => {
468
+ const text = this.cleanDraftTitleLine(line)
469
+ if (!text) return
470
+ const score = this.scoreDraftTitleLine(text, lineIndex, lines.length)
471
+ if (score < 25) return
472
+ if (!best || score > best.score + 6 || (Math.abs(score - best.score) <= 6 && lineIndex < best.lineIndex)) {
473
+ best = { text, score, lineIndex, sectionIndex: index }
474
+ }
475
+ })
476
+ return best
477
+ }
478
+ scoreDraftTitleLine(line, lineIndex, lineCount) {
479
+ let score = 0
480
+ if (DRAFT_TITLE_FAILURE_PATTERN.test(line)) score += 35
481
+ if (DRAFT_TITLE_STRONG_PATTERN.test(line)) score += 35
482
+ if (/:\s+\S/.test(line) && DRAFT_TITLE_FAILURE_PATTERN.test(line)) score += 8
483
+ if (lineIndex > Math.floor(lineCount * 0.7)) score += 6
484
+ if (DRAFT_TITLE_STACK_PATTERN.test(line)) score -= 28
485
+ if (DRAFT_TITLE_NOISE_PATTERN.test(line)) score -= 60
486
+ if (line.length > 180) score -= 12
487
+ return score
488
+ }
489
+ cleanDraftTitleLine(value) {
490
+ let text = String(value || '')
491
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
492
+ .replace(/\r/g, ' ')
493
+ .trim()
494
+ if (!text || DRAFT_TITLE_NOISE_PATTERN.test(text)) {
495
+ return ''
496
+ }
497
+ text = text
498
+ .replace(/^[-+*]\s+/, '')
499
+ .replace(/^<<PINOKIO_SHELL>>\s*/, '')
500
+ .replace(/\b([A-Za-z_][A-Za-z0-9_.-]*(?:TOKEN|SECRET|PASSWORD|API[_-]?KEY)[A-Za-z0-9_.-]*\s*=\s*)\S+/gi, '$1[redacted]')
501
+ .replace(/\b(?:sk|hf|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{12,}\b/g, '[secret]')
502
+ .replace(/\/(?:Users|home)\/[^/\s"'`]+\/[^\s"'`]*/g, (match) => this.shortenTitlePath(match, '/'))
503
+ .replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+\\[^\s"'`]*/g, (match) => this.shortenTitlePath(match, '\\'))
504
+ .replace(/\s+/g, ' ')
505
+ .trim()
506
+ if (!text || /^[\s=#-]+$/.test(text)) {
507
+ return ''
508
+ }
509
+ return this.truncateDraftTitle(text, DRAFT_TITLE_DISPLAY_LENGTH)
510
+ }
511
+ shortenTitlePath(value, separator) {
512
+ const text = String(value || '')
513
+ const parts = text.split(separator).filter(Boolean)
514
+ if (parts.length <= 3) {
515
+ return text
516
+ }
517
+ return `…${separator}${parts.slice(-2).join(separator)}`
518
+ }
519
+ renderDraftMarkdown() {
520
+ const payload = this.report || {}
521
+ const sections = Array.isArray(payload.sections) ? payload.sections : []
522
+ const lines = [
523
+ '# Issue Report',
524
+ '',
525
+ `App: ${payload.title || payload.app_id || 'unknown'} (${payload.app_id || 'unknown'})`,
526
+ payload.repo_url ? `Repo: ${payload.repo_url}` : null,
527
+ `Generated: ${payload.generated_at || new Date().toISOString()}`,
528
+ `Pinokio: ${payload.pinokiod || 'unknown'}`,
529
+ `Platform: ${payload.platform || 'unknown'} ${payload.arch || ''}`.trim(),
530
+ `Node: ${payload.node || 'unknown'}`,
531
+ '',
532
+ '## Summary',
533
+ '',
534
+ '',
535
+ '## System',
536
+ '',
537
+ '```json',
538
+ JSON.stringify(payload.system_spec || {}, null, 2),
539
+ '```',
540
+ '',
541
+ '## Logs'
542
+ ].filter((line) => line !== null)
543
+
544
+ let included = 0
545
+ sections.forEach((section, index) => {
546
+ const prepared = this.prepareSection(section, index)
547
+ if (!prepared) {
548
+ return
549
+ }
550
+ included += 1
551
+ const totalLines = Number(section.line_count) || prepared.includedLines || 0
552
+ const availableLines = Math.min(totalLines, Number(section.tail_count) || prepared.includedLines || totalLines)
553
+ const lineSummary = prepared.omittedLines > 0
554
+ ? `${totalLines} total, last ${prepared.includedLines} selected by user`
555
+ : `${totalLines} total, last ${availableLines} included${section.truncated ? ' (truncated)' : ''}`
556
+ lines.push(
557
+ '',
558
+ `### ${section.file || section.script || 'log'}`,
559
+ '',
560
+ `Source: ${section.source || 'api'}${section.script ? ` / ${section.script}` : ''}`,
561
+ `Lines: ${lineSummary}`,
562
+ '',
563
+ '```text',
564
+ prepared.text || '',
565
+ '```'
566
+ )
567
+ })
568
+ if (!included) {
569
+ lines.push('', 'No app log files were selected.')
570
+ }
571
+ return lines.join('\n')
572
+ }
573
+ rebuildDraftPreview(resetRedactions) {
574
+ this.reviewMarkdown = this.renderDraftMarkdown()
575
+ if (resetRedactions) {
576
+ this.redactionHasRun = false
577
+ this.redactionItems = []
578
+ this.renderedRedactionItems = []
579
+ this.selectedRedactionId = null
580
+ this.activeRedactionFilter = 'all'
581
+ this.resetRedactionReview(this.reviewMarkdown ? 'Run the privacy filter to review detected items.' : 'No report text to review.')
582
+ }
583
+ this.currentMarkdown = this.reviewMarkdown
584
+ this.updateDraftTitleSuggestion(false)
585
+ this.renderCurrentReport()
586
+ this.updateSectionCount(this.report && this.report.sections)
587
+ this.setRunFilterEnabled(Boolean(this.reviewMarkdown) && !this.filtering)
588
+ }
589
+ renderReportFiles(sections) {
590
+ this.clearReportFiles()
591
+ if (!this.reportFilesEl) return
592
+ ;(sections || []).forEach((section, index) => {
593
+ const mode = this.sectionMode(section, index)
594
+ const prepared = this.prepareSection(section, index)
595
+ const item = document.createElement('div')
596
+ item.className = 'logs-section-control'
597
+ item.classList.toggle('is-excluded', mode === 'exclude')
598
+
599
+ const checkbox = document.createElement('input')
600
+ checkbox.type = 'checkbox'
601
+ checkbox.className = 'logs-section-checkbox'
602
+ checkbox.checked = mode !== 'exclude'
603
+ checkbox.setAttribute('aria-label', `Include ${section.file || section.script || 'log section'}`)
604
+ checkbox.addEventListener('change', () => {
605
+ this.setSectionMode(section, index, checkbox.checked ? 'full' : 'exclude')
606
+ })
607
+
608
+ const textWrap = document.createElement('div')
609
+ textWrap.className = 'logs-section-text'
610
+ const name = document.createElement('div')
611
+ name.className = 'logs-section-name'
612
+ name.textContent = section.file || section.script || section.source || 'log'
613
+ name.title = name.textContent
614
+ const meta = document.createElement('div')
615
+ meta.className = 'logs-section-meta'
616
+ const size = Number(section.size) ? humanBytes(section.size) : 'unknown size'
617
+ const totalLines = Number(section.line_count) || 0
618
+ const trimText = prepared && prepared.omittedLines > 0 ? ` · ${prepared.omittedLines.toLocaleString()} older lines omitted` : ''
619
+ meta.textContent = `${totalLines.toLocaleString()} lines · ${size}${trimText}`
620
+ textWrap.appendChild(name)
621
+ textWrap.appendChild(meta)
622
+
623
+ const select = document.createElement('select')
624
+ select.className = 'logs-section-mode'
625
+ select.setAttribute('aria-label', `Draft inclusion for ${name.textContent}`)
626
+ for (const optionConfig of DRAFT_SECTION_MODES) {
627
+ const option = document.createElement('option')
628
+ option.value = optionConfig.value
629
+ option.textContent = optionConfig.label
630
+ select.appendChild(option)
631
+ }
632
+ select.value = mode
633
+ select.addEventListener('change', () => this.setSectionMode(section, index, select.value))
634
+
635
+ item.appendChild(checkbox)
636
+ item.appendChild(textWrap)
637
+ item.appendChild(select)
638
+ this.reportFilesEl.appendChild(item)
639
+ })
640
+ if (!sections || !sections.length) {
641
+ const empty = document.createElement('div')
642
+ empty.className = 'logs-review-empty'
643
+ empty.textContent = 'No latest files found.'
644
+ this.reportFilesEl.appendChild(empty)
645
+ }
646
+ }
647
+ renderSummary(payload) {
648
+ const sections = Array.isArray(payload && payload.sections) ? payload.sections : []
649
+ this.updateSectionCount(sections)
650
+ if (this.reportGeneratedEl) {
651
+ this.reportGeneratedEl.textContent = this.formatGenerated(payload && payload.generated_at)
652
+ }
653
+ this.renderReportFiles(sections)
654
+ return sections
655
+ }
656
+ setCopyEnabled(enabled) {
657
+ if (this.copyButton) {
658
+ this.copyButton.disabled = !enabled
659
+ }
660
+ }
661
+ setRunFilterEnabled(enabled) {
662
+ if (this.runFilterButton) {
663
+ this.runFilterButton.disabled = !enabled
664
+ if (!this.filtering) {
665
+ this.runFilterButton.classList.remove('is-busy')
666
+ this.runFilterButton.innerHTML = '<i class="fa-solid fa-shield-halved"></i><span>Run privacy filter</span>'
667
+ }
668
+ }
669
+ }
670
+ setFiltering(isFiltering) {
671
+ this.filtering = Boolean(isFiltering)
672
+ if (this.runFilterButton) {
673
+ this.runFilterButton.disabled = this.filtering || !this.reviewMarkdown
674
+ this.runFilterButton.classList.toggle('is-busy', this.filtering)
675
+ if (this.filtering) {
676
+ this.runFilterButton.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i><span>Filtering…</span>'
677
+ } else {
678
+ this.runFilterButton.innerHTML = '<i class="fa-solid fa-shield-halved"></i><span>Run privacy filter</span>'
679
+ }
680
+ }
681
+ this.updateDraftSizeReview()
682
+ }
683
+ setOutputText(text) {
684
+ if (this.outputEl) {
685
+ this.outputEl.textContent = text || ''
686
+ }
687
+ }
688
+ normalizeRedactionItems(items, text) {
689
+ const normalized = (Array.isArray(items) ? items : [])
690
+ .map((item, index) => {
691
+ const sourceStart = Number(item.sourceStart != null ? item.sourceStart : item.maskedStart)
692
+ const sourceEnd = Number(item.sourceEnd != null ? item.sourceEnd : item.maskedEnd)
693
+ if (!Number.isFinite(sourceStart) || !Number.isFinite(sourceEnd) || sourceEnd <= sourceStart || sourceStart < 0 || sourceEnd > text.length) {
694
+ return null
695
+ }
696
+ const id = item.id != null ? String(item.id) : String(index)
697
+ const label = String(item.label || 'private')
698
+ return {
699
+ id,
700
+ label,
701
+ sourceStart,
702
+ sourceEnd,
703
+ replacement: item.replacement || `[${label}]`,
704
+ enabled: item.enabled !== false,
705
+ line: this.lineForOffset(text, sourceStart),
706
+ context: this.lineContext(text, sourceStart, sourceEnd),
707
+ source: this.sourceForOffset(text, sourceStart)
708
+ }
709
+ })
710
+ .filter(Boolean)
711
+ .sort((a, b) => a.sourceStart - b.sourceStart || a.sourceEnd - b.sourceEnd)
712
+ const nonOverlapping = []
713
+ let cursor = 0
714
+ for (const item of normalized) {
715
+ if (item.sourceStart < cursor) {
716
+ continue
717
+ }
718
+ nonOverlapping.push(item)
719
+ cursor = item.sourceEnd
720
+ }
721
+ return nonOverlapping
722
+ }
723
+ lineForOffset(text, offset) {
724
+ return String(text || '').slice(0, Math.max(0, offset)).split('\n').length
725
+ }
726
+ lineContext(text, start, end) {
727
+ const value = String(text || '')
728
+ const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1
729
+ const nextLine = value.indexOf('\n', Math.max(0, end))
730
+ const lineEnd = nextLine >= 0 ? nextLine : value.length
731
+ return value.slice(lineStart, lineEnd).trim()
732
+ }
733
+ sourceForOffset(text, offset) {
734
+ const before = String(text || '').slice(0, Math.max(0, offset))
735
+ let source = 'Issue report'
736
+ const pattern = /^###\s+(.+)$/gm
737
+ let match = pattern.exec(before)
738
+ while (match) {
739
+ source = match[1]
740
+ match = pattern.exec(before)
741
+ }
742
+ return source
743
+ }
744
+ buildCurrentReport() {
745
+ const text = this.reviewMarkdown || ''
746
+ if (!text || !this.redactionItems.length) {
747
+ this.currentMarkdown = text
748
+ this.renderedRedactionItems = []
749
+ return
750
+ }
751
+ let cursor = 0
752
+ let output = ''
753
+ const renderedItems = []
754
+ for (const item of this.redactionItems) {
755
+ if (item.sourceStart < cursor) {
756
+ continue
757
+ }
758
+ if (item.sourceStart > cursor) {
759
+ output += text.slice(cursor, item.sourceStart)
760
+ }
761
+ const value = item.enabled ? item.replacement : text.slice(item.sourceStart, item.sourceEnd)
762
+ const maskedStart = output.length
763
+ output += value
764
+ const maskedEnd = output.length
765
+ renderedItems.push({
766
+ ...item,
767
+ maskedStart,
768
+ maskedEnd,
769
+ line: this.lineForOffset(output, maskedStart),
770
+ context: this.lineContext(output, maskedStart, maskedEnd),
771
+ source: this.sourceForOffset(output, maskedStart)
772
+ })
773
+ cursor = item.sourceEnd
774
+ }
775
+ if (cursor < text.length) {
776
+ output += text.slice(cursor)
777
+ }
778
+ this.currentMarkdown = output
779
+ this.renderedRedactionItems = renderedItems
780
+ }
781
+ enabledRedactionCount() {
782
+ return this.redactionItems.reduce((total, item) => total + (item.enabled ? 1 : 0), 0)
783
+ }
784
+ updateRedactionCount() {
785
+ if (!this.reviewCountEl) return
786
+ if (!this.redactionHasRun) {
787
+ this.reviewCountEl.textContent = 'Not run'
788
+ return
789
+ }
790
+ const enabled = this.enabledRedactionCount()
791
+ const total = this.redactionItems.length
792
+ this.reviewCountEl.textContent = total ? `${enabled} masked` : '0 masked'
793
+ }
794
+ encodeUtf8Base64(value) {
795
+ const text = String(value || '')
796
+ if (!textEncoder || typeof btoa !== 'function') {
797
+ return btoa(unescape(encodeURIComponent(text)))
798
+ }
799
+ const bytes = textEncoder.encode(text)
800
+ let binary = ''
801
+ const chunkSize = 0x8000
802
+ for (let index = 0; index < bytes.length; index += chunkSize) {
803
+ const chunk = bytes.subarray(index, index + chunkSize)
804
+ binary += String.fromCharCode.apply(null, chunk)
805
+ }
806
+ return btoa(binary)
807
+ }
808
+ buildDraftMetadata() {
809
+ const payload = this.report || {}
810
+ const appTitle = payload.title || payload.app_id || 'Pinokio app'
811
+ const title = this.draftTitleInput
812
+ ? this.draftTitleValue()
813
+ : (this.draftTitleValue() || `Issue report: ${appTitle}`)
814
+ const metadata = {
815
+ title,
816
+ body: this.currentMarkdown || this.reviewMarkdown || '',
817
+ tags: ['bug', 'logs'],
818
+ source: 'pinokio-logs',
819
+ appLocalId: payload.app_id || ''
820
+ }
821
+ if (payload.repo_url) {
822
+ metadata.repoUrl = payload.repo_url
823
+ metadata.appRepoUrl = payload.repo_url
824
+ metadata.parent = { type: 'app', url: payload.repo_url }
825
+ }
826
+ return metadata
827
+ }
828
+ buildDraftImportPayload() {
829
+ const metadata = this.buildDraftMetadata()
830
+ const metadataJson = JSON.stringify(metadata)
831
+ const metadataB64 = this.encodeUtf8Base64(metadataJson)
832
+ return {
833
+ metadata,
834
+ metadataB64,
835
+ bodyBytes: textByteLength(metadata.body || ''),
836
+ payloadBytes: textByteLength(metadataB64)
837
+ }
838
+ }
839
+ setCreateDraftBusy(isBusy) {
840
+ this.importingDraft = Boolean(isBusy)
841
+ if (!this.createDraftButton) return
842
+ if (this.importingDraft) {
843
+ this.createDraftButton.disabled = true
844
+ this.createDraftButton.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i><span>Opening…</span>'
845
+ } else {
846
+ this.createDraftButton.innerHTML = '<i class="fa-solid fa-paper-plane"></i><span>Ask Community</span>'
847
+ this.updateDraftSizeReview()
848
+ }
849
+ }
850
+ updateDraftSizeReview() {
851
+ const hasText = Boolean(this.currentMarkdown || this.reviewMarkdown)
852
+ const hasTitle = this.hasDraftTitle()
853
+ let draft = null
854
+ if (hasText) {
855
+ draft = this.buildDraftImportPayload()
856
+ this.draftBodyBytes = draft.bodyBytes
857
+ this.draftPayloadBytes = draft.payloadBytes
858
+ this.draftOversized = draft.bodyBytes > DRAFT_BODY_TARGET_BYTES || draft.payloadBytes > DRAFT_IMPORT_FIELD_LIMIT_BYTES
859
+ } else {
860
+ this.draftBodyBytes = 0
861
+ this.draftPayloadBytes = 0
862
+ this.draftOversized = false
863
+ }
864
+ if (this.draftSizeBadgeEl) {
865
+ this.draftSizeBadgeEl.textContent = hasText ? `${humanBytes(this.draftBodyBytes)} / ${humanBytes(DRAFT_BODY_TARGET_BYTES)}` : '--'
866
+ this.draftSizeBadgeEl.classList.toggle('is-error', Boolean(hasText && this.draftOversized))
867
+ }
868
+ if (this.draftMeterFillEl) {
869
+ const pct = hasText ? Math.min(100, Math.round((this.draftBodyBytes / DRAFT_BODY_TARGET_BYTES) * 100)) : 0
870
+ this.draftMeterFillEl.style.width = `${pct}%`
871
+ this.draftMeterFillEl.classList.toggle('is-error', Boolean(hasText && this.draftOversized))
872
+ }
873
+ if (this.draftStatusEl) {
874
+ let message = 'Waiting for latest log snapshot.'
875
+ let isError = false
876
+ if (hasText && this.draftOversized) {
877
+ isError = true
878
+ message = this.draftPayloadBytes > DRAFT_IMPORT_FIELD_LIMIT_BYTES
879
+ ? `Too large for registry import after encoding. Exclude a section or keep fewer recent lines.`
880
+ : `Too large for one-click draft import. Exclude a section or keep fewer recent lines.`
881
+ } else if (hasText && !hasTitle) {
882
+ isError = true
883
+ message = 'Add a title before posting to Community.'
884
+ } else if (hasText) {
885
+ message = 'Ready. Community will use this preview exactly.'
886
+ }
887
+ this.draftStatusEl.textContent = message
888
+ this.draftStatusEl.classList.toggle('is-error', isError)
889
+ }
890
+ if (this.draftTitleInput) {
891
+ this.draftTitleInput.disabled = !hasText
892
+ }
893
+ this.updateDraftTitleNote()
894
+ if (this.createDraftButton && !this.importingDraft) {
895
+ const canCreate = hasText && hasTitle && !this.draftOversized && !this.filtering && Boolean(this.draftUrl)
896
+ this.createDraftButton.disabled = !canCreate
897
+ this.createDraftButton.title = canCreate
898
+ ? 'Open a draft question on Community'
899
+ : (!hasTitle ? 'Add a title before posting to Community' : (this.draftOversized ? 'Reduce the report size before posting to Community' : 'Community posting is not available for this view'))
900
+ }
901
+ }
902
+ renderCurrentReport() {
903
+ this.buildCurrentReport()
904
+ const text = this.currentMarkdown || 'No latest app logs were found.'
905
+ this.renderHighlightedOutput(text, this.renderedRedactionItems)
906
+ this.renderRedactionReview()
907
+ this.updateRedactionCount()
908
+ this.updateDraftSizeReview()
909
+ this.setCopyEnabled(Boolean(this.currentMarkdown))
910
+ this.selectRedaction(this.selectedRedactionId, false)
911
+ }
912
+ renderHighlightedOutput(text, items = []) {
913
+ if (!this.outputEl) {
914
+ return
915
+ }
916
+ this.outputEl.textContent = ''
917
+ if (!text) {
918
+ return
919
+ }
920
+ if (!items.length) {
921
+ this.outputEl.textContent = text
922
+ return
923
+ }
924
+ let cursor = 0
925
+ for (const item of items) {
926
+ if (item.maskedStart < cursor) {
927
+ continue
928
+ }
929
+ if (item.maskedStart > cursor) {
930
+ this.outputEl.appendChild(document.createTextNode(text.slice(cursor, item.maskedStart)))
931
+ }
932
+ const token = document.createElement('span')
933
+ token.className = item.enabled ? 'logs-mask-token' : 'logs-unmasked-token'
934
+ token.dataset.redactionId = item.id
935
+ token.textContent = text.slice(item.maskedStart, item.maskedEnd)
936
+ token.title = `${item.enabled ? 'Masked' : 'Visible'} ${item.label} · line ${item.line}`
937
+ this.outputEl.appendChild(token)
938
+ cursor = item.maskedEnd
939
+ }
940
+ if (cursor < text.length) {
941
+ this.outputEl.appendChild(document.createTextNode(text.slice(cursor)))
942
+ }
943
+ }
944
+ renderRedactionReview() {
945
+ const items = this.redactionItems || []
946
+ const renderedById = new Map((this.renderedRedactionItems || []).map((item) => [item.id, item]))
947
+ const labels = new Map()
948
+ for (const item of items) {
949
+ labels.set(item.label, (labels.get(item.label) || 0) + 1)
950
+ }
951
+ this.updateRedactionCount()
952
+ if (this.reviewFiltersEl) {
953
+ this.reviewFiltersEl.textContent = ''
954
+ const filters = [['all', `All ${items.length}`], ...Array.from(labels.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([label, count]) => [label, `${label} ${count}`])]
955
+ for (const [value, label] of filters) {
956
+ const button = document.createElement('button')
957
+ button.type = 'button'
958
+ button.className = 'logs-redaction-filter'
959
+ button.classList.toggle('is-active', this.activeRedactionFilter === value)
960
+ button.dataset.redactionFilter = value
961
+ button.textContent = label
962
+ button.addEventListener('click', () => {
963
+ this.activeRedactionFilter = value
964
+ this.renderRedactionReview()
965
+ })
966
+ this.reviewFiltersEl.appendChild(button)
967
+ }
968
+ }
969
+ if (!this.reviewListEl) {
970
+ return
971
+ }
972
+ this.reviewListEl.textContent = ''
973
+ if (!items.length) {
974
+ const empty = document.createElement('div')
975
+ empty.className = 'logs-redaction-empty'
976
+ empty.textContent = this.redactionHasRun ? 'No redactions detected.' : 'Run the privacy filter to review detected items.'
977
+ this.reviewListEl.appendChild(empty)
978
+ return
979
+ }
980
+ const visibleItems = this.activeRedactionFilter === 'all'
981
+ ? items
982
+ : items.filter((item) => item.label === this.activeRedactionFilter)
983
+ if (!visibleItems.length) {
984
+ const empty = document.createElement('div')
985
+ empty.className = 'logs-redaction-empty'
986
+ empty.textContent = 'No redactions match this filter.'
987
+ this.reviewListEl.appendChild(empty)
988
+ return
989
+ }
990
+ for (const item of visibleItems) {
991
+ const rendered = renderedById.get(item.id) || item
992
+ const row = document.createElement('div')
993
+ row.className = 'logs-redaction-row'
994
+ row.dataset.redactionId = item.id
995
+ row.classList.toggle('is-selected', this.selectedRedactionId === item.id)
996
+ row.classList.toggle('is-disabled', !item.enabled)
997
+ row.tabIndex = 0
998
+ row.setAttribute('role', 'button')
999
+ row.setAttribute('aria-current', this.selectedRedactionId === item.id ? 'true' : 'false')
1000
+ row.addEventListener('click', () => this.selectRedaction(item.id, true))
1001
+ row.addEventListener('keydown', (event) => {
1002
+ if (event.key === 'Enter' || event.key === ' ') {
1003
+ event.preventDefault()
1004
+ this.selectRedaction(item.id, true)
1005
+ }
1006
+ })
1007
+
1008
+ const top = document.createElement('div')
1009
+ top.className = 'logs-redaction-row-top'
1010
+ const meta = document.createElement('div')
1011
+ meta.className = 'logs-redaction-row-meta'
1012
+
1013
+ const label = document.createElement('span')
1014
+ label.className = 'logs-redaction-label'
1015
+ label.textContent = item.label
1016
+ meta.appendChild(label)
1017
+
1018
+ const source = document.createElement('span')
1019
+ source.className = 'logs-redaction-source'
1020
+ source.textContent = `${item.source} · line ${item.line}`
1021
+ source.title = source.textContent
1022
+ meta.appendChild(source)
1023
+
1024
+ const toggle = document.createElement('label')
1025
+ toggle.className = 'logs-redaction-toggle'
1026
+ toggle.title = item.enabled ? 'Keep this item masked' : 'Show this item in the copied report'
1027
+ toggle.addEventListener('click', (event) => event.stopPropagation())
1028
+ const checkbox = document.createElement('input')
1029
+ checkbox.type = 'checkbox'
1030
+ checkbox.checked = item.enabled
1031
+ checkbox.setAttribute('aria-label', `${item.enabled ? 'Mask' : 'Show'} ${item.label}`)
1032
+ checkbox.addEventListener('click', (event) => event.stopPropagation())
1033
+ checkbox.addEventListener('change', (event) => {
1034
+ event.stopPropagation()
1035
+ item.enabled = checkbox.checked
1036
+ this.selectedRedactionId = item.id
1037
+ this.renderCurrentReport()
1038
+ })
1039
+ const toggleTrack = document.createElement('span')
1040
+ toggleTrack.className = 'logs-redaction-toggle-track'
1041
+ toggle.appendChild(checkbox)
1042
+ toggle.appendChild(toggleTrack)
1043
+
1044
+ const context = document.createElement('div')
1045
+ context.className = 'logs-redaction-context'
1046
+ context.textContent = rendered.context || ''
1047
+ context.title = rendered.context || ''
1048
+
1049
+ top.appendChild(meta)
1050
+ top.appendChild(toggle)
1051
+ row.appendChild(top)
1052
+ row.appendChild(context)
1053
+ this.reviewListEl.appendChild(row)
1054
+ }
1055
+ }
1056
+ selectRedaction(id, scrollPreview) {
1057
+ this.selectedRedactionId = id == null ? null : String(id)
1058
+ if (this.outputEl) {
1059
+ this.outputEl.querySelectorAll('.logs-mask-token, .logs-unmasked-token').forEach((node) => {
1060
+ node.classList.toggle('is-selected', node.dataset.redactionId === this.selectedRedactionId)
1061
+ })
1062
+ }
1063
+ if (this.reviewListEl) {
1064
+ this.reviewListEl.querySelectorAll('.logs-redaction-row').forEach((node) => {
1065
+ const selected = node.dataset.redactionId === this.selectedRedactionId
1066
+ node.classList.toggle('is-selected', selected)
1067
+ node.setAttribute('aria-current', selected ? 'true' : 'false')
1068
+ })
1069
+ }
1070
+ if (!scrollPreview || !this.outputEl || !this.selectedRedactionId) {
1071
+ return
1072
+ }
1073
+ const token = Array.from(this.outputEl.querySelectorAll('.logs-mask-token, .logs-unmasked-token')).find((node) => {
1074
+ return node.dataset.redactionId === this.selectedRedactionId
1075
+ })
1076
+ if (!token) {
1077
+ return
1078
+ }
1079
+ const reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches
1080
+ const outputRect = this.outputEl.getBoundingClientRect()
1081
+ const tokenRect = token.getBoundingClientRect()
1082
+ const nextTop = this.outputEl.scrollTop + tokenRect.top - outputRect.top - Math.round(outputRect.height * 0.35)
1083
+ this.outputEl.scrollTo({
1084
+ top: Math.max(0, nextTop),
1085
+ behavior: reducedMotion ? 'auto' : 'smooth'
1086
+ })
1087
+ }
1088
+ resetRedactionReview(message = 'Filtering has not run yet.') {
1089
+ this.redactionItems = []
1090
+ this.renderedRedactionItems = []
1091
+ this.selectedRedactionId = null
1092
+ this.activeRedactionFilter = 'all'
1093
+ this.updateRedactionCount()
1094
+ if (this.reviewFiltersEl) {
1095
+ this.reviewFiltersEl.textContent = ''
1096
+ }
1097
+ if (this.reviewListEl) {
1098
+ this.reviewListEl.textContent = ''
1099
+ const empty = document.createElement('div')
1100
+ empty.className = 'logs-redaction-empty'
1101
+ empty.textContent = message
1102
+ this.reviewListEl.appendChild(empty)
1103
+ }
1104
+ }
1105
+ render(payload) {
1106
+ const sections = this.renderSummary(payload)
1107
+ this.rawMarkdown = payload && payload.markdown ? String(payload.markdown) : ''
1108
+ this.ensureSectionModes(sections)
1109
+ this.reviewMarkdown = this.renderDraftMarkdown()
1110
+ this.currentMarkdown = this.reviewMarkdown
1111
+ this.redactionItems = []
1112
+ this.renderedRedactionItems = []
1113
+ this.filterChunks = 0
1114
+ this.redactionHasRun = false
1115
+ this.filtering = false
1116
+ this.selectedRedactionId = null
1117
+ this.activeRedactionFilter = 'all'
1118
+ this.draftTitleEdited = false
1119
+ this.updateDraftTitleSuggestion(true)
1120
+ this.renderCurrentReport()
1121
+ this.resetRedactionReview(this.reviewMarkdown ? 'Run the privacy filter to review detected items.' : 'No report text to review.')
1122
+ this.updateRedactionCount()
1123
+ this.setRunFilterEnabled(Boolean(this.reviewMarkdown))
1124
+ if (!this.reviewMarkdown) {
1125
+ this.setStatus(`Latest snapshot found ${sections.length} log section${sections.length === 1 ? '' : 's'}.`)
1126
+ return
1127
+ }
1128
+ this.setStatus(`Latest snapshot built from ${sections.length} log section${sections.length === 1 ? '' : 's'}. Run the privacy filter only if you want local redaction review.`)
1129
+ }
1130
+ renderError(message) {
1131
+ if (this.reportSectionsEl) this.reportSectionsEl.textContent = '--'
1132
+ if (this.reportGeneratedEl) this.reportGeneratedEl.textContent = '--'
1133
+ this.clearReportFiles()
1134
+ this.rawMarkdown = ''
1135
+ this.reviewMarkdown = ''
1136
+ this.currentMarkdown = ''
1137
+ this.redactionHasRun = false
1138
+ this.filtering = false
1139
+ this.clearDraftTitle()
1140
+ this.setOutputText(message || 'Unable to build latest log snapshot.')
1141
+ this.resetRedactionReview('No redaction review is available.')
1142
+ this.setCopyEnabled(false)
1143
+ this.setRunFilterEnabled(false)
1144
+ this.updateDraftSizeReview()
1145
+ this.setStatus(message || 'Unable to build latest log snapshot.', true)
1146
+ }
1147
+ renderFilterProgress(progress) {
1148
+ if (!progress || typeof progress !== 'object') {
1149
+ return
1150
+ }
1151
+ if (progress.type === 'runtime') {
1152
+ this.setStatus(`Loading OpenAI Privacy Filter locally (${progress.device}/${progress.dtype}). First run downloads and caches the model.`)
1153
+ } else if (progress.type === 'fallback') {
1154
+ this.setStatus(progress.message || `Retrying privacy filtering locally with ${progress.device}/${progress.dtype}.`)
1155
+ } else if (progress.type === 'download') {
1156
+ const fileLabel = progress.file ? ` ${progress.file}` : ''
1157
+ if (progress.total) {
1158
+ this.setStatus(`Downloading privacy filter${fileLabel}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}. Cached for future reports.`)
1159
+ } else {
1160
+ this.setStatus(`Downloading privacy filter${fileLabel}. Cached for future reports.`)
1161
+ }
1162
+ } else if (progress.type === 'chunk') {
1163
+ const total = Number(progress.total) || 0
1164
+ const done = Number(progress.done) || 0
1165
+ this.setStatus(total > 0 ? `Filtering locally… ${Math.min(done + 1, total)} / ${total} chunks.` : 'Filtering locally…')
1166
+ }
1167
+ }
1168
+ renderFilterError(error) {
1169
+ this.redactionItems = []
1170
+ this.renderedRedactionItems = []
1171
+ this.redactionHasRun = false
1172
+ this.currentMarkdown = this.reviewMarkdown
1173
+ this.selectedRedactionId = null
1174
+ this.setCopyEnabled(Boolean(this.currentMarkdown))
1175
+ this.setRunFilterEnabled(Boolean(this.reviewMarkdown))
1176
+ this.setOutputText(this.reviewMarkdown || 'Privacy filter failed. No report text is available.')
1177
+ this.updateDraftSizeReview()
1178
+ this.resetRedactionReview('Privacy filtering failed before any redactions could be reviewed.')
1179
+ this.setStatus(error && error.message ? error.message : 'Privacy filter failed.', true)
1180
+ }
1181
+ async filterReport() {
1182
+ const sourceMarkdown = this.reviewMarkdown || ''
1183
+ if (!sourceMarkdown || this.filtering) {
1184
+ return
1185
+ }
1186
+ const token = this.filterToken + 1
1187
+ this.filterToken = token
1188
+ this.setFiltering(true)
1189
+ this.setCopyEnabled(false)
1190
+ try {
1191
+ const result = await this.privacyFilter.filter(sourceMarkdown, (progress) => {
1192
+ if (token === this.filterToken) {
1193
+ this.renderFilterProgress(progress)
1194
+ }
1195
+ })
1196
+ if (token !== this.filterToken) {
1197
+ return
1198
+ }
1199
+ this.redactionItems = this.normalizeRedactionItems(result && result.items, sourceMarkdown)
1200
+ this.filterChunks = Number(result && result.chunks) || 1
1201
+ this.redactionHasRun = true
1202
+ this.selectedRedactionId = this.redactionItems.length ? this.redactionItems[0].id : null
1203
+ this.renderCurrentReport()
1204
+ this.selectRedaction(this.selectedRedactionId, false)
1205
+ const maskedCount = this.enabledRedactionCount()
1206
+ this.setStatus(`Privacy filter finished locally. ${maskedCount} item${maskedCount === 1 ? '' : 's'} masked across ${this.filterChunks} chunk${this.filterChunks === 1 ? '' : 's'}.`)
1207
+ } catch (error) {
1208
+ if (token === this.filterToken) {
1209
+ this.renderFilterError(error)
1210
+ }
1211
+ } finally {
1212
+ if (token === this.filterToken) {
1213
+ this.setFiltering(false)
1214
+ }
1215
+ }
1216
+ }
1217
+ async load(force = false) {
1218
+ if (!this.reportUrl) {
1219
+ this.renderError('Latest log snapshot is available for app workspaces.')
1220
+ return
1221
+ }
1222
+ if (this.report && !force) {
1223
+ this.renderSummary(this.report)
1224
+ this.rebuildDraftPreview(false)
1225
+ this.setRunFilterEnabled(Boolean(this.reviewMarkdown) && !this.filtering)
1226
+ if (!this.redactionHasRun && this.reviewMarkdown) {
1227
+ this.setStatus('Unfiltered report is ready. Run the privacy filter only if you want local redaction review.')
1228
+ }
1229
+ return
1230
+ }
1231
+ if (this.loading) return
1232
+ this.loading = true
1233
+ this.setBusy(true)
1234
+ this.setStatus('Building latest log snapshot…')
1235
+ this.filterToken += 1
1236
+ this.rawMarkdown = ''
1237
+ this.reviewMarkdown = ''
1238
+ this.currentMarkdown = ''
1239
+ this.redactionItems = []
1240
+ this.renderedRedactionItems = []
1241
+ this.redactionHasRun = false
1242
+ this.selectedRedactionId = null
1243
+ this.activeRedactionFilter = 'all'
1244
+ this.clearDraftTitle()
1245
+ this.resetRedactionReview('Waiting for latest log snapshot.')
1246
+ this.setCopyEnabled(false)
1247
+ this.setFiltering(false)
1248
+ this.updateDraftSizeReview()
1249
+ try {
1250
+ const response = await fetch(this.rawReportUrl || this.reportUrl, {
1251
+ headers: { 'Accept': 'application/json' },
1252
+ cache: 'no-store'
1253
+ })
1254
+ const payload = await response.json().catch(() => null)
1255
+ if (!response.ok) {
1256
+ throw new Error(payload && payload.error ? payload.error : `HTTP ${response.status}`)
1257
+ }
1258
+ this.report = payload
1259
+ this.render(payload)
1260
+ } catch (error) {
1261
+ this.renderError(error && error.message ? error.message : String(error || 'Unknown error'))
1262
+ } finally {
1263
+ this.loading = false
1264
+ this.setBusy(false)
1265
+ }
1266
+ }
1267
+ registryOrigin(value) {
1268
+ try {
1269
+ return new URL(value || this.registryBase, window.location.origin).origin
1270
+ } catch (_) {
1271
+ return 'https://pinokio.co'
1272
+ }
1273
+ }
1274
+ registryMessageOrigins() {
1275
+ const origins = new Set([this.registryOrigin(this.registryBase)])
1276
+ if (origins.has('https://pinokio.co')) {
1277
+ origins.add('https://www.pinokio.co')
1278
+ }
1279
+ return origins
1280
+ }
1281
+ async submitDraftImport(token, registry, popup, popupOrigin) {
1282
+ const draft = this.buildDraftImportPayload()
1283
+ if (!draft.metadata.title) {
1284
+ throw new Error('Add a title before posting to Community.')
1285
+ }
1286
+ if (!draft.metadata.body || this.draftOversized || draft.payloadBytes > DRAFT_IMPORT_FIELD_LIMIT_BYTES) {
1287
+ throw new Error('Draft is too large for registry import.')
1288
+ }
1289
+ const form = new FormData()
1290
+ form.append('token', token)
1291
+ form.append('registry', registry || this.registryBase)
1292
+ form.append('metadata_b64', draft.metadataB64)
1293
+ const response = await fetch(this.draftUrl, {
1294
+ method: 'POST',
1295
+ body: form,
1296
+ cache: 'no-store'
1297
+ })
1298
+ const payload = await response.json().catch(() => ({}))
1299
+ if (!response.ok) {
1300
+ throw new Error(payload && payload.error ? payload.error : `Draft import failed (${response.status})`)
1301
+ }
1302
+ const editUrl = payload && payload.editUrl ? String(payload.editUrl) : ''
1303
+ if (popup && !popup.closed) {
1304
+ popup.postMessage({
1305
+ type: 'pinokio:draft-import-result',
1306
+ ok: true,
1307
+ editUrl
1308
+ }, popupOrigin || this.registryOrigin(this.registryBase))
1309
+ }
1310
+ this.setStatus(editUrl ? 'Community draft created. Opening editor.' : 'Community draft created.')
1311
+ return payload
1312
+ }
1313
+ async createDraft() {
1314
+ if (this.importingDraft || this.draftOversized || !this.hasDraftTitle() || !this.currentMarkdown || !this.draftUrl) {
1315
+ this.updateDraftSizeReview()
1316
+ return
1317
+ }
1318
+ const authorizeUrl = new URL('/draft-import/authorize', this.registryBase)
1319
+ authorizeUrl.searchParams.set('handoff', 'post_message')
1320
+ authorizeUrl.searchParams.set('origin', window.location.origin)
1321
+ authorizeUrl.searchParams.set('wait', '1')
1322
+ this.setCreateDraftBusy(true)
1323
+ this.setStatus('Opening Community authorization…')
1324
+ const popup = window.open(authorizeUrl.toString(), 'pinokio-draft-import', 'width=720,height=760')
1325
+ if (!popup) {
1326
+ this.setCreateDraftBusy(false)
1327
+ this.setStatus('Community authorization window was blocked.', true)
1328
+ return
1329
+ }
1330
+ const registryOrigin = this.registryOrigin(this.registryBase)
1331
+ const registryMessageOrigins = this.registryMessageOrigins()
1332
+ let popupOrigin = registryOrigin
1333
+ let settled = false
1334
+ const finish = (message, isError) => {
1335
+ settled = true
1336
+ window.removeEventListener('message', onMessage)
1337
+ window.clearInterval(closeTimer)
1338
+ this.setCreateDraftBusy(false)
1339
+ if (message) {
1340
+ this.setStatus(message, isError)
1341
+ }
1342
+ }
1343
+ const failPopup = (message) => {
1344
+ if (popup && !popup.closed) {
1345
+ popup.postMessage({
1346
+ type: 'pinokio:draft-import-result',
1347
+ ok: false,
1348
+ error: message
1349
+ }, popupOrigin)
1350
+ }
1351
+ }
1352
+ const onMessage = async (event) => {
1353
+ if (settled || event.source !== popup || !registryMessageOrigins.has(event.origin)) {
1354
+ return
1355
+ }
1356
+ const data = event.data || {}
1357
+ if (!data || data.type !== 'pinokio:draft-import-token') {
1358
+ return
1359
+ }
1360
+ popupOrigin = event.origin
1361
+ const token = typeof data.token === 'string' ? data.token : ''
1362
+ const registry = typeof data.registry === 'string' ? data.registry : this.registryBase
1363
+ if (!token) {
1364
+ failPopup('Community did not return an import token.')
1365
+ finish('Community did not return an import token.', true)
1366
+ return
1367
+ }
1368
+ try {
1369
+ await this.submitDraftImport(token, registry, popup, popupOrigin)
1370
+ finish(null, false)
1371
+ } catch (error) {
1372
+ const message = error && error.message ? error.message : 'Draft import failed.'
1373
+ failPopup(message)
1374
+ finish(message, true)
1375
+ }
1376
+ }
1377
+ const closeTimer = window.setInterval(() => {
1378
+ if (!settled && popup.closed) {
1379
+ finish('Community authorization was closed before import.', true)
1380
+ }
1381
+ }, 1000)
1382
+ window.addEventListener('message', onMessage)
1383
+ popup.focus()
1384
+ }
1385
+ async copy() {
1386
+ if (!this.currentMarkdown) return
1387
+ try {
1388
+ await navigator.clipboard.writeText(this.currentMarkdown)
1389
+ this.setStatus(this.redactionHasRun ? 'Reviewed report copied.' : 'Unfiltered report copied.')
1390
+ } catch (_) {
1391
+ if (this.outputEl) {
1392
+ this.outputEl.focus()
1393
+ const selection = window.getSelection()
1394
+ const range = document.createRange()
1395
+ range.selectNodeContents(this.outputEl)
1396
+ selection.removeAllRanges()
1397
+ selection.addRange(range)
1398
+ }
1399
+ this.setStatus('Select the snapshot text to copy.')
1400
+ }
1401
+ }
1402
+ }
1403
+
71
1404
  class LogsViewer {
72
1405
  constructor(options) {
73
1406
  this.outputEl = options.outputEl
@@ -385,6 +1718,8 @@
385
1718
  this.rootDisplay = config.rootDisplay || ''
386
1719
  this.workspace = typeof config.workspace === 'string' ? config.workspace.trim() : ''
387
1720
  this.workspaceTitle = config.workspaceTitle || ''
1721
+ this.reportUrl = config.reportUrl || (this.workspace ? `/apps/logs/${encodeURIComponent(this.workspace)}/report` : '')
1722
+ this.initialView = config.initialView === 'latest' && this.reportUrl ? 'latest' : 'raw'
388
1723
  this.boundApplyHeight = null
389
1724
  this.boundBeforeUnload = null
390
1725
  this.headerObserver = null
@@ -399,6 +1734,7 @@
399
1734
  this.resizeState = null
400
1735
  this.sidebarPreferenceKey = this.workspace ? `${LOGS_SIDEBAR_STORAGE_KEY}:${this.workspace}` : LOGS_SIDEBAR_STORAGE_KEY
401
1736
  this.sidebarWidthKey = this.workspace ? `${LOGS_SIDEBAR_WIDTH_KEY}:${this.workspace}` : LOGS_SIDEBAR_WIDTH_KEY
1737
+ this.closeButton = document.getElementById('logs-close-view')
402
1738
  const downloadHref = config.downloadUrl || (this.workspace ? `/pinokio/logs.zip?workspace=${encodeURIComponent(this.workspace)}` : '/pinokio/logs.zip')
403
1739
  const zipEndpoint = this.workspace ? `/pinokio/log?workspace=${encodeURIComponent(this.workspace)}` : '/pinokio/log'
404
1740
  const zipControls = new LogsZipControls({
@@ -409,6 +1745,28 @@
409
1745
  defaultDownloadHref: downloadHref
410
1746
  })
411
1747
  this.zipControls = zipControls
1748
+ this.latestReport = new LogsLatestReport({
1749
+ reportUrl: this.reportUrl,
1750
+ draftUrl: config.draftUrl || '',
1751
+ registryBase: config.registryBase || 'https://pinokio.co',
1752
+ statusEl: document.getElementById('logs-report-status'),
1753
+ outputEl: document.getElementById('logs-report-output'),
1754
+ copyButton: document.getElementById('logs-copy-report'),
1755
+ createDraftButton: document.getElementById('logs-create-draft'),
1756
+ draftTitleInput: document.getElementById('logs-draft-title'),
1757
+ draftTitleNoteEl: document.getElementById('logs-draft-title-note'),
1758
+ runFilterButton: document.getElementById('logs-run-filter'),
1759
+ refreshButton: document.getElementById('logs-refresh-report'),
1760
+ reportFilesEl: document.getElementById('logs-report-files'),
1761
+ reportGeneratedEl: document.getElementById('logs-report-generated'),
1762
+ reportSectionsEl: document.getElementById('logs-report-sections'),
1763
+ draftSizeBadgeEl: document.getElementById('logs-draft-size-badge'),
1764
+ draftMeterFillEl: document.getElementById('logs-draft-meter-fill'),
1765
+ draftStatusEl: document.getElementById('logs-draft-status'),
1766
+ reviewListEl: document.getElementById('logs-redaction-list'),
1767
+ reviewFiltersEl: document.getElementById('logs-redaction-filters'),
1768
+ reviewCountEl: document.getElementById('logs-redaction-count')
1769
+ })
412
1770
  this.viewer = new LogsViewer({
413
1771
  outputEl: document.getElementById('logs-viewer-output'),
414
1772
  statusEl: document.getElementById('logs-viewer-status'),
@@ -440,12 +1798,82 @@
440
1798
  }
441
1799
  })
442
1800
  }
1801
+ this.initCloseButton()
1802
+ this.initViewSwitch()
443
1803
  this.initSidebarWidth()
444
1804
  this.initSidebarToggle()
445
1805
  this.initSidebarResizer()
446
1806
  this.setupPaneHeightManagement()
447
1807
  }
448
1808
 
1809
+ initCloseButton() {
1810
+ if (!this.closeButton) {
1811
+ return
1812
+ }
1813
+ this.closeButton.addEventListener('click', (event) => {
1814
+ event.preventDefault()
1815
+ event.stopPropagation()
1816
+ try {
1817
+ window.parent.postMessage({ type: 'pinokio:close-logs', e: 'pinokio:close-logs' }, window.location.origin)
1818
+ } catch (_) {
1819
+ window.parent.postMessage({ type: 'pinokio:close-logs', e: 'pinokio:close-logs' }, '*')
1820
+ }
1821
+ })
1822
+ }
1823
+
1824
+ initViewSwitch() {
1825
+ if (!this.rootElement) {
1826
+ return
1827
+ }
1828
+ this.viewButtons = Array.from(this.rootElement.querySelectorAll('[data-logs-view]'))
1829
+ this.viewButtons.forEach((button) => {
1830
+ button.addEventListener('click', () => {
1831
+ const view = button.dataset.logsView === 'latest' && this.reportUrl ? 'latest' : 'raw'
1832
+ this.setView(view, true)
1833
+ })
1834
+ })
1835
+ this.setView(this.initialView, false)
1836
+ }
1837
+
1838
+ setView(view, updateUrl) {
1839
+ const nextView = view === 'latest' && this.reportUrl ? 'latest' : 'raw'
1840
+ if (this.rootElement) {
1841
+ this.rootElement.dataset.view = nextView
1842
+ }
1843
+ if (Array.isArray(this.viewButtons)) {
1844
+ this.viewButtons.forEach((button) => {
1845
+ const active = button.dataset.logsView === nextView
1846
+ button.classList.toggle('is-active', active)
1847
+ button.setAttribute('aria-selected', active ? 'true' : 'false')
1848
+ button.tabIndex = active ? 0 : -1
1849
+ })
1850
+ }
1851
+ const latestPanel = document.getElementById('logs-latest-panel')
1852
+ const rawPanel = document.getElementById('logs-raw-panel')
1853
+ if (latestPanel) {
1854
+ latestPanel.hidden = nextView !== 'latest'
1855
+ }
1856
+ if (rawPanel) {
1857
+ rawPanel.hidden = nextView !== 'raw'
1858
+ }
1859
+ if (nextView === 'latest') {
1860
+ if (this.viewer) {
1861
+ this.viewer.stop()
1862
+ }
1863
+ if (this.latestReport) {
1864
+ this.latestReport.load(false)
1865
+ }
1866
+ }
1867
+ if (updateUrl) {
1868
+ try {
1869
+ const url = new URL(window.location.href)
1870
+ url.searchParams.set('view', nextView === 'latest' ? 'latest' : 'raw')
1871
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
1872
+ } catch (_) {}
1873
+ }
1874
+ this.applyPaneHeight()
1875
+ }
1876
+
449
1877
  initSidebarWidth() {
450
1878
  if (!this.rootElement) {
451
1879
  return