openclacky 1.3.1 → 1.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,25 +1,17 @@
1
- // trash.jsFile Recall & Session Recycle Bin
1
+ // ── Trash · view recycle-bin rendering, tabs, DOM wiring, dialogs ────────
2
2
  //
3
- // Two-tab panel under the "File Recall" sidebar item:
4
- // Tab 1 (文件回收 / Agent Files): AI-deleted files from all projects
5
- // Tab 2 (会话回收 / Sessions): soft-deleted sessions that can be restored
3
+ // Owns rendering of file/session cards, tab switching, toolbar wiring, and all
4
+ // confirm/toast dialogs. Reads through TrashStore.state and drives every data
5
+ // mutation through store actions, re-rendering on store change events.
6
6
  //
7
- // Each tab has its own toolbar actions (refresh, empty by age, empty all).
8
- // The "Clean orphans" button is file-trash-only and hidden on the session tab.
7
+ // Augments the `Trash` facade with onPanelShow.
9
8
  //
10
- // Load order: after app.js modules (I18n, Modal), before app.js boot.
9
+ // Depends on: TrashStore, I18n, Modal, Sessions.
10
+ // ───────────────────────────────────────────────────────────────────────────
11
11
 
12
- const Trash = (() => {
13
- // ── Private state ────────────────────────────────────────────────────
14
- let _files = [];
15
- let _totals = { count: 0, size: 0 };
16
- let _sessions = [];
17
- let _sessionTotals = { count: 0, size: 0 };
18
- let _activeTab = null; // null = no tab shown yet; set on first _switchTab
19
- let _loading = false;
20
- let _wired = false;
21
-
22
- // ── Helpers ──────────────────────────────────────────────────────────
12
+ const TrashView = (() => {
13
+ let _activeTab = null;
14
+ let _wired = false;
23
15
 
24
16
  function $(id) { return document.getElementById(id); }
25
17
 
@@ -63,73 +55,30 @@ const Trash = (() => {
63
55
  if (_activeTab === tab) return;
64
56
  _activeTab = tab;
65
57
 
66
- // Update tab button active states
67
58
  document.querySelectorAll(".trash-tab").forEach(btn => {
68
59
  btn.classList.toggle("active", btn.dataset.tab === tab);
69
60
  });
70
61
 
71
- // Show/hide tab content panels
72
62
  const filePane = $("trash-tab-file");
73
63
  const sessionPane = $("trash-tab-session");
74
64
  if (filePane) filePane.style.display = tab === "file-trash" ? "" : "none";
75
65
  if (sessionPane) sessionPane.style.display = tab === "session-trash" ? "" : "none";
76
66
 
77
- // Show/hide "Clean orphans" button — only relevant for file trash
78
67
  const btnOrphans = $("btn-trash-empty-orphans");
79
68
  if (btnOrphans) btnOrphans.style.display = tab === "file-trash" ? "" : "none";
80
69
 
81
- // Reload the active tab's data
82
70
  _load();
83
71
  }
84
72
 
85
- // ── Data loading ─────────────────────────────────────────────────────
86
-
87
- async function _load() {
88
- if (_loading) return;
89
- _loading = true;
90
-
73
+ function _load() {
91
74
  if (_activeTab === "file-trash") {
92
- await _loadFiles();
75
+ const list = $("trash-list");
76
+ if (list) list.innerHTML = `<div class="creator-loading">${_t("trash.loading")}</div>`;
77
+ Trash.loadFiles();
93
78
  } else {
94
- await _loadSessions();
95
- }
96
-
97
- _loading = false;
98
- }
99
-
100
- async function _loadFiles() {
101
- const list = $("trash-list");
102
- if (list) list.innerHTML =
103
- `<div class="creator-loading">${_t("trash.loading")}</div>`;
104
- try {
105
- const res = await fetch("/api/trash");
106
- const data = await res.json();
107
- if (!res.ok) throw new Error(data.error || "Load failed");
108
- _files = data.files || [];
109
- _totals = { count: data.total_count || 0, size: data.total_size || 0 };
110
- _renderFiles();
111
- } catch (e) {
112
- console.error("[Trash] load files failed", e);
113
- if (list) list.innerHTML =
114
- `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
115
- }
116
- }
117
-
118
- async function _loadSessions() {
119
- const list = $("trash-session-list");
120
- if (list) list.innerHTML =
121
- `<div class="creator-loading">${_t("trash.loading")}</div>`;
122
- try {
123
- const res = await fetch("/api/trash/sessions");
124
- const data = await res.json();
125
- if (!res.ok) throw new Error(data.error || "Load failed");
126
- _sessions = data.sessions || [];
127
- _sessionTotals = { count: data.count || 0, size: data.total_size || 0 };
128
- _renderSessions();
129
- } catch (e) {
130
- console.error("[Trash] load sessions failed", e);
131
- if (list) list.innerHTML =
132
- `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
79
+ const list = $("trash-session-list");
80
+ if (list) list.innerHTML = `<div class="creator-loading">${_t("trash.loading")}</div>`;
81
+ Trash.loadSessions();
133
82
  }
134
83
  }
135
84
 
@@ -143,31 +92,29 @@ const Trash = (() => {
143
92
  const btnAll = $("btn-trash-empty-all");
144
93
  if (!list) return;
145
94
 
146
- const orphanCount = _files.filter(f => {
147
- const root = f.project_root || "";
148
- return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
149
- /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
150
- }).length;
95
+ const files = Trash.state.files;
96
+ const totals = Trash.state.totals;
97
+ const orphanCount = Trash.state.orphanCount();
151
98
 
152
99
  if (summary) {
153
- summary.textContent = _files.length
100
+ summary.textContent = files.length
154
101
  ? I18n.t("trash.summary", {
155
- count: _totals.count,
156
- size: _humanBytes(_totals.size)
102
+ count: totals.count,
103
+ size: _humanBytes(totals.size)
157
104
  }) + (orphanCount > 0 ? " • " + I18n.t("trash.summaryOrphans", { count: orphanCount }) : "")
158
105
  : "";
159
106
  }
160
- if (btnOld) btnOld.disabled = _files.length === 0;
107
+ if (btnOld) btnOld.disabled = files.length === 0;
161
108
  if (btnOrphans) btnOrphans.disabled = orphanCount === 0;
162
- if (btnAll) btnAll.disabled = _files.length === 0;
109
+ if (btnAll) btnAll.disabled = files.length === 0;
163
110
 
164
- if (_files.length === 0) {
111
+ if (files.length === 0) {
165
112
  list.innerHTML = `<div class="creator-empty">${_t("trash.empty")}</div>`;
166
113
  return;
167
114
  }
168
115
 
169
116
  list.innerHTML = "";
170
- _files.forEach(f => list.appendChild(_buildFileCard(f)));
117
+ files.forEach(f => list.appendChild(_buildFileCard(f)));
171
118
  }
172
119
 
173
120
  function _buildFileCard(file) {
@@ -179,18 +126,12 @@ const Trash = (() => {
179
126
  const original = file.original_path || "";
180
127
  const basename = original.split("/").pop() || original;
181
128
  const parts = original.split("/").filter(Boolean);
182
- // Show last three path segments so same-named files (index.js, package.json, …)
183
- // are still distinguishable in the card title area.
184
129
  const shortPath = parts.length > 3
185
130
  ? ".../" + parts.slice(-3).join("/")
186
131
  : original;
187
132
  const sizeStr = _humanBytes(file.file_size || 0);
188
133
  const whenStr = _humanTime(file.deleted_at);
189
- // Heuristic: if project_root lives under a temp-dir prefix or matches the
190
- // Ruby Tempdir pattern (dYYYYMMDD-PID-random), the original project is gone.
191
- // We mark it as an orphan so the user can bulk-clean it confidently.
192
- const orphan = /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(file.project_root || "") ||
193
- /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(file.project_root || "");
134
+ const orphan = Trash.state.isOrphan(file);
194
135
 
195
136
  card.innerHTML = `
196
137
  <div class="trash-card-info">
@@ -228,7 +169,7 @@ const Trash = (() => {
228
169
  card.querySelector(".btn-trash-restore").addEventListener("click", () =>
229
170
  _restoreFile(file, card));
230
171
  card.querySelector(".btn-trash-delete").addEventListener("click", () =>
231
- _deleteFile(file, card));
172
+ _deleteFile(file));
232
173
 
233
174
  return card;
234
175
  }
@@ -242,24 +183,27 @@ const Trash = (() => {
242
183
  const btnAll = $("btn-trash-empty-all");
243
184
  if (!list) return;
244
185
 
186
+ const sessions = Trash.state.sessions;
187
+ const totals = Trash.state.sessionTotals;
188
+
245
189
  if (summary) {
246
- summary.textContent = _sessions.length
190
+ summary.textContent = sessions.length
247
191
  ? I18n.t("trash.summarySessions", {
248
- count: _sessionTotals.count,
249
- size: _humanBytes(_sessionTotals.size)
192
+ count: totals.count,
193
+ size: _humanBytes(totals.size)
250
194
  })
251
195
  : "";
252
196
  }
253
- if (btnOld) btnOld.disabled = _sessions.length === 0;
254
- if (btnAll) btnAll.disabled = _sessions.length === 0;
197
+ if (btnOld) btnOld.disabled = sessions.length === 0;
198
+ if (btnAll) btnAll.disabled = sessions.length === 0;
255
199
 
256
- if (_sessions.length === 0) {
200
+ if (sessions.length === 0) {
257
201
  list.innerHTML = `<div class="creator-empty">${_t("trash.noSessionTrash")}</div>`;
258
202
  return;
259
203
  }
260
204
 
261
205
  list.innerHTML = "";
262
- _sessions.forEach(s => list.appendChild(_buildSessionCard(s)));
206
+ sessions.forEach(s => list.appendChild(_buildSessionCard(s)));
263
207
  }
264
208
 
265
209
  function _buildSessionCard(session) {
@@ -268,7 +212,6 @@ const Trash = (() => {
268
212
  card.dataset.sessionId = session.session_id;
269
213
 
270
214
  const name = session.name || session.session_id || "";
271
- const shortId = (session.session_id || "").slice(0, 8);
272
215
  const taskCount = session.total_tasks || 0;
273
216
  const sizeStr = _humanBytes(session.file_size || 0);
274
217
  const whenStr = _humanTime(session.deleted_at || session.created_at);
@@ -304,7 +247,7 @@ const Trash = (() => {
304
247
  card.querySelector(".btn-trash-session-restore").addEventListener("click", () =>
305
248
  _restoreSession(session, card));
306
249
  card.querySelector(".btn-trash-session-delete").addEventListener("click", () =>
307
- _deleteSession(session, card));
250
+ _deleteSession(session));
308
251
 
309
252
  return card;
310
253
  }
@@ -314,71 +257,25 @@ const Trash = (() => {
314
257
  async function _restoreFile(file, card) {
315
258
  const btn = card.querySelector(".btn-trash-restore");
316
259
  btn.disabled = true;
317
- try {
318
- const res = await fetch("/api/trash/restore", {
319
- method: "POST",
320
- headers: { "Content-Type": "application/json" },
321
- body: JSON.stringify({
322
- project_root: file.project_root,
323
- original_path: file.original_path
324
- })
325
- });
326
- const data = await res.json();
327
- if (!res.ok || !data.ok) {
328
- Modal.toast(I18n.t("trash.restoreFail", {
329
- msg: data.error || res.statusText
330
- }), "error");
331
- } else {
332
- // Optimistic update — remove from local state and re-render immediately
333
- // so the UI feels instant without waiting for a full reload.
334
- _files = _files.filter(f =>
335
- !(f.project_root === file.project_root && f.original_path === file.original_path));
336
- _totals = {
337
- count: Math.max(0, _totals.count - 1),
338
- size: Math.max(0, _totals.size - (file.file_size || 0))
339
- };
340
- _renderFiles();
341
- Modal.toast(I18n.t("trash.restoreOk", {
342
- path: (file.original_path || "").split("/").pop()
343
- }), "success");
344
- }
345
- } catch (e) {
346
- Modal.toast(I18n.t("trash.restoreFail", { msg: e.message }), "error");
347
- } finally {
260
+ const res = await Trash.restoreFile(file);
261
+ if (!res.ok) {
262
+ Modal.toast(I18n.t("trash.restoreFail", { msg: res.error }), "error");
348
263
  btn.disabled = false;
264
+ } else {
265
+ Modal.toast(I18n.t("trash.restoreOk", {
266
+ path: (file.original_path || "").split("/").pop()
267
+ }), "success");
349
268
  }
350
269
  }
351
270
 
352
- async function _deleteFile(file, card) {
271
+ async function _deleteFile(file) {
353
272
  const basename = (file.original_path || "").split("/").pop() || file.original_path;
354
273
  const confirmed = await Modal.confirm(
355
274
  I18n.t("trash.confirmDeleteOne", { name: basename })
356
275
  );
357
276
  if (!confirmed) return;
358
-
359
- const url = "/api/trash?" + new URLSearchParams({
360
- project: file.project_root,
361
- file: file.original_path
362
- }).toString();
363
-
364
- try {
365
- const res = await fetch(url, { method: "DELETE" });
366
- const data = await res.json();
367
- if (!res.ok || !data.ok) {
368
- Modal.toast(I18n.t("trash.deleteFail", { msg: data.error || res.statusText }), "error");
369
- return;
370
- }
371
- // Optimistic update — same pattern as _restoreFile.
372
- _files = _files.filter(f =>
373
- !(f.project_root === file.project_root && f.original_path === file.original_path));
374
- _totals = {
375
- count: Math.max(0, _totals.count - 1),
376
- size: Math.max(0, _totals.size - (file.file_size || 0))
377
- };
378
- _renderFiles();
379
- } catch (e) {
380
- Modal.toast(I18n.t("trash.deleteFail", { msg: e.message }), "error");
381
- }
277
+ const res = await Trash.deleteFile(file);
278
+ if (!res.ok) Modal.toast(I18n.t("trash.deleteFail", { msg: res.error }), "error");
382
279
  }
383
280
 
384
281
  // ── Session actions ──────────────────────────────────────────────────
@@ -386,149 +283,62 @@ const Trash = (() => {
386
283
  async function _restoreSession(session, card) {
387
284
  const btn = card.querySelector(".btn-trash-session-restore");
388
285
  btn.disabled = true;
389
- try {
390
- const res = await fetch("/api/trash/sessions/restore", {
391
- method: "POST",
392
- headers: { "Content-Type": "application/json" },
393
- body: JSON.stringify({ session_id: session.session_id })
394
- });
395
- const data = await res.json();
396
- if (!res.ok || !data.ok) {
397
- Modal.toast(I18n.t("trash.sessionRestoreFail", {
398
- msg: data.error || res.statusText
399
- }), "error");
400
- } else {
401
- _sessions = _sessions.filter(s => s.session_id !== session.session_id);
402
- _sessionTotals = {
403
- count: Math.max(0, _sessionTotals.count - 1),
404
- size: Math.max(0, _sessionTotals.size - (session.file_size || 0))
405
- };
406
- _renderSessions();
407
- // Optimistically add the restored session to the sidebar — same pattern
408
- // as the WS session_restored handler, but covers the case where the WS
409
- // event is lost (offline tab, slow reconnect). Sessions.add is idempotent.
410
- const restored = data.session;
411
- if (restored && typeof Sessions !== "undefined") {
412
- Sessions.add(restored);
413
- Sessions.renderList();
414
- }
415
- Modal.toast(I18n.t("trash.sessionRestoreOk"), "success", restored && restored.id ? {
416
- action: {
417
- label: I18n.t("trash.sessionRestoreOkAction"),
418
- onClick: () => Sessions.select(restored.id)
419
- }
420
- } : {});
421
- }
422
- } catch (e) {
423
- Modal.toast(I18n.t("trash.sessionRestoreFail", { msg: e.message }), "error");
424
- } finally {
286
+ const res = await Trash.restoreSession(session);
287
+ if (!res.ok) {
288
+ Modal.toast(I18n.t("trash.sessionRestoreFail", { msg: res.error }), "error");
425
289
  btn.disabled = false;
290
+ return;
426
291
  }
292
+ const restored = res.session;
293
+ Modal.toast(I18n.t("trash.sessionRestoreOk"), "success", restored && restored.id ? {
294
+ action: {
295
+ label: I18n.t("trash.sessionRestoreOkAction"),
296
+ onClick: () => Sessions.select(restored.id)
297
+ }
298
+ } : {});
427
299
  }
428
300
 
429
- async function _deleteSession(session, card) {
301
+ async function _deleteSession(session) {
430
302
  const name = session.name || (session.session_id || "").slice(0, 8);
431
303
  const confirmed = await Modal.confirm(
432
304
  I18n.t("trash.confirmDeleteSession", { name: name })
433
305
  );
434
306
  if (!confirmed) return;
435
-
436
- try {
437
- const res = await fetch(`/api/trash/sessions/${encodeURIComponent(session.session_id)}`, {
438
- method: "DELETE"
439
- });
440
- const data = await res.json();
441
- if (!res.ok || !data.ok) {
442
- Modal.toast(I18n.t("trash.deleteFail", { msg: data.error || res.statusText }), "error");
443
- return;
444
- }
445
- _sessions = _sessions.filter(s => s.session_id !== session.session_id);
446
- _renderSessions();
447
- } catch (e) {
448
- Modal.toast(I18n.t("trash.deleteFail", { msg: e.message }), "error");
449
- }
307
+ const res = await Trash.deleteSession(session);
308
+ if (!res.ok) Modal.toast(I18n.t("trash.deleteFail", { msg: res.error }), "error");
450
309
  }
451
310
 
452
311
  // ── Bulk actions ─────────────────────────────────────────────────────
453
312
 
454
- // Count locally how many entries would be wiped by a bulk operation,
455
- // so the confirmation dialog can show "delete N items" instead of a vague
456
- // "all eligible". daysOld=0 means "everything", otherwise filter by deleted_at.
457
- function _countMatching(items, daysOld) {
458
- if (!Array.isArray(items)) return 0;
459
- if (!daysOld || daysOld <= 0) return items.length;
460
- const cutoff = Date.now() - daysOld * 86400000;
461
- return items.filter(it => {
462
- const t = Date.parse(it.deleted_at || "");
463
- return !isNaN(t) && t < cutoff;
464
- }).length;
465
- }
466
-
467
313
  async function _emptyBulk(daysOld, confirmKey) {
468
- const isSession = _activeTab === "session-trash";
469
- const matchCount = _countMatching(isSession ? _sessions : _files, daysOld);
314
+ const isSession = _activeTab === "session-trash";
315
+ const items = isSession ? Trash.state.sessions : Trash.state.files;
316
+ const matchCount = Trash.countMatching(items, daysOld);
470
317
 
471
318
  if (matchCount === 0) {
472
319
  Modal.toast(_t(daysOld > 0 ? "trash.nothingOld" : "trash.empty"), "info");
473
320
  return;
474
321
  }
475
322
 
476
- const confirmed = await Modal.confirm(
477
- I18n.t(confirmKey, { count: matchCount })
478
- );
323
+ const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matchCount }));
479
324
  if (!confirmed) return;
480
325
 
481
- if (isSession) return _emptySessionsBulk(daysOld);
482
-
483
- const qs = new URLSearchParams();
484
- qs.set("days_old", String(daysOld));
485
- const url = "/api/trash?" + qs.toString();
486
-
487
- try {
488
- const res = await fetch(url, { method: "DELETE" });
489
- const data = await res.json();
490
- if (!res.ok || !data.ok) {
491
- Modal.toast(I18n.t("trash.cleanFail", { msg: data.error || res.statusText }), "error");
492
- return;
493
- }
326
+ if (isSession) {
327
+ const res = await Trash.emptySessionsBulk(daysOld);
328
+ if (!res.ok) { Modal.toast(I18n.t("trash.cleanFail", { msg: res.error }), "error"); return; }
329
+ Modal.toast(I18n.t("trash.sessionsCleaned", { count: res.deleted_count }), "success");
330
+ } else {
331
+ const res = await Trash.emptyFilesBulk(daysOld);
332
+ if (!res.ok) { Modal.toast(I18n.t("trash.cleanFail", { msg: res.error }), "error"); return; }
494
333
  Modal.toast(I18n.t("trash.emptied", {
495
- count: data.deleted_count || 0,
496
- size: _humanBytes(data.freed_size || 0)
497
- }), "success");
498
- await _loadFiles();
499
- } catch (e) {
500
- Modal.toast(I18n.t("trash.cleanFail", { msg: e.message }), "error");
501
- }
502
- }
503
-
504
- async function _emptySessionsBulk(daysOld) {
505
- const qs = new URLSearchParams();
506
- qs.set("days_old", String(daysOld));
507
- const url = "/api/trash/sessions?" + qs.toString();
508
-
509
- try {
510
- const res = await fetch(url, { method: "DELETE" });
511
- const data = await res.json();
512
- if (!res.ok || !data.ok) {
513
- Modal.toast(I18n.t("trash.cleanFail", { msg: data.error || res.statusText }), "error");
514
- return;
515
- }
516
- Modal.toast(I18n.t("trash.sessionsCleaned", {
517
- count: data.deleted_count || 0
334
+ count: res.deleted_count,
335
+ size: _humanBytes(res.freed_size)
518
336
  }), "success");
519
- await _loadSessions();
520
- } catch (e) {
521
- Modal.toast(I18n.t("trash.cleanFail", { msg: e.message }), "error");
522
337
  }
523
338
  }
524
339
 
525
340
  async function _emptyOrphans() {
526
- // Same heuristic as in _buildFileCard — keep both in sync if you ever change it.
527
- const orphans = _files.filter(f => {
528
- const root = f.project_root || "";
529
- return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
530
- /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
531
- });
341
+ const orphans = Trash.orphans();
532
342
  if (orphans.length === 0) {
533
343
  Modal.toast(_t("trash.noOrphans"), "info");
534
344
  return;
@@ -540,29 +350,16 @@ const Trash = (() => {
540
350
 
541
351
  let deleted = 0, freed = 0, failed = 0;
542
352
  for (const f of orphans) {
543
- const url = "/api/trash?" + new URLSearchParams({
544
- project: f.project_root,
545
- file: f.original_path
546
- }).toString();
547
- try {
548
- const r = await fetch(url, { method: "DELETE" });
549
- const d = await r.json();
550
- if (r.ok && d.ok) {
551
- deleted += 1;
552
- freed += d.freed_size || 0;
553
- } else {
554
- failed += 1;
555
- }
556
- } catch (_e) {
557
- failed += 1;
558
- }
353
+ const r = await Trash.deleteOneFileRaw(f);
354
+ if (r.ok) { deleted += 1; freed += r.freed_size; }
355
+ else failed += 1;
559
356
  }
560
357
  Modal.toast(I18n.t("trash.orphansCleaned", {
561
358
  count: deleted,
562
359
  size: _humanBytes(freed),
563
360
  failed: failed
564
361
  }), failed > 0 ? "warning" : "success");
565
- await _loadFiles();
362
+ await Trash.loadFiles();
566
363
  }
567
364
 
568
365
  // ── Event wiring ─────────────────────────────────────────────────────
@@ -571,13 +368,11 @@ const Trash = (() => {
571
368
  if (_wired) return;
572
369
  _wired = true;
573
370
 
574
- // Tab switches
575
371
  const tabFile = $("tab-file-trash");
576
372
  const tabSession = $("tab-session-trash");
577
373
  if (tabFile) tabFile.addEventListener("click", () => _switchTab("file-trash"));
578
374
  if (tabSession) tabSession.addEventListener("click", () => _switchTab("session-trash"));
579
375
 
580
- // Toolbar buttons
581
376
  const btnRefresh = $("btn-trash-refresh");
582
377
  const btnOld = $("btn-trash-empty-old");
583
378
  const btnOrphans = $("btn-trash-empty-orphans");
@@ -592,15 +387,29 @@ const Trash = (() => {
592
387
  ? "trash.confirmEmptySessionAll" : "trash.confirmEmptyAll"));
593
388
  }
594
389
 
595
- // ── Public API ────────────────────────────────────────────────────────
390
+ function _subscribe() {
391
+ Trash.on("trash:filesChanged", _renderFiles);
392
+ Trash.on("trash:sessionsChanged", _renderSessions);
393
+ Trash.on("trash:filesError", (e) => {
394
+ const list = $("trash-list");
395
+ if (list) list.innerHTML = `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
396
+ });
397
+ Trash.on("trash:sessionsError", (e) => {
398
+ const list = $("trash-session-list");
399
+ if (list) list.innerHTML = `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
400
+ });
401
+ }
596
402
 
597
- return {
598
- /** Called by Router when the trash panel becomes active. */
403
+ const viewApi = {
599
404
  onPanelShow() {
600
405
  _wire();
601
- // Reset to file-trash tab on each panel show so the user always
602
- // starts on the familiar "agent files" view.
406
+ _activeTab = null;
603
407
  _switchTab("file-trash");
604
- },
408
+ }
605
409
  };
410
+
411
+ return { init: _subscribe, api: viewApi };
606
412
  })();
413
+
414
+ Object.assign(Trash, TrashView.api);
415
+ TrashView.init();