pinokiod 8.0.39 → 8.0.40

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.
@@ -1,1556 +0,0 @@
1
- <html>
2
- <head>
3
- <meta charset="UTF-8">
4
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
5
- <link href="/css/fontawesome.min.css" rel="stylesheet">
6
- <link href="/css/solid.min.css" rel="stylesheet">
7
- <link href="/css/regular.min.css" rel="stylesheet">
8
- <link href="/css/brands.min.css" rel="stylesheet">
9
- <link href="/noty.css" rel="stylesheet"/>
10
- <link href="/style.css" rel="stylesheet"/>
11
- <link href="/task-launcher.css" rel="stylesheet"/>
12
- <% if (agent === "electron") { %>
13
- <link href="/electron.css" rel="stylesheet"/>
14
- <% } %>
15
- <style>
16
- .vault-page {
17
- --vault-warning: #b06a09;
18
- --vault-warning-soft: color-mix(in srgb, var(--vault-warning) 8%, var(--task-panel));
19
- --vault-selected: color-mix(in srgb, var(--task-accent) 10%, var(--task-panel));
20
- --vault-inline: 18px;
21
- --vault-control-height: 28px;
22
- --vault-row-height: 44px;
23
- --vault-tree-row: 26px;
24
- --vault-tree-path-row: 34px;
25
- }
26
- body.dark.vault-page {
27
- --vault-warning: #dca451;
28
- --vault-warning-soft: color-mix(in srgb, var(--vault-warning) 8%, var(--task-panel));
29
- --vault-selected: color-mix(in srgb, var(--task-accent) 9%, var(--task-panel));
30
- }
31
- .vault-shell,
32
- .vault-shell * { box-sizing: border-box; }
33
- body.vault-page .task-container {
34
- display: flex;
35
- min-height: 0;
36
- overflow: hidden;
37
- }
38
- .vault-shell {
39
- display: flex;
40
- min-height: 0;
41
- flex: 1 1 auto;
42
- flex-direction: column;
43
- }
44
- .vault-shell .task-shell-body {
45
- display: block;
46
- min-height: 0;
47
- flex: 1 1 auto;
48
- overflow: hidden;
49
- padding: 0;
50
- }
51
- .vault-body {
52
- display: flex;
53
- height: 100%;
54
- min-width: 0;
55
- min-height: 0;
56
- flex-direction: column;
57
- color: var(--task-text);
58
- }
59
- .vault-overview {
60
- display: flex;
61
- align-items: center;
62
- gap: 10px;
63
- padding: 7px var(--vault-inline);
64
- border-bottom: 1px solid var(--task-border);
65
- }
66
- .vault-metrics {
67
- display: flex;
68
- flex: 1;
69
- min-width: 0;
70
- align-items: stretch;
71
- }
72
- .vault-metric {
73
- min-width: 0;
74
- padding: 0 12px;
75
- border: 0;
76
- border-left: 1px solid var(--task-border);
77
- }
78
- .vault-metric:first-child { padding-left: 0; border-left: 0; }
79
- .vault-metric-value {
80
- display: block;
81
- color: var(--task-text);
82
- font-size: 15px;
83
- line-height: 1.15;
84
- font-weight: 650;
85
- letter-spacing: -0.015em;
86
- white-space: nowrap;
87
- }
88
- .vault-metric-label {
89
- display: block;
90
- margin-top: 1px;
91
- color: var(--task-muted);
92
- font-size: 10.5px;
93
- line-height: 1.2;
94
- white-space: nowrap;
95
- }
96
- button.vault-metric {
97
- appearance: none;
98
- background: transparent;
99
- color: inherit;
100
- font: inherit;
101
- text-align: left;
102
- cursor: pointer;
103
- }
104
- button.vault-metric:hover .vault-metric-label,
105
- button.vault-metric:focus-visible .vault-metric-label { color: var(--task-text); }
106
- .vault-overview-actions {
107
- display: flex;
108
- align-items: center;
109
- justify-content: flex-end;
110
- gap: 8px;
111
- flex: 0 0 auto;
112
- }
113
- .vault-button,
114
- .vault-icon-button,
115
- .vault-text-button {
116
- appearance: none;
117
- border: 1px solid var(--task-border-strong);
118
- background: color-mix(in srgb, var(--task-panel) 94%, var(--task-soft));
119
- color: var(--task-text);
120
- font: inherit;
121
- cursor: pointer;
122
- }
123
- .vault-button {
124
- display: inline-flex;
125
- min-height: var(--vault-control-height);
126
- align-items: center;
127
- justify-content: center;
128
- gap: 7px;
129
- padding: 6px 12px;
130
- border-radius: 7px;
131
- font-size: 12px;
132
- line-height: 1;
133
- white-space: nowrap;
134
- }
135
- .vault-button:hover { background: var(--task-soft); }
136
- .vault-button.primary {
137
- border-color: var(--task-accent-surface);
138
- background: var(--task-accent-surface);
139
- color: var(--task-accent-contrast);
140
- }
141
- .vault-button.primary:hover { background: var(--task-accent-hover); }
142
- .vault-button:disabled { opacity: .5; cursor: default; }
143
- .vault-icon-button {
144
- width: var(--vault-control-height);
145
- height: var(--vault-control-height);
146
- display: inline-flex;
147
- align-items: center;
148
- justify-content: center;
149
- padding: 0;
150
- border-radius: 7px;
151
- font-size: 12px;
152
- }
153
- .vault-icon-button:hover { background: var(--task-soft); }
154
- .vault-advanced { position: relative; }
155
- .vault-advanced > summary { list-style: none; cursor: pointer; }
156
- .vault-advanced > summary::-webkit-details-marker { display: none; }
157
- .vault-advanced-menu {
158
- position: absolute;
159
- z-index: 20;
160
- top: calc(100% + 6px);
161
- right: 0;
162
- width: 272px;
163
- box-sizing: border-box;
164
- padding: 12px;
165
- border: 1px solid var(--task-border-strong);
166
- border-radius: 9px;
167
- background: var(--task-panel);
168
- box-shadow: 0 12px 30px color-mix(in srgb, var(--task-text) 10%, transparent);
169
- }
170
- .vault-advanced-title { color: var(--task-text); font-size: 12.5px; font-weight: 650; }
171
- .vault-advanced-copy { margin: 4px 0 10px; color: var(--task-muted); font-size: 11px; line-height: 1.45; }
172
- .vault-button:focus-visible,
173
- .vault-icon-button:focus-visible,
174
- .vault-text-button:focus-visible,
175
- button.vault-metric:focus-visible,
176
- .vault-rail-add:focus-visible,
177
- .vault-nav-row:focus-visible,
178
- .vault-search input:focus-visible,
179
- .vault-select:focus-visible {
180
- outline: 2px solid color-mix(in srgb, var(--task-accent) 55%, transparent);
181
- outline-offset: 2px;
182
- }
183
- .vault-scan-state,
184
- .vault-result,
185
- .vault-feedback {
186
- display: none;
187
- align-items: center;
188
- min-height: 38px;
189
- gap: 9px;
190
- padding: 6px var(--vault-inline);
191
- border-bottom: 1px solid var(--task-border);
192
- font-size: 12.5px;
193
- }
194
- .vault-scan-state {
195
- position: relative;
196
- overflow: hidden;
197
- }
198
- .vault-scan-state.show,
199
- .vault-result.show,
200
- .vault-feedback.show { display: flex; }
201
- .vault-scan-state i,
202
- .vault-result > i { color: var(--task-accent); }
203
- .vault-result strong { font-weight: 650; }
204
- .vault-result-detail,
205
- .vault-scan-detail { color: var(--task-muted); }
206
- .vault-scan-detail {
207
- min-width: 0;
208
- overflow: hidden;
209
- text-overflow: ellipsis;
210
- white-space: nowrap;
211
- }
212
- .vault-scan-percent {
213
- flex: 0 0 auto;
214
- margin-left: auto;
215
- color: var(--task-muted);
216
- font-size: 11.5px;
217
- font-variant-numeric: tabular-nums;
218
- font-weight: 650;
219
- }
220
- .vault-progress-track {
221
- position: absolute;
222
- right: 0;
223
- bottom: 0;
224
- left: 0;
225
- height: 2px;
226
- overflow: hidden;
227
- background: color-mix(in srgb, var(--task-accent) 12%, transparent);
228
- }
229
- .vault-progress-bar {
230
- display: block;
231
- width: 100%;
232
- height: 100%;
233
- background: var(--task-accent);
234
- transform-origin: left center;
235
- }
236
- .vault-progress-bar.determinate {
237
- transform: scaleX(var(--vault-progress, 0));
238
- transition: transform 320ms cubic-bezier(.22, 1, .36, 1);
239
- }
240
- .vault-progress-bar.indeterminate {
241
- transform: scaleX(1);
242
- animation: vault-progress-pulse 1.4s ease-in-out infinite;
243
- }
244
- @keyframes vault-progress-pulse {
245
- 0%, 100% { opacity: .22; }
246
- 50% { opacity: .78; }
247
- }
248
- .vault-result .vault-button { min-height: 28px; margin-left: auto; padding: 4px 10px; }
249
- .vault-feedback { min-height: 34px; background: var(--task-soft); }
250
- .vault-feedback.error { color: var(--vault-warning); }
251
- .vault-explorer {
252
- display: grid;
253
- min-height: 0;
254
- flex: 1 1 auto;
255
- grid-template-columns: 248px minmax(0, 1fr);
256
- overflow: hidden;
257
- }
258
- .vault-rail {
259
- min-width: 0;
260
- min-height: 0;
261
- border-right: 1px solid var(--task-border);
262
- background: color-mix(in srgb, var(--task-panel) 96%, var(--task-soft));
263
- overflow-x: hidden;
264
- overflow-y: auto;
265
- overscroll-behavior: contain;
266
- }
267
- .vault-rail-section {
268
- display: block;
269
- min-height: 0;
270
- padding: 12px 9px;
271
- }
272
- .vault-rail-section + .vault-rail-section { border-top: 1px solid var(--task-border); }
273
- .vault-rail-label {
274
- margin: 0 7px 6px;
275
- color: var(--task-muted);
276
- font-size: 10.5px;
277
- font-weight: 700;
278
- letter-spacing: .08em;
279
- }
280
- .vault-rail-heading {
281
- display: flex;
282
- height: 20px;
283
- align-items: center;
284
- margin: 0 5px 4px 7px;
285
- }
286
- .vault-rail-heading .vault-rail-label { flex: 1 1 auto; margin: 0; }
287
- .vault-rail-add {
288
- width: 20px;
289
- height: 20px;
290
- display: inline-flex;
291
- flex: 0 0 20px;
292
- align-items: center;
293
- justify-content: center;
294
- padding: 0;
295
- border: 0;
296
- border-radius: 5px;
297
- background: transparent;
298
- color: var(--task-muted);
299
- cursor: pointer;
300
- font: inherit;
301
- font-size: 10px;
302
- }
303
- .vault-rail-add:hover { background: var(--task-soft); color: var(--task-text); }
304
- .vault-rail-add:disabled { opacity: .45; cursor: default; }
305
- .vault-nav-list { display: grid; gap: 0; }
306
- .vault-nav-row {
307
- width: 100%;
308
- min-width: 0;
309
- height: var(--vault-tree-row);
310
- min-height: var(--vault-tree-row);
311
- max-height: var(--vault-tree-row);
312
- display: grid;
313
- grid-template-columns: 18px minmax(0, 1fr) auto;
314
- align-items: center;
315
- gap: 6px;
316
- padding: 2px 6px;
317
- border: 1px solid transparent;
318
- border-radius: 7px;
319
- background: transparent;
320
- color: var(--task-text);
321
- text-align: left;
322
- font: inherit;
323
- cursor: pointer;
324
- }
325
- .vault-nav-row:hover { background: var(--task-soft); }
326
- .vault-nav-row.selected {
327
- border-color: color-mix(in srgb, var(--task-accent) 72%, var(--task-border-strong));
328
- background: var(--vault-selected);
329
- }
330
- .vault-nav-row > i { width: 15px; color: var(--task-muted); text-align: center; font-size: 11.5px; }
331
- .vault-nav-copy { min-width: 0; }
332
- .vault-nav-name {
333
- display: block;
334
- overflow: hidden;
335
- font-size: 11.75px;
336
- font-weight: 520;
337
- line-height: 1.25;
338
- text-overflow: ellipsis;
339
- white-space: nowrap;
340
- }
341
- .vault-nav-path {
342
- display: block;
343
- overflow: hidden;
344
- margin-top: 1px;
345
- color: var(--task-muted);
346
- font-size: 10px;
347
- line-height: 1.25;
348
- text-overflow: ellipsis;
349
- white-space: nowrap;
350
- }
351
- .vault-nav-count {
352
- color: var(--task-muted);
353
- font-size: 11px;
354
- font-variant-numeric: tabular-nums;
355
- }
356
- .vault-nav-count.attention { color: var(--vault-warning); font-weight: 650; }
357
- #vault-locations {
358
- display: flex;
359
- min-height: 0;
360
- flex-direction: column;
361
- align-items: stretch;
362
- justify-content: flex-start;
363
- gap: 0;
364
- }
365
- .vault-source-line {
366
- display: grid;
367
- flex: 0 0 auto;
368
- height: var(--vault-tree-row);
369
- min-height: var(--vault-tree-row);
370
- max-height: var(--vault-tree-row);
371
- grid-template-columns: 18px minmax(0, 1fr);
372
- align-items: start;
373
- align-self: stretch;
374
- margin: 0;
375
- }
376
- .vault-source-line.has-path {
377
- height: var(--vault-tree-path-row);
378
- min-height: var(--vault-tree-path-row);
379
- max-height: var(--vault-tree-path-row);
380
- }
381
- .vault-source-line.depth-1 { padding-left: 12px; }
382
- .vault-source-line.depth-2 { padding-left: 24px; }
383
- .vault-source-toggle {
384
- width: 18px;
385
- height: 100%;
386
- padding: 0;
387
- border: 0;
388
- background: transparent;
389
- color: var(--task-muted);
390
- cursor: pointer;
391
- font-size: 10px;
392
- }
393
- .vault-source-toggle.placeholder { cursor: default; }
394
- .vault-source-line .vault-nav-row { grid-template-columns: 18px minmax(0, 1fr) auto; }
395
- .vault-source-line.has-path .vault-nav-row {
396
- height: var(--vault-tree-path-row);
397
- min-height: var(--vault-tree-path-row);
398
- max-height: var(--vault-tree-path-row);
399
- }
400
- .vault-rail-footer { padding: 0 16px 12px; }
401
- .vault-text-button {
402
- padding: 2px 0;
403
- border: 0;
404
- background: transparent;
405
- color: var(--task-muted);
406
- font-size: 11.5px;
407
- }
408
- .vault-text-button:hover { color: var(--task-text); }
409
- .vault-pane {
410
- display: grid;
411
- min-width: 0;
412
- min-height: 0;
413
- grid-template-rows: auto minmax(0, 1fr) auto;
414
- overflow: hidden;
415
- }
416
- .vault-pane-action-note { color: var(--task-muted); font-size: 11.5px; white-space: nowrap; }
417
- .vault-toolbar {
418
- display: flex;
419
- align-items: center;
420
- gap: 8px;
421
- min-height: 44px;
422
- padding: 6px var(--vault-inline);
423
- border-bottom: 1px solid var(--task-border);
424
- }
425
- .vault-search { position: relative; width: min(320px, 55%); }
426
- .vault-search i {
427
- position: absolute;
428
- top: 50%;
429
- left: 10px;
430
- color: var(--task-muted);
431
- font-size: 12px;
432
- transform: translateY(-50%);
433
- pointer-events: none;
434
- }
435
- .vault-search input,
436
- .vault-select {
437
- height: 30px;
438
- box-sizing: border-box;
439
- border: 1px solid var(--task-border-strong);
440
- border-radius: 7px;
441
- background: var(--task-panel);
442
- color: var(--task-text);
443
- font: inherit;
444
- font-size: 12px;
445
- }
446
- .vault-search input { width: 100%; padding: 5px 10px 5px 30px; }
447
- .vault-search input::placeholder { color: var(--task-muted); }
448
- .vault-select { padding: 4px 28px 4px 9px; }
449
- .vault-toolbar-count { margin-left: auto; color: var(--task-muted); font-size: 11.5px; white-space: nowrap; }
450
- .vault-table { min-width: 660px; }
451
- .vault-table-wrap {
452
- min-height: 0;
453
- overflow: auto;
454
- overscroll-behavior: contain;
455
- }
456
- .vault-columns,
457
- .vault-file-row,
458
- .vault-group-row {
459
- display: grid;
460
- grid-template-columns: minmax(220px, 1.6fr) minmax(64px, .4fr) minmax(118px, .8fr) minmax(96px, .6fr) minmax(76px, auto);
461
- align-items: center;
462
- column-gap: 10px;
463
- }
464
- .vault-table.matches .vault-columns,
465
- .vault-table.matches .vault-file-row,
466
- .vault-table.matches .vault-group-row {
467
- grid-template-columns: minmax(210px, 1.5fr) minmax(64px, .4fr) minmax(140px, 1fr) minmax(96px, .55fr) minmax(76px, auto);
468
- }
469
- .vault-columns {
470
- min-height: 34px;
471
- padding: 0 var(--vault-inline);
472
- border-bottom: 1px solid var(--task-border);
473
- color: var(--task-muted);
474
- font-size: 10.5px;
475
- font-weight: 600;
476
- }
477
- .vault-file-row {
478
- min-height: var(--vault-row-height);
479
- padding: 6px var(--vault-inline);
480
- border-bottom: 1px solid var(--task-border);
481
- font-size: 12px;
482
- }
483
- .vault-file-row:hover { background: color-mix(in srgb, var(--task-soft) 64%, transparent); }
484
- .vault-file-row.directory { min-height: 38px; padding-top: 4px; padding-bottom: 4px; background: color-mix(in srgb, var(--task-soft) 38%, transparent); }
485
- .vault-group-row {
486
- min-height: 48px;
487
- padding: 6px var(--vault-inline);
488
- border-bottom: 1px solid var(--task-border-strong);
489
- background: color-mix(in srgb, var(--task-soft) 56%, transparent);
490
- }
491
- .vault-group-main { min-width: 0; }
492
- .vault-group-title {
493
- display: flex;
494
- min-width: 0;
495
- align-items: center;
496
- gap: 8px;
497
- color: var(--task-text);
498
- font-size: 12.5px;
499
- font-weight: 600;
500
- }
501
- .vault-group-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
502
- .vault-group-meta { margin: 2px 0 0 23px; color: var(--task-muted); font-size: 10px; }
503
- .vault-group-action { grid-column: 5; justify-self: end; }
504
- .vault-group-action .vault-button { min-height: 28px; padding: 4px 9px; }
505
- .vault-name-cell { display: flex; min-width: 0; align-items: center; gap: 8px; }
506
- .vault-name-cell.indent-1 { padding-left: 18px; }
507
- .vault-name-cell.indent-2 { padding-left: 36px; }
508
- .vault-name-icon { width: 16px; flex: 0 0 16px; color: var(--task-muted); text-align: center; }
509
- .vault-disclosure {
510
- width: 16px;
511
- height: 24px;
512
- flex: 0 0 16px;
513
- padding: 0;
514
- border: 0;
515
- background: transparent;
516
- color: var(--task-muted);
517
- cursor: pointer;
518
- font-size: 10px;
519
- }
520
- .vault-name-copy { min-width: 0; }
521
- .vault-file-name {
522
- display: block;
523
- overflow: hidden;
524
- color: var(--task-text);
525
- font-size: 12px;
526
- font-weight: 520;
527
- line-height: 1.3;
528
- text-overflow: ellipsis;
529
- white-space: nowrap;
530
- }
531
- .vault-file-path {
532
- display: block;
533
- overflow: hidden;
534
- color: var(--task-muted);
535
- font-size: 10.5px;
536
- line-height: 1.3;
537
- text-overflow: ellipsis;
538
- white-space: nowrap;
539
- }
540
- .vault-match-path {
541
- display: block;
542
- min-width: 0;
543
- color: var(--task-muted);
544
- font-size: 10.5px;
545
- line-height: 1.35;
546
- overflow-wrap: anywhere;
547
- white-space: normal;
548
- word-break: break-word;
549
- }
550
- .vault-size,
551
- .vault-space { color: var(--task-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
552
- .vault-status { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--task-text); }
553
- .vault-status-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--task-muted); }
554
- .vault-status-dot.warning { background: var(--vault-warning); }
555
- .vault-status i { width: 12px; color: var(--task-muted); text-align: center; }
556
- .vault-row-action { justify-self: end; }
557
- .vault-row-action .vault-text-button { color: var(--task-text); font-size: 11.5px; }
558
- .vault-detail {
559
- padding: 8px var(--vault-inline) 9px 56px;
560
- border-bottom: 1px solid var(--task-border);
561
- background: color-mix(in srgb, var(--task-soft) 48%, transparent);
562
- }
563
- .vault-detail-label { margin-bottom: 5px; color: var(--task-muted); font-size: 10px; font-weight: 650; }
564
- .vault-location-detail { display: flex; align-items: center; gap: 7px; padding: 2px 0; color: var(--task-text); font-size: 10.5px; }
565
- .vault-location-detail i { color: var(--task-muted); }
566
- .vault-empty {
567
- display: grid;
568
- min-height: 190px;
569
- place-items: center;
570
- padding: 28px;
571
- text-align: center;
572
- }
573
- .vault-empty-inner { max-width: 420px; }
574
- .vault-empty i { color: var(--task-muted); font-size: 18px; }
575
- .vault-empty h3 { margin: 9px 0 4px; font-size: 14px; }
576
- .vault-empty p { margin: 0; color: var(--task-muted); font-size: 12px; line-height: 1.55; }
577
- .vault-empty .vault-button { margin-top: 10px; }
578
- .vault-pane-footer {
579
- padding: 9px var(--vault-inline) 11px;
580
- color: var(--task-muted);
581
- font-size: 10.5px;
582
- }
583
- .vault-event-kind { font-weight: 570; }
584
- .vault-event-time { color: var(--task-muted); font-size: 10.5px; }
585
- .vault-unavailable { color: var(--task-muted); font-size: 11px; }
586
- @media (max-width: 1140px) {
587
- .vault-overview { align-items: flex-start; flex-direction: column; }
588
- .vault-overview-actions { width: 100%; justify-content: flex-start; }
589
- .vault-metric { padding-right: 12px; padding-left: 12px; }
590
- .vault-explorer { grid-template-columns: 224px minmax(0, 1fr); }
591
- }
592
- @media (max-width: 820px) {
593
- body.vault-page .task-container { display: block; overflow-y: auto; }
594
- .vault-shell,
595
- .vault-shell .task-shell-body,
596
- .vault-body { height: auto; overflow: visible; }
597
- .vault-metrics { width: 100%; overflow-x: auto; }
598
- .vault-explorer { display: block; overflow: visible; }
599
- .vault-rail { max-height: 330px; border-right: 0; border-bottom: 1px solid var(--task-border); }
600
- .vault-pane { display: block; overflow: visible; }
601
- .vault-table-wrap { overflow-x: auto; overflow-y: visible; }
602
- }
603
- @media (pointer: coarse) {
604
- .vault-button,
605
- .vault-result .vault-button,
606
- .vault-group-action .vault-button { min-height: 40px; }
607
- .vault-icon-button { width: 40px; height: 40px; }
608
- .vault-rail-add { width: 28px; height: 28px; flex-basis: 28px; }
609
- }
610
- @media (prefers-reduced-motion: reduce) {
611
- .vault-page * { scroll-behavior: auto !important; }
612
- .vault-progress-bar { transition: none; }
613
- .vault-progress-bar.indeterminate {
614
- animation: none;
615
- opacity: .5;
616
- transform: scaleX(1);
617
- }
618
- }
619
- </style>
620
- </head>
621
- <body class='<%=theme%> main-sidebar-page task-launcher-page task-page vault-page' data-agent="<%=agent%>">
622
- <%- include('partials/app_navheader', { agent }) %>
623
- <main>
624
- <div class='task-container'>
625
- <section class='task-shell vault-shell'>
626
- <div class='task-shell-body'>
627
- <div class='vault-body'>
628
- <section class='vault-overview'>
629
- <div class='vault-metrics' id='vault-metrics'></div>
630
- <div class='vault-overview-actions'>
631
- <button class='vault-button primary' id='btn-scan' type='button'></button>
632
- <details class='vault-advanced' id='vault-advanced'>
633
- <summary class='vault-icon-button' id='btn-vault-options'><i class='fa-solid fa-ellipsis'></i></summary>
634
- <div class='vault-advanced-menu'>
635
- <div class='vault-advanced-title' id='vault-repair-title'></div>
636
- <p class='vault-advanced-copy' id='vault-repair-description'></p>
637
- <button class='vault-button' id='btn-repair' type='button'></button>
638
- </div>
639
- </details>
640
- </div>
641
- </section>
642
- <div class='vault-scan-state' id='vault-scan-state'></div>
643
- <div class='vault-result' id='vault-result'></div>
644
- <div class='vault-feedback' id='vault-feedback'></div>
645
- <section class='vault-explorer' id='vault-explorer'>
646
- <aside class='vault-rail'>
647
- <div class='vault-rail-section'>
648
- <div class='vault-rail-label' id='views-label'></div>
649
- <div class='vault-nav-list' id='vault-views'></div>
650
- </div>
651
- <div class='vault-rail-section'>
652
- <div class='vault-rail-heading'>
653
- <div class='vault-rail-label' id='locations-label'></div>
654
- <button class='vault-rail-add' id='btn-add-source' type='button' aria-label='Add external folder' title='Add external folder'><i class='fa-solid fa-plus'></i></button>
655
- </div>
656
- <div id='vault-locations'></div>
657
- </div>
658
- <div class='vault-rail-footer' id='vault-rail-footer'></div>
659
- </aside>
660
- <section class='vault-pane' aria-label='Vault files'>
661
- <div class='vault-toolbar' id='vault-toolbar'></div>
662
- <div class='vault-table-wrap' id='vault-table-wrap'></div>
663
- <footer class='vault-pane-footer' id='vault-pane-footer'></footer>
664
- </section>
665
- </section>
666
- </div>
667
- </div>
668
- </section>
669
- </div>
670
- <%- include('partials/main_sidebar', { selected: 'vault' }) %>
671
- </main>
672
- <script src="/Socket.js"></script>
673
- <script>
674
- const COPY = {
675
- title: "Vault",
676
- subtitle: "One copy on disk, shared by every app that uses it. Nothing is scanned or changed unless you ask.",
677
- views: "VIEWS",
678
- locations: "LOCATIONS",
679
- all: "All tracked files",
680
- duplicates: "Duplicates",
681
- shared: "Shared",
682
- independent: "Independent",
683
- reclaimable: "Reclaimable",
684
- activity: "Activity",
685
- pinokio: "Pinokio",
686
- apps: "Apps",
687
- external: "External folders",
688
- add_external_folder: "Add external folder",
689
- external_added: "Added to Locations. Run a scan when you’re ready.",
690
- external_exists: "That folder is already in Locations.",
691
- external_other_disk: "Added to Locations. It can be scanned, but this disk cannot share space with the Vault.",
692
- on_disk: "On disk",
693
- saved: "Saved",
694
- already_shared: "Already shared",
695
- already_shared_help: "Space already avoided by files sharing the same data",
696
- saved_by_vault: "Saved by Vault",
697
- saved_by_vault_help: "Space saved through your Deduplicate actions",
698
- can_save: "Can save",
699
- pinokio_folder: "Pinokio folder",
700
- last_scanned: "Last scanned",
701
- never: "Never",
702
- scan: "Scan now",
703
- scan_again: "Scan again",
704
- scanning: "Scanning…",
705
- vault_options: "Vault options",
706
- repair_index: "Repair Vault index",
707
- repair_action: "Repair index",
708
- repair_description: "Reconstruct Vault’s internal records if tracked files or sharing status look incorrect. This does not scan for new duplicates.",
709
- repairing: "Repairing…",
710
- repair_done: "Vault index repaired",
711
- scan_progress: "Scanning your configured locations",
712
- scan_analyzing: "Analyzing large files",
713
- scan_finishing: "Finishing scan",
714
- scan_estimate_help: "Estimated from your last completed scan",
715
- scan_folders: "folders checked",
716
- scan_files: "files checked",
717
- analyzing: "analyzing",
718
- waiting: "files waiting",
719
- scan_complete: "Scan complete",
720
- found_in: "found in",
721
- locations_lower: "locations",
722
- can_be_saved: "can be saved",
723
- review: "Review",
724
- search_all: "Search tracked files",
725
- search_duplicates: "Search duplicates",
726
- search_shared: "Search shared files",
727
- search_independent: "Search independent files",
728
- search_activity: "Search activity",
729
- all_statuses: "All statuses",
730
- by_location: "By location",
731
- name: "Name",
732
- size: "Size",
733
- status: "Vault status",
734
- matches: "Matches",
735
- space: "Space",
736
- duplicate: "Duplicate",
737
- tracked: "Tracked",
738
- different_disk: "Different disk",
739
- saved_suffix: "saved",
740
- can_save_suffix: "can save",
741
- unavailable: "Unavailable",
742
- sharing_unavailable: "Sharing is unavailable on this disk",
743
- without_sharing: "without sharing",
744
- saved_all_time: "saved all-time",
745
- just_now: "Just now",
746
- minutes_ago: "m ago",
747
- hours_ago: "h ago",
748
- event: "event",
749
- events: "events",
750
- unknown_location: "Unknown location",
751
- expand: "Expand",
752
- collapse: "Collapse",
753
- file: "file",
754
- files: "files",
755
- location: "location",
756
- deduplicate: "Deduplicate",
757
- ignore: "Ignore",
758
- make_independent: "Make independent",
759
- allow_sharing: "Allow sharing again",
760
- reclaim: "Reclaim",
761
- reclaim_all: "Reclaim all",
762
- undo: "Undo",
763
- identical_contents_at: "Identical contents at",
764
- no_files: "Nothing tracked yet",
765
- no_files_hint: "Run a scan to find large files. Scanning never changes them.",
766
- scan_waiting: "Waiting for scan results",
767
- scan_waiting_hint: "Tracked files will appear here when this scan finishes.",
768
- no_duplicates: "Everything is already shared",
769
- no_duplicates_hint: "There are no files waiting for your review.",
770
- no_shared: "Nothing is shared yet",
771
- no_shared_hint: "Shared files will appear here after you review duplicates.",
772
- no_independent: "No independent files",
773
- no_independent_hint: "Files you choose to keep separate will appear here.",
774
- no_reclaimable: "Nothing to reclaim",
775
- no_reclaimable_hint: "Files no longer used by any configured location will appear here.",
776
- no_activity: "No activity yet",
777
- no_activity_hint: "Scans and actions will be recorded here.",
778
- view_all: "View all tracked files",
779
- show_all_locations: "Show all locations",
780
- tracked_note: "Only tracked files 100 MB and larger appear here. Files keep their current locations.",
781
- duplicate_note: "Only files waiting for review are shown.",
782
- disabled: "The vault is turned off (PINOKIO_VAULT=false).",
783
- done: "Done",
784
- converted: "Deduplicated",
785
- ignored: "Kept independent",
786
- reclaimed: "Reclaimed",
787
- allowed: "Sharing allowed again",
788
- event_convert: "Deduplicated",
789
- event_found: "Duplicate found",
790
- event_adopt: "Added to your vault",
791
- event_reclaim: "Reclaimed",
792
- event_undo: "Undid deduplication",
793
- event_diverged: "Changed by an app — no longer shared",
794
- event_detach: "Made independent",
795
- event_reshare: "Sharing allowed again"
796
- }
797
-
798
- const state = {
799
- data: null,
800
- view: "all",
801
- sourceId: null,
802
- query: "",
803
- statusFilter: "all",
804
- collapsedSources: new Set(),
805
- collapsedDirs: new Set(),
806
- expandedFiles: new Set(),
807
- scanRequested: false,
808
- scanBaseline: null,
809
- scanResult: null,
810
- feedback: null
811
- }
812
-
813
- const el = (id) => document.getElementById(id)
814
- const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (char) => ({
815
- "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
816
- }[char]))
817
- const attr = esc
818
- const fmt = (value) => {
819
- const n = Number(value) || 0
820
- if (!n) return "0 B"
821
- const units = ["B", "KB", "MB", "GB", "TB"]
822
- const index = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)))
823
- const digits = index > 1 ? 1 : 0
824
- return `${(n / Math.pow(1024, index)).toFixed(digits)} ${units[index]}`
825
- }
826
- const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
827
- const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
828
- const dirname = (value) => {
829
- const parts = String(value || "").split("/").filter(Boolean)
830
- parts.pop()
831
- return parts.join(" / ")
832
- }
833
- const timeAgo = (ts) => {
834
- if (!ts) return COPY.never
835
- const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
836
- if (seconds < 60) return COPY.just_now
837
- const minutes = Math.floor(seconds / 60)
838
- if (minutes < 60) return `${minutes}${COPY.minutes_ago}`
839
- const hours = Math.floor(minutes / 60)
840
- if (hours < 24) return `${hours}${COPY.hours_ago}`
841
- return new Date(ts).toLocaleDateString()
842
- }
843
- const post = async (payload) => {
844
- const response = await fetch("/vault/action", {
845
- method: "POST",
846
- headers: { "Content-Type": "application/json" },
847
- body: JSON.stringify(payload)
848
- })
849
- return response.json()
850
- }
851
- const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
852
- const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
853
- const sourcePath = (source) => {
854
- if (!source) return ""
855
- if (source.kind === "pinokio") return `~/${basename(source.root)}`
856
- if (source.kind === "external") return source.target_path || source.display_path || ""
857
- if (source.kind === "app") return ""
858
- return source.display_path || source.root || ""
859
- }
860
- const isDescendantSource = (candidateId, parentId) => {
861
- if (!parentId) return true
862
- if (candidateId === parentId) return true
863
- let current = sourceById(candidateId)
864
- const seen = new Set()
865
- while (current && current.parent_id && !seen.has(current.id)) {
866
- if (current.parent_id === parentId) return true
867
- seen.add(current.id)
868
- current = sourceById(current.parent_id)
869
- }
870
- return false
871
- }
872
-
873
- const buildItems = () => {
874
- const data = state.data
875
- const items = []
876
- const duplicatePaths = new Set(data.duplicates.map((item) => item.path))
877
- const excludedPaths = new Set(data.excluded.map((item) => item.path))
878
- for (const blob of data.blobs) {
879
- const linkedNames = blob.names.filter((name) => name.mode === "link")
880
- for (const name of blob.names) {
881
- if (duplicatePaths.has(name.path) || excludedPaths.has(name.path)) continue
882
- const shared = name.mode === "link" && linkedNames.length > 1
883
- items.push({
884
- kind: "file", path: name.path, relative_path: name.relative_path || basename(name.path),
885
- source_id: name.source_id, source_kind: name.source_kind, source_label: name.source_label,
886
- size: blob.size || 0, hash: blob.hash, blob, status: shared ? "shared" : "tracked",
887
- locations: blob.names, saved: shared ? (blob.size || 0) * Math.max(1, linkedNames.length - 1) : 0
888
- })
889
- }
890
- }
891
- for (const duplicate of data.duplicates) {
892
- items.push({
893
- kind: "file", path: duplicate.path, relative_path: duplicate.relative_path || basename(duplicate.path),
894
- source_id: duplicate.source_id, source_kind: duplicate.source_kind, source_label: duplicate.source_label,
895
- size: duplicate.size || 0, hash: duplicate.hash, status: "duplicate",
896
- shareable: duplicate.shareable !== false, matches: duplicate.matches || [], saved: 0
897
- })
898
- }
899
- for (const independent of data.excluded) {
900
- items.push({
901
- kind: "file", path: independent.path, relative_path: independent.relative_path || basename(independent.path),
902
- source_id: independent.source_id, source_kind: independent.source_kind, source_label: independent.source_label,
903
- size: independent.size || 0, status: "independent", saved: 0
904
- })
905
- }
906
- return items
907
- }
908
-
909
- const getCounts = (items) => ({
910
- all: items.length,
911
- duplicates: items.filter((item) => item.status === "duplicate").length,
912
- shared: items.filter((item) => item.status === "shared").length,
913
- independent: items.filter((item) => item.status === "independent").length,
914
- reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
915
- activity: state.data.events.length
916
- })
917
-
918
- const sourceDuplicateCount = (id) => state.data.duplicates.filter((item) => isDescendantSource(item.source_id, id)).length
919
- const sourceTrackedCount = (id, items) => items.filter((item) => isDescendantSource(item.source_id, id)).length
920
- const activeItems = (items) => {
921
- let result = items
922
- if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
923
- if (state.view === "shared") result = result.filter((item) => item.status === "shared")
924
- if (state.view === "independent") result = result.filter((item) => item.status === "independent")
925
- if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
926
- if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
927
- const query = state.query.trim().toLowerCase()
928
- if (query) result = result.filter((item) => `${item.relative_path} ${item.path} ${item.source_label || ""}`.toLowerCase().includes(query))
929
- return result
930
- }
931
-
932
- const viewIcon = {
933
- all: "fa-regular fa-file-lines",
934
- duplicates: "fa-regular fa-copy",
935
- shared: "fa-solid fa-link",
936
- independent: "fa-solid fa-code-branch",
937
- reclaimable: "fa-regular fa-trash-can",
938
- activity: "fa-solid fa-wave-square"
939
- }
940
- const viewLabel = {
941
- all: COPY.all,
942
- duplicates: COPY.duplicates,
943
- shared: COPY.shared,
944
- independent: COPY.independent,
945
- reclaimable: COPY.reclaimable,
946
- activity: COPY.activity
947
- }
948
-
949
- const renderViews = (items) => {
950
- const counts = getCounts(items)
951
- el("views-label").textContent = COPY.views
952
- const views = ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
953
- el("vault-views").innerHTML = views.map((view) => `
954
- <button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}">
955
- <i class="${viewIcon[view]}"></i>
956
- <span class="vault-nav-copy"><span class="vault-nav-name">${esc(viewLabel[view])}</span></span>
957
- <span class="vault-nav-count ${view === "duplicates" && counts[view] ? "attention" : ""}">${counts[view]}</span>
958
- </button>`).join("")
959
- }
960
-
961
- const renderSourceNode = (source, depth, items) => {
962
- const children = sourceChildren(source.id).filter((child) => {
963
- const count = state.view === "duplicates" ? sourceDuplicateCount(child.id) : sourceTrackedCount(child.id, items)
964
- return child.kind === "virtual" || count > 0 || (child.kind === "external" && child.available)
965
- })
966
- const duplicateCount = sourceDuplicateCount(source.id)
967
- const trackedCount = sourceTrackedCount(source.id, items)
968
- if (state.view === "duplicates" && duplicateCount === 0) return ""
969
- const collapsed = state.collapsedSources.has(source.id)
970
- const pathText = source.kind === "virtual" ? (source.id === "apps" ? "api" : "") : sourcePath(source)
971
- const count = state.view === "duplicates" ? duplicateCount : trackedCount
972
- const icon = source.kind === "app" ? "fa-regular fa-folder-open" : "fa-regular fa-folder"
973
- let html = `<div class="vault-source-line depth-${Math.min(depth, 2)} ${pathText ? "has-path" : ""}">`
974
- if (children.length) {
975
- html += `<button class="vault-source-toggle" type="button" data-toggle-source="${attr(source.id)}" aria-label="${collapsed ? COPY.expand : COPY.collapse}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button>`
976
- } else {
977
- html += `<span class="vault-source-toggle placeholder"></span>`
978
- }
979
- html += `<button class="vault-nav-row ${state.sourceId === source.id ? "selected" : ""}" type="button" data-source="${attr(source.id)}">
980
- <i class="${icon}"></i>
981
- <span class="vault-nav-copy"><span class="vault-nav-name">${esc(source.label)}</span>${pathText ? `<span class="vault-nav-path" title="${attr(pathText)}">${esc(pathText)}</span>` : ""}</span>
982
- <span class="vault-nav-count ${duplicateCount ? "attention" : ""}">${count || ""}</span>
983
- </button></div>`
984
- if (!collapsed) html += children.map((child) => renderSourceNode(child, depth + 1, items)).join("")
985
- return html
986
- }
987
-
988
- const renderLocations = (items) => {
989
- el("locations-label").textContent = COPY.locations
990
- const pinokio = sourceById("pinokio")
991
- const external = sourceById("external")
992
- let html = pinokio ? renderSourceNode(pinokio, 0, items) : ""
993
- if (external && sourceChildren("external").length) html += renderSourceNode(external, 0, items)
994
- el("vault-locations").innerHTML = html
995
- el("vault-rail-footer").innerHTML = state.view === "duplicates"
996
- ? `<button class="vault-text-button" type="button" data-view="all">${esc(COPY.show_all_locations)}</button>`
997
- : ""
998
- }
999
-
1000
- const selectedSource = () => state.sourceId ? sourceById(state.sourceId) : null
1001
- const scopeDuplicates = (sourceId) => state.data.duplicates.filter((item) => item.source_id === sourceId)
1002
- const batchAction = (source) => {
1003
- if (!source || source.kind === "virtual" || source.kind === "pinokio") return ""
1004
- const duplicates = scopeDuplicates(source.id)
1005
- if (!duplicates.length) return ""
1006
- const shareable = duplicates.filter((item) => item.shareable !== false)
1007
- if (!source.shareable || !shareable.length) return `<div class="vault-pane-action-note">${esc(COPY.sharing_unavailable)}</div>`
1008
- return `<button class="vault-button" type="button" data-deduplicate-scope="${attr(source.id)}">${esc(COPY.deduplicate)} ${countLabel(shareable.length)}</button>`
1009
- }
1010
-
1011
- const toolbarSummary = (visibleItems) => {
1012
- if (state.view === "duplicates") {
1013
- const locations = new Set(visibleItems.map((item) => item.source_id).filter(Boolean)).size
1014
- const bytes = visibleItems.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
1015
- return `${countLabel(visibleItems.length)} · ${countLabel(locations, COPY.location, COPY.locations_lower)} · ${fmt(bytes)} ${COPY.can_save_suffix}`
1016
- } else if (state.view === "activity") {
1017
- return countLabel(visibleItems.length, COPY.event, COPY.events)
1018
- } else if (state.view === "reclaimable") {
1019
- return `${countLabel(visibleItems.length)} · ${fmt(state.data.reclaimable)}`
1020
- }
1021
- const duplicateCount = visibleItems.filter((item) => item.status === "duplicate").length
1022
- const saveable = visibleItems.filter((item) => item.status === "duplicate" && item.shareable).reduce((sum, item) => sum + item.size, 0)
1023
- return `${countLabel(visibleItems.length)}${duplicateCount ? ` · ${countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${fmt(saveable)} ${COPY.can_save_suffix}` : ""}`
1024
- }
1025
-
1026
- const updateToolbarSummary = (visibleItems) => {
1027
- const summary = el("vault-toolbar-summary")
1028
- if (summary) summary.textContent = toolbarSummary(visibleItems)
1029
- }
1030
-
1031
- const searchPlaceholder = () => {
1032
- if (state.view === "duplicates") return COPY.search_duplicates
1033
- if (state.view === "shared") return COPY.search_shared
1034
- if (state.view === "independent") return COPY.search_independent
1035
- if (state.view === "activity") return COPY.search_activity
1036
- const source = selectedSource()
1037
- return source && source.kind === "app" ? `Search in ${source.label}` : COPY.search_all
1038
- }
1039
- const renderToolbar = (visibleItems) => {
1040
- if (state.view === "reclaimable") {
1041
- const count = state.data.blobs.filter((blob) => blob.orphan).length
1042
- el("vault-toolbar").innerHTML = count
1043
- ? `<span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span><button class="vault-button" type="button" id="btn-reclaim-all">${esc(COPY.reclaim_all)}</button>`
1044
- : ""
1045
- return
1046
- }
1047
- const source = selectedSource()
1048
- el("vault-toolbar").innerHTML = `<label class="vault-search"><i class="fa-solid fa-magnifying-glass"></i><input id="vault-search" value="${attr(state.query)}" placeholder="${attr(searchPlaceholder())}" /></label>
1049
- ${state.view === "all" ? `<select class="vault-select" id="vault-status-filter" aria-label="${attr(COPY.all_statuses)}">
1050
- <option value="all" ${state.statusFilter === "all" ? "selected" : ""}>${esc(COPY.all_statuses)}</option>
1051
- <option value="duplicate" ${state.statusFilter === "duplicate" ? "selected" : ""}>${esc(COPY.duplicates)}</option>
1052
- <option value="shared" ${state.statusFilter === "shared" ? "selected" : ""}>${esc(COPY.shared)}</option>
1053
- <option value="tracked" ${state.statusFilter === "tracked" ? "selected" : ""}>${esc(COPY.tracked)}</option>
1054
- <option value="independent" ${state.statusFilter === "independent" ? "selected" : ""}>${esc(COPY.independent)}</option>
1055
- </select>` : state.view === "duplicates" ? `<span class="vault-select">${esc(COPY.by_location)}</span>` : ""}
1056
- <span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
1057
- ${state.view === "all" ? batchAction(source) : ""}`
1058
- }
1059
-
1060
- const statusMarkup = (item) => {
1061
- if (item.status === "duplicate") {
1062
- return item.shareable
1063
- ? `<span class="vault-status"><span class="vault-status-dot warning"></span>${esc(COPY.duplicate)}</span>`
1064
- : `<span class="vault-status"><i class="fa-regular fa-circle-xmark"></i>${esc(COPY.different_disk)}</span>`
1065
- }
1066
- if (item.status === "shared") return `<span class="vault-status"><i class="fa-solid fa-link"></i>${esc(COPY.shared)} · ${item.locations.length} ${esc(COPY.locations_lower)}</span>`
1067
- if (item.status === "independent") return `<span class="vault-status"><i class="fa-solid fa-code-branch"></i>${esc(COPY.independent)}</span>`
1068
- return `<span class="vault-status"><span class="vault-status-dot"></span>${esc(COPY.tracked)}</span>`
1069
- }
1070
- const spaceMarkup = (item) => {
1071
- if (item.status === "duplicate") return item.shareable ? `${fmt(item.size)} ${COPY.can_save_suffix}` : COPY.unavailable
1072
- if (item.status === "shared") return `${fmt(item.saved)} ${COPY.saved_suffix}`
1073
- return "—"
1074
- }
1075
- const rowAction = (item) => {
1076
- if (item.status === "duplicate") return `<button class="vault-text-button" type="button" data-detach="${attr(item.path)}">${esc(COPY.ignore)}</button>`
1077
- if (item.status === "independent") return `<button class="vault-text-button" type="button" data-reshare="${attr(item.path)}">${esc(COPY.allow_sharing)}</button>`
1078
- return `<button class="vault-text-button" type="button" data-detach="${attr(item.path)}">${esc(COPY.make_independent)}</button>`
1079
- }
1080
-
1081
- const fileDetail = (item) => {
1082
- if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
1083
- return `<div class="vault-detail"><div class="vault-detail-label">${esc(COPY.identical_contents_at)} ${countLabel(item.locations.length, COPY.location, COPY.locations_lower)}</div>${item.locations.map((location) => `
1084
- <div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(location.source_label || "")}${location.relative_path ? ` / ${esc(location.relative_path)}` : ""}</span></div>`).join("")}</div>`
1085
- }
1086
-
1087
- const renderFileRow = (item, depth = 0, showMatch = false) => {
1088
- const directoryPath = dirname(item.relative_path)
1089
- const match = (item.matches || []).find((location) => location.path !== item.path)
1090
- const expandable = item.locations && item.locations.length > 1
1091
- return `<div class="vault-file-row">
1092
- <div class="vault-name-cell indent-${Math.min(depth, 2)}">
1093
- ${expandable ? `<button class="vault-disclosure" type="button" data-expand-file="${attr(item.path)}"><i class="fa-solid fa-chevron-${state.expandedFiles.has(item.path) ? "down" : "right"}"></i></button>` : `<span class="vault-disclosure"></span>`}
1094
- <i class="fa-regular fa-file vault-name-icon"></i>
1095
- <span class="vault-name-copy"><span class="vault-file-name">${esc(basename(item.relative_path))}</span>${directoryPath && depth === 0 ? `<span class="vault-file-path">${esc(directoryPath)}</span>` : ""}</span>
1096
- </div>
1097
- <span class="vault-size">${item.size ? fmt(item.size) : "—"}</span>
1098
- <span>${showMatch ? (match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—") : statusMarkup(item)}</span>
1099
- <span class="vault-space">${esc(spaceMarkup(item))}</span>
1100
- <span class="vault-row-action">${rowAction(item)}</span>
1101
- </div>${fileDetail(item)}`
1102
- }
1103
-
1104
- const makeTree = (items) => {
1105
- const root = { dirs: new Map(), files: [] }
1106
- for (const item of items) {
1107
- const parts = String(item.relative_path || basename(item.path)).split("/").filter(Boolean)
1108
- const fileName = parts.pop() || basename(item.path)
1109
- item._treeName = fileName
1110
- let node = root
1111
- for (const part of parts) {
1112
- if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] })
1113
- node = node.dirs.get(part)
1114
- }
1115
- node.files.push(item)
1116
- }
1117
- return root
1118
- }
1119
- const nodeItems = (node) => [...node.files, ...[...node.dirs.values()].flatMap((child) => nodeItems(child))]
1120
- const renderTreeNode = (node, prefix, depth) => {
1121
- let current = node
1122
- const labels = [node.name]
1123
- while (current.files.length === 0 && current.dirs.size === 1) {
1124
- current = [...current.dirs.values()][0]
1125
- labels.push(current.name)
1126
- }
1127
- const key = [...prefix, ...labels].join("/")
1128
- const collapsed = state.collapsedDirs.has(key)
1129
- const items = nodeItems(current)
1130
- const duplicateCount = items.filter((item) => item.status === "duplicate").length
1131
- const shareableBytes = items.filter((item) => item.status === "duplicate" && item.shareable).reduce((sum, item) => sum + item.size, 0)
1132
- const shared = items.filter((item) => item.status === "shared").length
1133
- const summary = duplicateCount ? countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()) : shared ? COPY.shared : COPY.tracked
1134
- let html = `<div class="vault-file-row directory">
1135
- <div class="vault-name-cell indent-${Math.min(depth, 2)}"><button class="vault-disclosure" type="button" data-toggle-dir="${attr(key)}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button><i class="fa-regular fa-folder vault-name-icon"></i><span class="vault-file-name">${esc(labels.join(" / "))}</span></div>
1136
- <span class="vault-size">${countLabel(items.length)}</span><span>${esc(summary)}</span><span class="vault-space">${shareableBytes ? `${fmt(shareableBytes)} ${COPY.can_save_suffix}` : "—"}</span><span></span>
1137
- </div>`
1138
- if (!collapsed) {
1139
- html += current.files.sort((a, b) => a._treeName.localeCompare(b._treeName)).map((item) => renderFileRow(item, depth + 1)).join("")
1140
- html += [...current.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)).map((child) => renderTreeNode(child, [...prefix, ...labels], depth + 1)).join("")
1141
- }
1142
- return html
1143
- }
1144
- const renderTree = (items) => {
1145
- const tree = makeTree(items)
1146
- return tree.files.map((item) => renderFileRow(item)).join("") + [...tree.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)).map((node) => renderTreeNode(node, [], 0)).join("")
1147
- }
1148
-
1149
- const groupTitle = (source) => {
1150
- if (!source) return COPY.unknown_location
1151
- const parts = [source.label]
1152
- let current = source
1153
- while (current && current.parent_id) {
1154
- current = sourceById(current.parent_id)
1155
- if (current) parts.unshift(current.label)
1156
- }
1157
- return parts.join(" / ")
1158
- }
1159
- const sourceSort = (a, b) => {
1160
- const first = sourceById(a[0])
1161
- const second = sourceById(b[0])
1162
- const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
1163
- return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
1164
- }
1165
- const renderDuplicateGroups = (items) => {
1166
- const groups = new Map()
1167
- for (const item of items) {
1168
- const key = item.source_id || "unknown"
1169
- if (!groups.has(key)) groups.set(key, [])
1170
- groups.get(key).push(item)
1171
- }
1172
- return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
1173
- const source = sourceById(sourceId)
1174
- const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
1175
- const action = source && source.shareable && group.some((item) => item.shareable)
1176
- ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(group.filter((item) => item.shareable).length)}</button>`
1177
- : `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
1178
- return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${countLabel(group.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${bytes ? fmt(bytes) : COPY.unavailable}</div></div><div class="vault-group-action">${action}</div></div>${group.sort((a, b) => a.relative_path.localeCompare(b.relative_path)).map((item) => renderFileRow(item, 0, true)).join("")}`
1179
- }).join("")
1180
- }
1181
-
1182
- const renderInventoryGroups = (items) => {
1183
- const groups = new Map()
1184
- for (const item of items) {
1185
- const key = item.source_id || "unknown"
1186
- if (!groups.has(key)) groups.set(key, [])
1187
- groups.get(key).push(item)
1188
- }
1189
- return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
1190
- const source = sourceById(sourceId)
1191
- const duplicates = group.filter((item) => item.status === "duplicate")
1192
- const saveable = duplicates.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
1193
- const shared = group.filter((item) => item.status === "shared").length
1194
- const meta = [countLabel(group.length)]
1195
- if (duplicates.length) meta.push(countLabel(duplicates.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()))
1196
- else if (shared) meta.push(`${shared} ${COPY.shared.toLowerCase()}`)
1197
- if (saveable) meta.push(`${fmt(saveable)} ${COPY.can_save_suffix}`)
1198
- const action = batchAction(source)
1199
- return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${esc(meta.join(" · "))}</div></div>${action ? `<div class="vault-group-action">${action}</div>` : ""}</div>${renderTree(group)}`
1200
- }).join("")
1201
- }
1202
-
1203
- const emptyState = (view) => {
1204
- const scanning = scanActive(state.data && state.data.scan)
1205
- const content = {
1206
- all: scanning
1207
- ? [COPY.scan_waiting, COPY.scan_waiting_hint, "fa-solid fa-circle-notch fa-spin"]
1208
- : [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
1209
- duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
1210
- shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
1211
- independent: [COPY.no_independent, COPY.no_independent_hint, "fa-solid fa-code-branch"],
1212
- reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
1213
- activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
1214
- }[view]
1215
- return `<div class="vault-empty"><div class="vault-empty-inner"><i class="${content[2]}"></i><h3>${esc(content[0])}</h3><p>${esc(content[1])}</p>${view === "duplicates" ? `<button class="vault-button" type="button" data-view="all">${esc(COPY.view_all)}</button>` : view === "all" && !scanning ? `<button class="vault-button" type="button" id="btn-empty-scan">${esc(COPY.scan)}</button>` : ""}</div></div>`
1216
- }
1217
-
1218
- const renderReclaimable = () => {
1219
- const blobs = state.data.blobs.filter((blob) => blob.orphan)
1220
- if (!blobs.length) return emptyState("reclaimable")
1221
- return blobs.map((blob) => `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-regular fa-file vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name">${esc(blob.names[0] ? basename(blob.names[0].path) : `${blob.hash.slice(0, 12)}…`)}</span></span></div><span class="vault-size">${fmt(blob.size)}</span><span>${esc(COPY.reclaimable)}</span><span class="vault-space">${fmt(blob.size)}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-reclaim="${attr(blob.hash)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
1222
- }
1223
-
1224
- const renderActivity = () => {
1225
- const labels = {
1226
- convert: COPY.event_convert, found: COPY.event_found, adopt: COPY.event_adopt,
1227
- reclaim: COPY.event_reclaim, undo: COPY.event_undo, diverged: COPY.event_diverged,
1228
- detach: COPY.event_detach, reshare: COPY.event_reshare, skip_download: COPY.event_adopt
1229
- }
1230
- const query = state.query.toLowerCase()
1231
- const events = state.data.events.filter((event) => !query || `${labels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
1232
- if (!events.length) return emptyState("activity")
1233
- const seenBatches = new Set()
1234
- return events.map((event) => {
1235
- let undo = ""
1236
- if (event.kind === "convert" && event.batch_id && !seenBatches.has(event.batch_id)) {
1237
- seenBatches.add(event.batch_id)
1238
- undo = `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
1239
- }
1240
- const location = event.path ? state.data.sources.find((source) => source.id === event.source_id) : null
1241
- return `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(labels[event.kind] || event.kind)}</span><span class="vault-file-path">${esc(location ? `${location.label} / ` : "")}${esc(event.path ? basename(event.path) : (event.hash || "").slice(0, 12))}</span></span></div><span class="vault-size">${event.bytes_saved ? fmt(event.bytes_saved) : event.size ? fmt(event.size) : "—"}</span><span class="vault-event-time">${esc(new Date(event.ts).toLocaleString())}</span><span></span><span class="vault-row-action">${undo}</span></div>`
1242
- }).join("")
1243
- }
1244
-
1245
- const renderTable = (items) => {
1246
- let body = ""
1247
- let matches = false
1248
- if (state.view === "duplicates") {
1249
- matches = true
1250
- body = items.length ? renderDuplicateGroups(items) : emptyState("duplicates")
1251
- } else if (state.view === "reclaimable") {
1252
- body = renderReclaimable()
1253
- } else if (state.view === "activity") {
1254
- body = renderActivity()
1255
- } else {
1256
- body = items.length ? (state.sourceId ? renderTree(items) : renderInventoryGroups(items)) : emptyState(state.view)
1257
- }
1258
- const third = matches ? COPY.matches : state.view === "activity" ? COPY.last_scanned : COPY.status
1259
- el("vault-table-wrap").innerHTML = `<div class="vault-table ${matches ? "matches" : ""}"><div class="vault-columns"><span>${esc(COPY.name)}</span><span>${esc(COPY.size)}</span><span>${esc(third)}</span><span>${esc(COPY.space)}</span><span></span></div>${body}</div>`
1260
- }
1261
-
1262
- const renderOverview = () => {
1263
- const data = state.data
1264
- const last = data.last_scan
1265
- const folderMetric = last ? `<div class="vault-metric"><span class="vault-metric-value">${fmt(last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total)}</span><span class="vault-metric-label">${esc(COPY.pinokio_folder)}</span></div>` : ""
1266
- el("vault-metrics").innerHTML = `${folderMetric}
1267
- <div class="vault-metric" title="${attr(`${fmt(data.bytes_without_sharing)} ${COPY.without_sharing}`)}"><span class="vault-metric-value">${fmt(data.bytes_on_disk)}</span><span class="vault-metric-label">${esc(COPY.on_disk)}</span></div>
1268
- <div class="vault-metric" title="${attr(COPY.already_shared_help)}"><span class="vault-metric-value">${fmt(data.saved_by_sharing)}</span><span class="vault-metric-label">${esc(COPY.already_shared)}</span></div>
1269
- <div class="vault-metric" title="${attr(COPY.saved_by_vault_help)}"><span class="vault-metric-value">${fmt(data.lifetime_bytes_saved)}</span><span class="vault-metric-label">${esc(COPY.saved_by_vault)}</span></div>
1270
- <button class="vault-metric" type="button" id="btn-review-metric"><span class="vault-metric-value">${fmt(data.pending_bytes)}</span><span class="vault-metric-label">${esc(COPY.can_save)}</span></button>
1271
- <div class="vault-metric"><span class="vault-metric-value">${esc(timeAgo(last && last.ts))}</span><span class="vault-metric-label">${esc(COPY.last_scanned)}</span></div>`
1272
- const scanning = data.scan && (data.scan.active || data.scan.queued > 0)
1273
- el("btn-scan").innerHTML = scanning ? `<i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}` : `<i class="fa-solid fa-rotate"></i>${esc(last ? COPY.scan_again : COPY.scan)}`
1274
- el("btn-scan").disabled = !!scanning
1275
- el("btn-vault-options").setAttribute("aria-label", COPY.vault_options)
1276
- el("vault-repair-title").textContent = COPY.repair_index
1277
- el("vault-repair-description").textContent = COPY.repair_description
1278
- el("btn-repair").textContent = COPY.repair_action
1279
- el("btn-repair").disabled = !!scanning
1280
- const scanState = el("vault-scan-state")
1281
- if (scanning) {
1282
- const scan = data.scan
1283
- const walking = scan.walk_duration_ms == null
1284
- const hashTotal = scan.hash_total || 0
1285
- const hashDone = Math.min(scan.hashed || 0, hashTotal)
1286
- const hashRatio = hashTotal ? Math.min(1, hashDone / hashTotal) : 1
1287
- const phase = walking ? COPY.scan_progress : hashTotal ? COPY.scan_analyzing : COPY.scan_finishing
1288
- const hasEstimate = Number.isFinite(scan.estimated_files) && scan.estimated_files > 0
1289
- const walkWeight = Number.isFinite(scan.estimated_walk_weight) ? scan.estimated_walk_weight : 0.8
1290
- const walkRatio = hasEstimate ? Math.min(1, (scan.files || 0) / scan.estimated_files) : null
1291
- const progressRatio = walking
1292
- ? (hasEstimate ? walkRatio * walkWeight : null)
1293
- : (hasEstimate ? walkWeight + ((1 - walkWeight) * hashRatio) : hashRatio)
1294
- const progressValue = progressRatio == null ? null : Math.max(0, Math.min(100, Math.round(progressRatio * 100)))
1295
- const details = [`${scan.dirs || 0} ${COPY.scan_folders}`, `${scan.files || 0} ${COPY.scan_files}`, fmt(scan.bytes_total || 0)]
1296
- if (scan.current_file) details.push(`${COPY.analyzing} ${scan.current_file}`)
1297
- if (walking && scan.queued > 1) details.push(`${scan.queued} ${COPY.waiting}`)
1298
- const estimated = hasEstimate && progressValue < 100
1299
- const percent = progressValue == null ? "" : `<span class="vault-scan-percent"${estimated ? ` title="${attr(COPY.scan_estimate_help)}"` : ""}>${estimated ? "~" : ""}${progressValue}%</span>`
1300
- const progressAttrs = progressValue == null
1301
- ? `role="progressbar" aria-label="${attr(phase)}"`
1302
- : `role="progressbar" aria-label="${attr(phase)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${progressValue}"${estimated ? ` aria-valuetext="${attr(`About ${progressValue} percent. ${COPY.scan_estimate_help}.`)}"` : ""}`
1303
- const progressStyle = progressValue == null ? "" : ` style="--vault-progress:${progressRatio}"`
1304
- scanState.classList.add("show")
1305
- scanState.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i><strong>${esc(phase)}</strong><span class="vault-scan-detail">${esc(details.join(" · "))}</span>${percent}<span class="vault-progress-track" ${progressAttrs}><span class="vault-progress-bar ${progressValue == null ? "indeterminate" : "determinate"}"${progressStyle}></span></span>`
1306
- } else {
1307
- scanState.classList.remove("show")
1308
- scanState.innerHTML = ""
1309
- }
1310
- }
1311
-
1312
- const renderResult = () => {
1313
- const result = el("vault-result")
1314
- if (!state.scanResult) {
1315
- result.classList.remove("show")
1316
- result.innerHTML = ""
1317
- return
1318
- }
1319
- const info = state.scanResult
1320
- result.classList.add("show")
1321
- const duplicateLabel = countLabel(info.count, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())
1322
- result.innerHTML = `<i class="fa-regular fa-circle-check"></i><strong>${esc(COPY.scan_complete)}</strong><span class="vault-result-detail">${duplicateLabel} ${esc(COPY.found_in)} ${countLabel(info.locations, COPY.location, COPY.locations_lower)} · ${fmt(info.bytes)} ${esc(COPY.can_be_saved)}</span><button class="vault-button" type="button" id="btn-review-result">${esc(COPY.review)} ${duplicateLabel}<i class="fa-solid fa-chevron-right"></i></button>`
1323
- }
1324
- const renderFeedback = () => {
1325
- const feedback = el("vault-feedback")
1326
- if (!state.feedback) {
1327
- feedback.className = "vault-feedback"
1328
- feedback.innerHTML = ""
1329
- return
1330
- }
1331
- feedback.className = `vault-feedback show ${state.feedback.error ? "error" : ""}`
1332
- feedback.innerHTML = `<i class="fa-solid fa-${state.feedback.error ? "triangle-exclamation" : "circle-check"}"></i><span>${esc(state.feedback.message)}</span>`
1333
- }
1334
-
1335
- const render = () => {
1336
- if (!state.data) return
1337
- renderOverview()
1338
- renderResult()
1339
- renderFeedback()
1340
- if (!state.data.enabled) {
1341
- el("vault-explorer").style.display = "none"
1342
- return
1343
- }
1344
- el("vault-explorer").style.display = "grid"
1345
- const items = buildItems()
1346
- renderViews(items)
1347
- renderLocations(items)
1348
- const visible = activeItems(items)
1349
- renderToolbar(visible)
1350
- renderTable(visible)
1351
- el("vault-pane-footer").textContent = state.view === "duplicates" ? COPY.duplicate_note : COPY.tracked_note
1352
- }
1353
-
1354
- const scanActive = (scan) => !!(scan && (scan.active || scan.queued > 0))
1355
- const fetchJson = async (url) => {
1356
- const response = await fetch(url)
1357
- if (!response.ok) throw new Error(`Vault status request failed (${response.status})`)
1358
- return response.json()
1359
- }
1360
- const applyFullData = (data) => {
1361
- const scanning = scanActive(data.scan)
1362
- const completed = state.scanRequested && !scanning && data.last_scan && data.last_scan.ts !== state.scanBaseline
1363
- state.data = data
1364
- if (completed) {
1365
- state.scanRequested = false
1366
- const shareable = data.duplicates.filter((item) => item.shareable !== false)
1367
- state.scanResult = {
1368
- count: shareable.length,
1369
- locations: new Set(shareable.map((item) => item.source_id).filter(Boolean)).size,
1370
- bytes: shareable.reduce((sum, item) => sum + item.size, 0)
1371
- }
1372
- }
1373
- render()
1374
- return scanning
1375
- }
1376
- const refresh = async () => {
1377
- let delay = 15000
1378
- try {
1379
- const progressOnly = !!(state.data && scanActive(state.data.scan))
1380
- if (progressOnly) {
1381
- const progress = await fetchJson("/info/dedup?progress=1")
1382
- state.data.scan = progress.scan
1383
- state.data.last_scan = progress.last_scan
1384
- if (scanActive(progress.scan)) {
1385
- delay = 1500
1386
- renderOverview()
1387
- renderFeedback()
1388
- } else {
1389
- delay = applyFullData(await fetchJson("/info/dedup")) ? 1500 : 15000
1390
- }
1391
- } else {
1392
- delay = applyFullData(await fetchJson("/info/dedup")) ? 1500 : 15000
1393
- }
1394
- } catch (error) {
1395
- state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1396
- renderFeedback()
1397
- delay = 5000
1398
- } finally {
1399
- clearTimeout(window.__vaultRefresh)
1400
- window.__vaultRefresh = setTimeout(refresh, delay)
1401
- }
1402
- }
1403
-
1404
- const runAction = async (payload, success) => {
1405
- state.feedback = null
1406
- renderFeedback()
1407
- const result = await post(payload)
1408
- if (result.error) state.feedback = { error: true, message: result.error }
1409
- else state.feedback = { error: false, message: typeof success === "function" ? success(result) : success }
1410
- await refresh()
1411
- }
1412
-
1413
- const chooseExternalFolder = () => new Promise((resolve, reject) => {
1414
- const picker = new Socket()
1415
- let settled = false
1416
- const finish = (value) => {
1417
- if (settled) return
1418
- settled = true
1419
- resolve(value)
1420
- }
1421
- const fail = (error) => {
1422
- if (settled) return
1423
- settled = true
1424
- reject(error)
1425
- }
1426
- picker.run({
1427
- method: "kernel.bin.filepicker",
1428
- params: { title: COPY.add_external_folder, type: "folder" }
1429
- }, (packet) => {
1430
- if (packet.type === "result") {
1431
- const paths = packet.data && Array.isArray(packet.data.paths) ? packet.data.paths : []
1432
- finish(paths[0] || null)
1433
- picker.close()
1434
- } else if (packet.type === "error") {
1435
- const message = packet.data && packet.data.message ? packet.data.message : "The folder picker could not be opened."
1436
- fail(new Error(message))
1437
- picker.close()
1438
- }
1439
- }).then(() => finish(null)).catch(fail)
1440
- })
1441
-
1442
- document.addEventListener("click", async (event) => {
1443
- const advanced = el("vault-advanced")
1444
- if (advanced.open && !advanced.contains(event.target)) advanced.open = false
1445
- const target = event.target.closest("button")
1446
- if (!target) return
1447
- if (target.dataset.view) {
1448
- state.view = target.dataset.view
1449
- state.sourceId = null
1450
- state.query = ""
1451
- state.statusFilter = "all"
1452
- if (state.view === "duplicates") state.scanResult = null
1453
- render()
1454
- } else if (target.dataset.source) {
1455
- state.sourceId = state.sourceId === target.dataset.source ? null : target.dataset.source
1456
- render()
1457
- } else if (target.dataset.toggleSource) {
1458
- const id = target.dataset.toggleSource
1459
- if (state.collapsedSources.has(id)) state.collapsedSources.delete(id)
1460
- else state.collapsedSources.add(id)
1461
- renderLocations(buildItems())
1462
- } else if (target.dataset.toggleDir) {
1463
- const key = target.dataset.toggleDir
1464
- if (state.collapsedDirs.has(key)) state.collapsedDirs.delete(key)
1465
- else state.collapsedDirs.add(key)
1466
- render()
1467
- } else if (target.dataset.expandFile) {
1468
- const file = target.dataset.expandFile
1469
- if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
1470
- else state.expandedFiles.add(file)
1471
- render()
1472
- } else if (target.id === "btn-add-source") {
1473
- target.disabled = true
1474
- try {
1475
- const folderPath = await chooseExternalFolder()
1476
- if (!folderPath) return
1477
- const result = await post({ action: "add_source", path: folderPath })
1478
- if (result.error) {
1479
- state.feedback = { error: true, message: result.error }
1480
- } else {
1481
- state.view = "all"
1482
- state.sourceId = result.source && result.source.id ? result.source.id : null
1483
- state.query = ""
1484
- state.feedback = {
1485
- error: false,
1486
- message: result.created === false
1487
- ? COPY.external_exists
1488
- : result.source && result.source.shareable === false
1489
- ? COPY.external_other_disk
1490
- : COPY.external_added
1491
- }
1492
- }
1493
- await refresh()
1494
- } catch (error) {
1495
- state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1496
- renderFeedback()
1497
- } finally {
1498
- target.disabled = false
1499
- }
1500
- } else if (target.id === "btn-scan" || target.id === "btn-empty-scan") {
1501
- state.scanRequested = true
1502
- state.scanBaseline = state.data.last_scan ? state.data.last_scan.ts : null
1503
- state.scanResult = null
1504
- state.feedback = null
1505
- await post({ action: "scan" })
1506
- await refresh()
1507
- } else if (target.id === "btn-repair") {
1508
- target.disabled = true
1509
- target.textContent = COPY.repairing
1510
- await runAction({ action: "repair" }, COPY.repair_done)
1511
- advanced.open = false
1512
- } else if (target.id === "btn-review-result" || target.id === "btn-review-metric") {
1513
- state.view = "duplicates"
1514
- state.sourceId = null
1515
- state.query = ""
1516
- state.scanResult = null
1517
- render()
1518
- } else if (target.dataset.deduplicateScope) {
1519
- await runAction({ action: "deduplicate", scope_id: target.dataset.deduplicateScope }, (result) => `${COPY.converted}: ${fmt(result.bytes_saved || 0)}`)
1520
- } else if (target.dataset.detach) {
1521
- await runAction({ action: "detach", path: target.dataset.detach }, COPY.ignored)
1522
- } else if (target.dataset.reshare) {
1523
- await runAction({ action: "reshare", path: target.dataset.reshare }, COPY.allowed)
1524
- } else if (target.dataset.reclaim) {
1525
- await runAction({ action: "reclaim", hash: target.dataset.reclaim }, (result) => `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`)
1526
- } else if (target.id === "btn-reclaim-all") {
1527
- await runAction({ action: "reclaim_all" }, (result) => `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`)
1528
- } else if (target.dataset.undo) {
1529
- await runAction({ action: "undo", batch_id: target.dataset.undo }, (result) => `${COPY.event_undo} (${result.undone || 0})`)
1530
- }
1531
- })
1532
-
1533
- document.addEventListener("input", (event) => {
1534
- if (event.target.id !== "vault-search") return
1535
- state.query = event.target.value
1536
- const items = activeItems(buildItems())
1537
- updateToolbarSummary(items)
1538
- renderTable(items)
1539
- })
1540
- document.addEventListener("change", (event) => {
1541
- if (event.target.id !== "vault-status-filter") return
1542
- state.statusFilter = event.target.value
1543
- render()
1544
- })
1545
-
1546
- el("btn-scan").textContent = COPY.scan
1547
- el("btn-add-source").setAttribute("aria-label", COPY.add_external_folder)
1548
- el("btn-add-source").setAttribute("title", COPY.add_external_folder)
1549
- el("btn-vault-options").setAttribute("aria-label", COPY.vault_options)
1550
- el("vault-repair-title").textContent = COPY.repair_index
1551
- el("vault-repair-description").textContent = COPY.repair_description
1552
- el("btn-repair").textContent = COPY.repair_action
1553
- refresh()
1554
- </script>
1555
- </body>
1556
- </html>