main
js 501 lines 16.6 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getContext } from "/index.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 import { formatDateTime } from "/js/time-utils.js";
6
7 const REFRESH_DEBOUNCE_MS = 180;
8
9 function lineType(text) {
10 if (text.startsWith("@@")) return "hunk";
11 if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
12 return "meta";
13 }
14 if (text.startsWith("+")) return "add";
15 if (text.startsWith("-")) return "del";
16 if (text.startsWith("\\ No newline")) return "note";
17 return "context";
18 }
19
20 function dirname(path) {
21 const clean = String(path || "").replace(/\/+$/, "");
22 const index = clean.lastIndexOf("/");
23 return index > 0 ? clean.slice(0, index) : "";
24 }
25
26 function apiPath(name) {
27 return `/plugins/_time_travel/${name}`;
28 }
29
30 const model = {
31 loading: false,
32 workspaceLoading: false,
33 busy: false,
34 error: "",
35 payload: null,
36 contextId: "",
37 workspaces: [],
38 selectedWorkspaceId: "",
39 workspacePath: "",
40 fileFilter: "",
41 selectedHash: "",
42 selectedPath: "",
43 selectedDiff: null,
44 diffLoading: false,
45 diffError: "",
46 previewOpen: false,
47 previewLoading: false,
48 previewError: "",
49 previewTechnicalDetails: "",
50 previewDetailsOpen: false,
51 preview: null,
52 _root: null,
53 _mode: "canvas",
54 _refreshTimer: null,
55 _filterTimer: null,
56 _requestSeq: 0,
57 _diffSeq: 0,
58
59 async init(element = null) {
60 await this.onMount(element, { mode: "canvas" });
61 },
62
63 async onMount(element = null, options = {}) {
64 if (element) this._root = element;
65 this._mode = options?.mode === "modal" ? "modal" : "canvas";
66 if (this._mode !== "modal") {
67 this.setupCanvasSurface(element);
68 }
69 const nextContextId = this.resolveContextId();
70 const resetWorkspace = this._mode === "modal" || this.contextId !== nextContextId || !this.selectedWorkspaceId;
71 this.contextId = nextContextId;
72 await this.loadWorkspaces({ contextId: this.contextId, reset: resetWorkspace });
73 if (this._mode === "modal" || !this.payload || resetWorkspace) {
74 await this.refresh({ contextId: this.contextId, keepSelection: !resetWorkspace, skipWorkspaceLoad: true });
75 }
76 },
77
78 async onOpen(payload = {}) {
79 const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
80 this.contextId = nextContextId;
81 await this.loadWorkspaces({ contextId: nextContextId, reset: true });
82 await this.refresh({ contextId: nextContextId, skipWorkspaceLoad: true });
83 },
84
85 cleanup() {
86 if (this._refreshTimer) {
87 clearTimeout(this._refreshTimer);
88 this._refreshTimer = null;
89 }
90 if (this._filterTimer) {
91 clearTimeout(this._filterTimer);
92 this._filterTimer = null;
93 }
94 },
95
96 setupCanvasSurface(element = null) {
97 if (element) this._root = element;
98 },
99
100 resolveContextId() {
101 const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
102 return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
103 },
104
105 scheduleRefresh(options = {}) {
106 if (this._refreshTimer) clearTimeout(this._refreshTimer);
107 this._refreshTimer = setTimeout(() => {
108 this._refreshTimer = null;
109 this.refresh(options).catch((error) => console.error("Time Travel refresh failed", error));
110 }, REFRESH_DEBOUNCE_MS);
111 },
112
113 scheduleFilterRefresh() {
114 if (this._filterTimer) clearTimeout(this._filterTimer);
115 this._filterTimer = setTimeout(() => {
116 this._filterTimer = null;
117 this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
118 }, 240);
119 },
120
121 async loadWorkspaces(options = {}) {
122 const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
123 this.workspaceLoading = true;
124 try {
125 const response = await callJsonApi(apiPath("history_workspaces"), {
126 context_id: contextId,
127 });
128 if (!response?.ok) throw new Error(response?.error || "Could not load workspaces.");
129 this.workspaces = Array.isArray(response.workspaces) ? response.workspaces : [];
130 const defaultWorkspaceId = String(response.default_workspace_id || "");
131 const hasSelected = this.workspaces.some((workspace) => workspace?.id === this.selectedWorkspaceId);
132 if (options.reset || !this.selectedWorkspaceId || !hasSelected) {
133 this.selectedWorkspaceId = defaultWorkspaceId || String(this.workspaces[0]?.id || "");
134 }
135 } catch (error) {
136 this.workspaces = [];
137 this.selectedWorkspaceId = "";
138 this.error = error instanceof Error ? error.message : String(error);
139 } finally {
140 this.workspaceLoading = false;
141 }
142 },
143
144 async refresh(options = {}) {
145 const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
146 if (!options.skipWorkspaceLoad && (options.reloadWorkspaces || this.workspaces.length === 0)) {
147 await this.loadWorkspaces({ contextId, reset: Boolean(options.resetWorkspace) });
148 }
149 const seq = ++this._requestSeq;
150 this.loading = true;
151 this.error = "";
152 try {
153 const response = await callJsonApi(apiPath("history_list"), {
154 context_id: contextId,
155 workspace_id: this.selectedWorkspaceId,
156 limit: 100,
157 offset: 0,
158 file_filter: this.fileFilter,
159 });
160 if (seq !== this._requestSeq) return;
161 if (!response?.ok) throw new Error(response?.error || "Could not load history.");
162 this.payload = response;
163 this.contextId = String(response.context_id || contextId || "");
164 if (response.workspace?.id && !this.selectedWorkspaceId) {
165 this.selectedWorkspaceId = String(response.workspace.id);
166 }
167 const selectedWorkspace = this.selectedWorkspace();
168 this.workspacePath = String(
169 response.workspace?.display_path
170 || response.workspace?.path
171 || selectedWorkspace?.display_path
172 || selectedWorkspace?.path
173 || ""
174 );
175 this.reconcileSelection(Boolean(options.keepSelection));
176 } catch (error) {
177 if (seq !== this._requestSeq) return;
178 this.error = error instanceof Error ? error.message : String(error);
179 } finally {
180 if (seq === this._requestSeq) this.loading = false;
181 }
182 },
183
184 async loadMore() {
185 if (this.loading || !this.payload?.has_more || this.isLocked()) return;
186 const seq = ++this._requestSeq;
187 this.loading = true;
188 try {
189 const response = await callJsonApi(apiPath("history_list"), {
190 context_id: this.contextId,
191 workspace_id: this.selectedWorkspaceId,
192 limit: 100,
193 offset: this.commits().length,
194 file_filter: this.fileFilter,
195 });
196 if (seq !== this._requestSeq) return;
197 if (!response?.ok) throw new Error(response?.error || "Could not load history.");
198 this.payload.commits = [...this.commits(), ...(response.commits || [])];
199 this.payload.has_more = Boolean(response.has_more);
200 } catch (error) {
201 this.error = error instanceof Error ? error.message : String(error);
202 } finally {
203 if (seq === this._requestSeq) this.loading = false;
204 }
205 },
206
207 reconcileSelection(keepSelection = false) {
208 if (this.isLocked()) {
209 this.selectedHash = "";
210 this.selectedPath = "";
211 this.selectedDiff = null;
212 return;
213 }
214
215 const rows = this.timelineRows();
216 let selected = keepSelection ? rows.find((row) => row.key === this.selectedHash) : null;
217 if (!selected) selected = rows[0] || null;
218 this.selectedHash = selected?.key || "";
219
220 const files = this.selectedFiles();
221 if (!files.some((file) => this.fileKey(file) === this.selectedPath)) {
222 this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
223 }
224 void this.loadSelectedDiff();
225 },
226
227 commits() {
228 return Array.isArray(this.payload?.commits) ? this.payload.commits : [];
229 },
230
231 present() {
232 return this.payload?.present || {};
233 },
234
235 isLocked() {
236 return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
237 },
238
239 selectedWorkspace() {
240 return (this.workspaces || []).find((workspace) => workspace?.id === this.selectedWorkspaceId) || null;
241 },
242
243 workspaceOptionLabel(workspace) {
244 const label = String(workspace?.label || workspace?.title || workspace?.name || "Workspace");
245 const path = String(workspace?.display_path || workspace?.path || "");
246 const suffix = workspace?.locked || workspace?.available === false ? " (unavailable)" : "";
247 return path ? `${label} - ${path}${suffix}` : `${label}${suffix}`;
248 },
249
250 async selectWorkspace(workspaceId) {
251 const nextWorkspaceId = String(workspaceId || "");
252 if (!nextWorkspaceId || nextWorkspaceId === this.selectedWorkspaceId || this.busy) return;
253 this.selectedWorkspaceId = nextWorkspaceId;
254 this.fileFilter = "";
255 this.selectedHash = "";
256 this.selectedPath = "";
257 this.selectedDiff = null;
258 this.diffError = "";
259 this.previewOpen = false;
260 this.preview = null;
261 await this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
262 },
263
264 hasHistory() {
265 return this.commits().length > 0;
266 },
267
268 hasPresentChanges() {
269 return Boolean(this.present()?.dirty);
270 },
271
272 timelineRows() {
273 const rows = [];
274 rows.push({
275 key: "present",
276 kind: "present",
277 hash: this.payload?.current_hash || "",
278 short_hash: "present",
279 message: this.hasPresentChanges() ? "Present changes" : "Present clean",
280 timestamp: "",
281 files: this.present()?.files || [],
282 is_current: false,
283 dirty: this.hasPresentChanges(),
284 });
285 for (const commit of this.commits()) {
286 rows.push({ key: commit.hash, kind: "commit", ...commit });
287 }
288 return rows;
289 },
290
291 selectedRow() {
292 return this.timelineRows().find((row) => row.key === this.selectedHash) || null;
293 },
294
295 selectedCommit() {
296 const row = this.selectedRow();
297 return row?.kind === "commit" ? row : null;
298 },
299
300 selectedFiles() {
301 const row = this.selectedRow();
302 return Array.isArray(row?.files) ? row.files : [];
303 },
304
305 selectedFile() {
306 return this.selectedFiles().find((file) => this.fileKey(file) === this.selectedPath) || null;
307 },
308
309 selectRow(row) {
310 this.selectedHash = row?.key || "";
311 const files = this.selectedFiles();
312 this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
313 void this.loadSelectedDiff();
314 },
315
316 selectFile(file) {
317 this.selectedPath = this.fileKey(file);
318 void this.loadSelectedDiff();
319 },
320
321 fileKey(file) {
322 return `${file?.old_path || ""}:${file?.path || ""}`;
323 },
324
325 async loadSelectedDiff() {
326 const file = this.selectedFile();
327 const row = this.selectedRow();
328 this.selectedDiff = null;
329 this.diffError = "";
330 if (!file || !row || this.isLocked()) return;
331 const seq = ++this._diffSeq;
332 this.diffLoading = true;
333 try {
334 const response = await callJsonApi(apiPath("history_diff"), {
335 context_id: this.contextId,
336 workspace_id: this.selectedWorkspaceId,
337 commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
338 path: file.path || file.old_path,
339 mode: row.kind === "present" ? "present" : "commit",
340 });
341 if (seq !== this._diffSeq) return;
342 if (!response?.ok) throw new Error(response?.error || "Could not load diff.");
343 this.selectedDiff = response;
344 } catch (error) {
345 if (seq !== this._diffSeq) return;
346 this.diffError = error instanceof Error ? error.message : String(error);
347 } finally {
348 if (seq === this._diffSeq) this.diffLoading = false;
349 }
350 },
351
352 async manualSnapshot() {
353 if (this.busy || this.isLocked()) return;
354 this.busy = true;
355 this.error = "";
356 try {
357 const response = await callJsonApi(apiPath("history_snapshot"), {
358 context_id: this.contextId,
359 workspace_id: this.selectedWorkspaceId,
360 trigger: "manual",
361 });
362 if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
363 globalThis.justToast?.(response.snapshot?.created ? "Snapshot captured" : "No changes to snapshot", "success", 1400, "time-travel-snapshot");
364 await this.refresh({ keepSelection: true });
365 } catch (error) {
366 this.error = error instanceof Error ? error.message : String(error);
367 } finally {
368 this.busy = false;
369 }
370 },
371
372 async openPreview(operation, commit = null) {
373 const target = commit || this.selectedCommit();
374 if (!target || this.busy || this.isLocked()) return;
375 if (operation === "travel" && target.is_current) return;
376 this.previewOpen = true;
377 this.previewLoading = true;
378 this.previewError = "";
379 this.previewTechnicalDetails = "";
380 this.previewDetailsOpen = false;
381 this.preview = { operation, commit_hash: target.hash, short_hash: target.short_hash, files: [], previews: [] };
382 try {
383 const response = await callJsonApi(apiPath("history_preview"), {
384 context_id: this.contextId,
385 workspace_id: this.selectedWorkspaceId,
386 operation,
387 commit_hash: target.hash,
388 });
389 if (!response?.ok) throw new Error(response?.error || "Preview failed.");
390 this.preview = response;
391 } catch (error) {
392 this.previewError = error instanceof Error ? error.message : String(error);
393 } finally {
394 this.previewLoading = false;
395 }
396 },
397
398 closePreview() {
399 if (this.busy) return;
400 this.previewOpen = false;
401 this.preview = null;
402 this.previewError = "";
403 this.previewTechnicalDetails = "";
404 this.previewDetailsOpen = false;
405 },
406
407 async confirmPreview() {
408 if (!this.preview || this.busy || this.previewLoading) return;
409 const operation = this.preview.operation;
410 const endpoint = operation === "travel" ? "history_travel" : "history_revert";
411 this.busy = true;
412 this.previewError = "";
413 this.previewDetailsOpen = false;
414 try {
415 const response = await callJsonApi(apiPath(endpoint), {
416 context_id: this.contextId,
417 workspace_id: this.selectedWorkspaceId,
418 commit_hash: this.preview.commit_hash,
419 metadata: { source: "time_travel_ui" },
420 });
421 if (!response?.ok) {
422 const error = new Error(response?.error || `${operation} failed.`);
423 error.technicalDetails = response?.technical_details || "";
424 throw error;
425 }
426 globalThis.justToast?.(operation === "travel" ? "Workspace traveled" : "Revert applied", "success", 1500, "time-travel-apply");
427 this.previewOpen = false;
428 this.preview = null;
429 await this.refresh({ keepSelection: false });
430 } catch (error) {
431 this.previewError = error instanceof Error ? error.message : String(error);
432 this.previewTechnicalDetails = error?.technicalDetails || "";
433 } finally {
434 this.busy = false;
435 }
436 },
437
438 patchLines(diff = null) {
439 const patch = String((diff || this.selectedDiff)?.patch || "");
440 if (!patch) return [];
441 const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
442 return textLines.map((text, index) => ({
443 id: `${index}-${text.slice(0, 20)}`,
444 text,
445 type: lineType(text),
446 }));
447 },
448
449 fileTitle(file) {
450 if (file?.old_path && file.old_path !== file.path) {
451 return `${file.old_path} -> ${file.path}`;
452 }
453 return file?.path || file?.old_path || "";
454 },
455
456 statusLabel(file) {
457 return String(file?.action || file?.status || "changed").replaceAll("_", " ");
458 },
459
460 rowMeta(row) {
461 if (!row) return "";
462 const files = Array.isArray(row.files) ? row.files.length : 0;
463 if (row.kind === "present") return files ? `${files} file${files === 1 ? "" : "s"}` : "clean";
464 return `${row.short_hash || ""} · ${this.formatTime(row.timestamp)}`;
465 },
466
467 formatTime(value) {
468 if (!value) return "";
469 const date = new Date(value);
470 if (Number.isNaN(date.getTime())) return String(value);
471 return formatDateTime(value, "short");
472 },
473
474 formatSigned(value, sign) {
475 const number = Number(value) || 0;
476 return `${sign}${number.toLocaleString()}`;
477 },
478
479 fullPath(file) {
480 const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
481 const base = String(this.workspacePath || "").replace(/\/+$/, "");
482 return relativePath ? `${base}/${relativePath}` : base;
483 },
484
485 async openContainingFolder(file) {
486 const parent = dirname(this.fullPath(file));
487 await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
488 },
489
490 async copyPath(file) {
491 const path = this.fullPath(file);
492 try {
493 await navigator.clipboard.writeText(path);
494 globalThis.justToast?.("Path copied", "success", 1200, "time-travel-copy");
495 } catch (_error) {
496 globalThis.prompt?.("Copy path", path);
497 }
498 },
499 };
500
501 export const store = createStore("timeTravel", model);