@chrysb/alphaclaw 0.3.5-beta.0 → 0.3.5-beta.1

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 (44) hide show
  1. package/bin/alphaclaw.js +65 -1
  2. package/lib/public/css/explorer.css +201 -6
  3. package/lib/public/js/app.js +45 -1
  4. package/lib/public/js/components/channels.js +1 -0
  5. package/lib/public/js/components/file-tree.js +56 -67
  6. package/lib/public/js/components/file-viewer/constants.js +6 -0
  7. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  8. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  9. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  10. package/lib/public/js/components/file-viewer/index.js +164 -0
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  12. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  13. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  14. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  15. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  16. package/lib/public/js/components/file-viewer/storage.js +58 -0
  17. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  18. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  19. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  20. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  21. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  24. package/lib/public/js/components/file-viewer/utils.js +11 -0
  25. package/lib/public/js/components/gateway.js +83 -30
  26. package/lib/public/js/components/icons.js +13 -0
  27. package/lib/public/js/components/sidebar-git-panel.js +72 -11
  28. package/lib/public/js/components/usage-tab.js +4 -1
  29. package/lib/public/js/components/watchdog-tab.js +6 -0
  30. package/lib/public/js/lib/api.js +16 -0
  31. package/lib/public/js/lib/browse-file-policies.js +34 -0
  32. package/lib/scripts/git +40 -0
  33. package/lib/scripts/git-askpass +6 -0
  34. package/lib/server/constants.js +8 -0
  35. package/lib/server/routes/browse/constants.js +51 -0
  36. package/lib/server/routes/browse/file-helpers.js +43 -0
  37. package/lib/server/routes/browse/git.js +131 -0
  38. package/lib/server/routes/{browse.js → browse/index.js} +290 -218
  39. package/lib/server/routes/browse/path-utils.js +53 -0
  40. package/lib/server/routes/browse/sqlite.js +140 -0
  41. package/lib/server/routes/proxy.js +11 -5
  42. package/lib/setup/core-prompts/TOOLS.md +0 -4
  43. package/package.json +1 -1
  44. package/lib/public/js/components/file-viewer.js +0 -1095
@@ -1,1095 +0,0 @@
1
- import { h } from "https://esm.sh/preact";
2
- import {
3
- useCallback,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from "https://esm.sh/preact/hooks";
9
- import htm from "https://esm.sh/htm";
10
- import { marked } from "https://esm.sh/marked";
11
- import {
12
- fetchBrowseFileDiff,
13
- fetchFileContent,
14
- saveFileContent,
15
- } from "../lib/api.js";
16
- import {
17
- formatFrontmatterValue,
18
- getFileSyntaxKind,
19
- highlightEditorLines,
20
- parseFrontmatter,
21
- } from "../lib/syntax-highlighters/index.js";
22
- import {
23
- clearStoredFileDraft,
24
- readStoredFileDraft,
25
- updateDraftIndex,
26
- writeStoredFileDraft,
27
- } from "../lib/browse-draft-state.js";
28
- import { ActionButton } from "./action-button.js";
29
- import { LoadingSpinner } from "./loading-spinner.js";
30
- import { SegmentedControl } from "./segmented-control.js";
31
- import { LockLineIcon, SaveFillIcon } from "./icons.js";
32
- import { showToast } from "./toast.js";
33
-
34
- const html = htm.bind(h);
35
- const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode";
36
- const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode";
37
- const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath";
38
- const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]);
39
- const kLockedBrowsePaths = new Set([
40
- "hooks/bootstrap/agents.md",
41
- "hooks/bootstrap/tools.md",
42
- ".alphaclaw/hourly-git-sync.sh",
43
- ".alphaclaw/.cli-device-auto-approved",
44
- ]);
45
- const kLoadingIndicatorDelayMs = 1000;
46
- const kFileRefreshIntervalMs = 5000;
47
-
48
- const parsePathSegments = (inputPath) =>
49
- String(inputPath || "")
50
- .split("/")
51
- .map((part) => part.trim())
52
- .filter(Boolean);
53
-
54
- const normalizePolicyPath = (inputPath) =>
55
- String(inputPath || "")
56
- .replaceAll("\\", "/")
57
- .replace(/^\.\/+/, "")
58
- .replace(/^\/+/, "")
59
- .trim()
60
- .toLowerCase();
61
-
62
- const matchesPolicyPath = (policyPathSet, normalizedPath) => {
63
- const safeNormalizedPath = String(normalizedPath || "").trim();
64
- if (!safeNormalizedPath) return false;
65
- for (const policyPath of policyPathSet) {
66
- if (
67
- safeNormalizedPath === policyPath ||
68
- safeNormalizedPath.endsWith(`/${policyPath}`)
69
- ) {
70
- return true;
71
- }
72
- }
73
- return false;
74
- };
75
-
76
- const readStoredFileViewerMode = () => {
77
- try {
78
- const storedMode = String(
79
- window.localStorage.getItem(kFileViewerModeStorageKey) ||
80
- window.localStorage.getItem(kLegacyFileViewerModeStorageKey) ||
81
- "",
82
- ).trim();
83
- return storedMode === "preview" ? "preview" : "edit";
84
- } catch {
85
- return "edit";
86
- }
87
- };
88
-
89
- const clampSelectionIndex = (value, maxValue) => {
90
- const numericValue = Number.parseInt(String(value ?? ""), 10);
91
- if (!Number.isFinite(numericValue)) return 0;
92
- return Math.max(0, Math.min(maxValue, numericValue));
93
- };
94
-
95
- const readEditorSelectionStorageMap = () => {
96
- try {
97
- const rawStorageValue = window.localStorage.getItem(
98
- kEditorSelectionStorageKey,
99
- );
100
- if (!rawStorageValue) return {};
101
- const parsedStorageValue = JSON.parse(rawStorageValue);
102
- if (!parsedStorageValue || typeof parsedStorageValue !== "object")
103
- return {};
104
- return parsedStorageValue;
105
- } catch {
106
- return {};
107
- }
108
- };
109
-
110
- const readStoredEditorSelection = (filePath) => {
111
- const safePath = String(filePath || "").trim();
112
- if (!safePath) return null;
113
- const storageMap = readEditorSelectionStorageMap();
114
- const selection = storageMap[safePath];
115
- if (!selection || typeof selection !== "object") return null;
116
- return {
117
- start: selection.start,
118
- end: selection.end,
119
- };
120
- };
121
-
122
- const writeStoredEditorSelection = (filePath, selection) => {
123
- const safePath = String(filePath || "").trim();
124
- if (!safePath || !selection || typeof selection !== "object") return;
125
- try {
126
- const nextStorageValue = readEditorSelectionStorageMap();
127
- nextStorageValue[safePath] = {
128
- start: selection.start,
129
- end: selection.end,
130
- };
131
- window.localStorage.setItem(
132
- kEditorSelectionStorageKey,
133
- JSON.stringify(nextStorageValue),
134
- );
135
- } catch {}
136
- };
137
-
138
- export const FileViewer = ({
139
- filePath = "",
140
- isPreviewOnly = false,
141
- browseView = "edit",
142
- onRequestEdit = () => {},
143
- }) => {
144
- const normalizedPath = String(filePath || "").trim();
145
- const normalizedPolicyPath = normalizePolicyPath(normalizedPath);
146
- const [content, setContent] = useState("");
147
- const [initialContent, setInitialContent] = useState("");
148
- const [viewMode, setViewMode] = useState(readStoredFileViewerMode);
149
- const [loading, setLoading] = useState(false);
150
- const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] =
151
- useState(false);
152
- const [diffLoading, setDiffLoading] = useState(false);
153
- const [diffError, setDiffError] = useState("");
154
- const [diffContent, setDiffContent] = useState("");
155
- const [saving, setSaving] = useState(false);
156
- const [error, setError] = useState("");
157
- const [isFolderPath, setIsFolderPath] = useState(false);
158
- const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false);
159
- const [externalChangeNoticeShown, setExternalChangeNoticeShown] =
160
- useState(false);
161
- const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(
162
- () => new Set(),
163
- );
164
- const editorLineNumbersRef = useRef(null);
165
- const editorHighlightRef = useRef(null);
166
- const editorTextareaRef = useRef(null);
167
- const previewRef = useRef(null);
168
- const viewScrollRatioRef = useRef(0);
169
- const isSyncingScrollRef = useRef(false);
170
- const loadedFilePathRef = useRef("");
171
- const restoredSelectionPathRef = useRef("");
172
- const fileRefreshInFlightRef = useRef(false);
173
- const editorLineNumberRowRefs = useRef([]);
174
- const editorHighlightLineRefs = useRef([]);
175
-
176
- const pathSegments = useMemo(
177
- () => parsePathSegments(normalizedPath),
178
- [normalizedPath],
179
- );
180
- const hasSelectedPath = normalizedPath.length > 0;
181
- const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly;
182
- const isDiffView = String(browseView || "edit") === "diff";
183
- const isDirty = canEditFile && content !== initialContent;
184
- const isLockedFile =
185
- canEditFile && matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath);
186
- const isProtectedFile =
187
- canEditFile &&
188
- !isLockedFile &&
189
- matchesPolicyPath(kProtectedBrowsePaths, normalizedPolicyPath);
190
- const isProtectedLocked =
191
- isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath);
192
- const isEditBlocked = isLockedFile || isProtectedLocked;
193
- const syntaxKind = useMemo(
194
- () => getFileSyntaxKind(normalizedPath),
195
- [normalizedPath],
196
- );
197
- const isMarkdownFile = syntaxKind === "markdown";
198
- const shouldUseHighlightedEditor = syntaxKind !== "plain";
199
- const parsedFrontmatter = useMemo(
200
- () =>
201
- isMarkdownFile
202
- ? parseFrontmatter(content)
203
- : { entries: [], body: content },
204
- [content, isMarkdownFile],
205
- );
206
- const highlightedEditorLines = useMemo(
207
- () =>
208
- shouldUseHighlightedEditor
209
- ? highlightEditorLines(content, syntaxKind)
210
- : [],
211
- [content, shouldUseHighlightedEditor, syntaxKind],
212
- );
213
- const editorLineNumbers = useMemo(() => {
214
- const lineCount = String(content || "").split("\n").length;
215
- return Array.from({ length: lineCount }, (_, index) => index + 1);
216
- }, [content]);
217
-
218
- const syncEditorLineNumberHeights = useCallback(() => {
219
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return;
220
- const numberRows = editorLineNumberRowRefs.current;
221
- const highlightRows = editorHighlightLineRefs.current;
222
- const rowCount = Math.min(numberRows.length, highlightRows.length);
223
- for (let index = 0; index < rowCount; index += 1) {
224
- const numberRow = numberRows[index];
225
- const highlightRow = highlightRows[index];
226
- if (!numberRow || !highlightRow) continue;
227
- numberRow.style.height = `${highlightRow.offsetHeight}px`;
228
- }
229
- }, [shouldUseHighlightedEditor, viewMode]);
230
-
231
- useEffect(() => {
232
- syncEditorLineNumberHeights();
233
- }, [content, syncEditorLineNumberHeights]);
234
-
235
- useEffect(() => {
236
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {};
237
- const onResize = () => syncEditorLineNumberHeights();
238
- window.addEventListener("resize", onResize);
239
- return () => window.removeEventListener("resize", onResize);
240
- }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]);
241
- const previewHtml = useMemo(
242
- () =>
243
- isMarkdownFile
244
- ? marked.parse(parsedFrontmatter.body || "", {
245
- gfm: true,
246
- breaks: true,
247
- })
248
- : "",
249
- [parsedFrontmatter.body, isMarkdownFile],
250
- );
251
-
252
- useEffect(() => {
253
- if (!isMarkdownFile && viewMode !== "edit") {
254
- setViewMode("edit");
255
- }
256
- }, [isMarkdownFile, viewMode]);
257
-
258
- useEffect(() => {
259
- try {
260
- window.localStorage.setItem(kFileViewerModeStorageKey, viewMode);
261
- } catch {}
262
- }, [viewMode]);
263
-
264
- useEffect(() => {
265
- if (!loading) {
266
- setShowDelayedLoadingSpinner(false);
267
- return () => {};
268
- }
269
- const timer = window.setTimeout(() => {
270
- setShowDelayedLoadingSpinner(true);
271
- }, kLoadingIndicatorDelayMs);
272
- return () => window.clearTimeout(timer);
273
- }, [loading]);
274
-
275
- useEffect(() => {
276
- let active = true;
277
- loadedFilePathRef.current = "";
278
- restoredSelectionPathRef.current = "";
279
- if (!hasSelectedPath) {
280
- setContent("");
281
- setInitialContent("");
282
- setError("");
283
- setIsFolderPath(false);
284
- viewScrollRatioRef.current = 0;
285
- loadedFilePathRef.current = "";
286
- return () => {
287
- active = false;
288
- };
289
- }
290
-
291
- const loadFile = async () => {
292
- setLoading(true);
293
- setError("");
294
- setIsFolderPath(false);
295
- try {
296
- const data = await fetchFileContent(normalizedPath);
297
- if (!active) return;
298
- const nextContent = data.content || "";
299
- const draftContent = readStoredFileDraft(normalizedPath);
300
- setContent(draftContent || nextContent);
301
- updateDraftIndex(
302
- normalizedPath,
303
- Boolean(draftContent && draftContent !== nextContent),
304
- { dispatchEvent: (event) => window.dispatchEvent(event) },
305
- );
306
- setInitialContent(nextContent);
307
- setExternalChangeNoticeShown(false);
308
- viewScrollRatioRef.current = 0;
309
- loadedFilePathRef.current = normalizedPath;
310
- restoredSelectionPathRef.current = "";
311
- } catch (loadError) {
312
- if (!active) return;
313
- const message = loadError.message || "Could not load file";
314
- if (/path is not a file/i.test(message)) {
315
- setContent("");
316
- setInitialContent("");
317
- setIsFolderPath(true);
318
- setError("");
319
- loadedFilePathRef.current = normalizedPath;
320
- restoredSelectionPathRef.current = "";
321
- return;
322
- }
323
- setError(message);
324
- } finally {
325
- if (active) setLoading(false);
326
- }
327
- };
328
- loadFile();
329
- return () => {
330
- active = false;
331
- };
332
- }, [hasSelectedPath, normalizedPath]);
333
-
334
- useEffect(() => {
335
- if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {};
336
- const refreshFromDisk = async () => {
337
- if (loading || saving) return;
338
- if (fileRefreshInFlightRef.current) return;
339
- fileRefreshInFlightRef.current = true;
340
- try {
341
- const data = await fetchFileContent(normalizedPath);
342
- const diskContent = data.content || "";
343
- if (diskContent === initialContent) {
344
- setExternalChangeNoticeShown(false);
345
- return;
346
- }
347
- // Auto-refresh only when editor has no unsaved work.
348
- if (!isDirty) {
349
- setContent(diskContent);
350
- setInitialContent(diskContent);
351
- clearStoredFileDraft(normalizedPath);
352
- updateDraftIndex(normalizedPath, false, {
353
- dispatchEvent: (event) => window.dispatchEvent(event),
354
- });
355
- setExternalChangeNoticeShown(false);
356
- window.dispatchEvent(
357
- new CustomEvent("alphaclaw:browse-tree-refresh"),
358
- );
359
- return;
360
- }
361
- if (!externalChangeNoticeShown) {
362
- showToast(
363
- "This file changed on disk. Save to overwrite or reload by re-opening.",
364
- "error",
365
- );
366
- setExternalChangeNoticeShown(true);
367
- }
368
- } catch {
369
- // Ignore transient refresh errors to avoid interrupting editing.
370
- } finally {
371
- fileRefreshInFlightRef.current = false;
372
- }
373
- };
374
- const intervalId = window.setInterval(
375
- refreshFromDisk,
376
- kFileRefreshIntervalMs,
377
- );
378
- return () => {
379
- window.clearInterval(intervalId);
380
- };
381
- }, [
382
- hasSelectedPath,
383
- isFolderPath,
384
- canEditFile,
385
- loading,
386
- saving,
387
- normalizedPath,
388
- initialContent,
389
- isDirty,
390
- externalChangeNoticeShown,
391
- ]);
392
-
393
- useEffect(() => {
394
- let active = true;
395
- if (!hasSelectedPath || !isDiffView || isPreviewOnly) {
396
- setDiffLoading(false);
397
- setDiffError("");
398
- setDiffContent("");
399
- return () => {
400
- active = false;
401
- };
402
- }
403
- const loadDiff = async () => {
404
- setDiffLoading(true);
405
- setDiffError("");
406
- try {
407
- const data = await fetchBrowseFileDiff(normalizedPath);
408
- if (!active) return;
409
- setDiffContent(String(data?.content || ""));
410
- } catch (nextError) {
411
- if (!active) return;
412
- setDiffError(nextError.message || "Could not load diff");
413
- } finally {
414
- if (active) setDiffLoading(false);
415
- }
416
- };
417
- loadDiff();
418
- return () => {
419
- active = false;
420
- };
421
- }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]);
422
-
423
- useEffect(() => {
424
- if (loadedFilePathRef.current !== normalizedPath) return;
425
- if (!canEditFile || !hasSelectedPath || loading) return;
426
- if (content === initialContent) {
427
- clearStoredFileDraft(normalizedPath);
428
- updateDraftIndex(normalizedPath, false, {
429
- dispatchEvent: (event) => window.dispatchEvent(event),
430
- });
431
- return;
432
- }
433
- writeStoredFileDraft(normalizedPath, content);
434
- updateDraftIndex(normalizedPath, true, {
435
- dispatchEvent: (event) => window.dispatchEvent(event),
436
- });
437
- }, [
438
- canEditFile,
439
- hasSelectedPath,
440
- loading,
441
- content,
442
- initialContent,
443
- normalizedPath,
444
- ]);
445
-
446
- useEffect(() => {
447
- if (!canEditFile || loading || !hasSelectedPath) return () => {};
448
- if (loadedFilePathRef.current !== normalizedPath) return () => {};
449
- if (restoredSelectionPathRef.current === normalizedPath) return () => {};
450
- if (viewMode !== "edit") return () => {};
451
- const storedSelection = readStoredEditorSelection(normalizedPath);
452
- if (!storedSelection) {
453
- restoredSelectionPathRef.current = normalizedPath;
454
- return () => {};
455
- }
456
- let frameId = 0;
457
- let attempts = 0;
458
- const restoreSelection = () => {
459
- const textareaElement = editorTextareaRef.current;
460
- if (!textareaElement) {
461
- attempts += 1;
462
- if (attempts < 6)
463
- frameId = window.requestAnimationFrame(restoreSelection);
464
- return;
465
- }
466
- const maxIndex = String(content || "").length;
467
- const start = clampSelectionIndex(storedSelection.start, maxIndex);
468
- const end = clampSelectionIndex(storedSelection.end, maxIndex);
469
- textareaElement.focus();
470
- textareaElement.setSelectionRange(start, Math.max(start, end));
471
- window.requestAnimationFrame(() => {
472
- const nextTextareaElement = editorTextareaRef.current;
473
- if (!nextTextareaElement) return;
474
- const safeContent = String(content || "");
475
- const safeStart = clampSelectionIndex(start, safeContent.length);
476
- const lineIndex =
477
- safeContent.slice(0, safeStart).split("\n").length - 1;
478
- const computedStyle = window.getComputedStyle(nextTextareaElement);
479
- const parsedLineHeight = Number.parseFloat(
480
- computedStyle.lineHeight || "",
481
- );
482
- const lineHeight =
483
- Number.isFinite(parsedLineHeight) && parsedLineHeight > 0
484
- ? parsedLineHeight
485
- : 20;
486
- const nextScrollTop = Math.max(
487
- 0,
488
- lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4,
489
- );
490
- nextTextareaElement.scrollTop = nextScrollTop;
491
- if (editorLineNumbersRef.current) {
492
- editorLineNumbersRef.current.scrollTop = nextScrollTop;
493
- }
494
- if (editorHighlightRef.current) {
495
- editorHighlightRef.current.scrollTop = nextScrollTop;
496
- }
497
- viewScrollRatioRef.current = getScrollRatio(nextTextareaElement);
498
- });
499
- restoredSelectionPathRef.current = normalizedPath;
500
- };
501
- frameId = window.requestAnimationFrame(restoreSelection);
502
- return () => {
503
- if (frameId) window.cancelAnimationFrame(frameId);
504
- };
505
- }, [
506
- canEditFile,
507
- loading,
508
- hasSelectedPath,
509
- normalizedPath,
510
- content,
511
- viewMode,
512
- ]);
513
-
514
- const handleSave = useCallback(async () => {
515
- if (!canEditFile || saving || !isDirty || isEditBlocked) return;
516
- setSaving(true);
517
- setError("");
518
- try {
519
- await saveFileContent(normalizedPath, content);
520
- setInitialContent(content);
521
- setExternalChangeNoticeShown(false);
522
- clearStoredFileDraft(normalizedPath);
523
- updateDraftIndex(normalizedPath, false, {
524
- dispatchEvent: (event) => window.dispatchEvent(event),
525
- });
526
- window.dispatchEvent(
527
- new CustomEvent("alphaclaw:browse-file-saved", {
528
- detail: { path: normalizedPath },
529
- }),
530
- );
531
- showToast("Saved", "success");
532
- } catch (saveError) {
533
- const message = saveError.message || "Could not save file";
534
- setError(message);
535
- showToast(message, "error");
536
- } finally {
537
- setSaving(false);
538
- }
539
- }, [
540
- canEditFile,
541
- saving,
542
- isDirty,
543
- isEditBlocked,
544
- normalizedPath,
545
- content,
546
- initialContent,
547
- ]);
548
-
549
- useEffect(() => {
550
- const handleKeyDown = (event) => {
551
- const isSaveShortcut =
552
- (event.metaKey || event.ctrlKey) &&
553
- !event.shiftKey &&
554
- !event.altKey &&
555
- String(event.key || "").toLowerCase() === "s";
556
- if (!isSaveShortcut) return;
557
- if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return;
558
- event.preventDefault();
559
- void handleSave();
560
- };
561
- window.addEventListener("keydown", handleKeyDown);
562
- return () => window.removeEventListener("keydown", handleKeyDown);
563
- }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]);
564
-
565
- const handleEditProtectedFile = () => {
566
- if (!normalizedPolicyPath) return;
567
- setProtectedEditBypassPaths((previousPaths) => {
568
- const nextPaths = new Set(previousPaths);
569
- nextPaths.add(normalizedPolicyPath);
570
- return nextPaths;
571
- });
572
- };
573
-
574
- const handleContentInput = (event) => {
575
- if (isEditBlocked || isPreviewOnly) return;
576
- const nextContent = event.target.value;
577
- setContent(nextContent);
578
- if (hasSelectedPath && canEditFile) {
579
- writeStoredEditorSelection(normalizedPath, {
580
- start: event.target.selectionStart,
581
- end: event.target.selectionEnd,
582
- });
583
- }
584
- if (hasSelectedPath && canEditFile) {
585
- writeStoredFileDraft(normalizedPath, nextContent);
586
- updateDraftIndex(normalizedPath, nextContent !== initialContent, {
587
- dispatchEvent: (event) => window.dispatchEvent(event),
588
- });
589
- }
590
- };
591
-
592
- const handleEditorSelectionChange = () => {
593
- if (!hasSelectedPath || !canEditFile || loading) return;
594
- const textareaElement = editorTextareaRef.current;
595
- if (!textareaElement) return;
596
- writeStoredEditorSelection(normalizedPath, {
597
- start: textareaElement.selectionStart,
598
- end: textareaElement.selectionEnd,
599
- });
600
- };
601
-
602
- const getScrollRatio = (element) => {
603
- if (!element) return 0;
604
- const maxScrollTop = element.scrollHeight - element.clientHeight;
605
- if (maxScrollTop <= 0) return 0;
606
- return element.scrollTop / maxScrollTop;
607
- };
608
-
609
- const setScrollByRatio = (element, ratio) => {
610
- if (!element) return;
611
- const maxScrollTop = element.scrollHeight - element.clientHeight;
612
- if (maxScrollTop <= 0) {
613
- element.scrollTop = 0;
614
- return;
615
- }
616
- const clampedRatio = Math.max(0, Math.min(1, ratio));
617
- element.scrollTop = maxScrollTop * clampedRatio;
618
- };
619
-
620
- const handleEditorScroll = (event) => {
621
- if (isSyncingScrollRef.current) return;
622
- const nextScrollTop = event.currentTarget.scrollTop;
623
- const nextRatio = getScrollRatio(event.currentTarget);
624
- viewScrollRatioRef.current = nextRatio;
625
- if (!editorLineNumbersRef.current) return;
626
- editorLineNumbersRef.current.scrollTop = nextScrollTop;
627
- if (editorHighlightRef.current) {
628
- editorHighlightRef.current.scrollTop = nextScrollTop;
629
- editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft;
630
- }
631
- if (previewRef.current) {
632
- isSyncingScrollRef.current = true;
633
- setScrollByRatio(previewRef.current, nextRatio);
634
- window.requestAnimationFrame(() => {
635
- isSyncingScrollRef.current = false;
636
- });
637
- }
638
- };
639
-
640
- const handlePreviewScroll = (event) => {
641
- if (isSyncingScrollRef.current) return;
642
- const nextRatio = getScrollRatio(event.currentTarget);
643
- viewScrollRatioRef.current = nextRatio;
644
- isSyncingScrollRef.current = true;
645
- setScrollByRatio(editorTextareaRef.current, nextRatio);
646
- setScrollByRatio(editorLineNumbersRef.current, nextRatio);
647
- setScrollByRatio(editorHighlightRef.current, nextRatio);
648
- window.requestAnimationFrame(() => {
649
- isSyncingScrollRef.current = false;
650
- });
651
- };
652
-
653
- const handleChangeViewMode = (nextMode) => {
654
- if (nextMode === viewMode) return;
655
- const nextRatio =
656
- viewMode === "preview"
657
- ? getScrollRatio(previewRef.current)
658
- : getScrollRatio(editorTextareaRef.current);
659
- viewScrollRatioRef.current = nextRatio;
660
- setViewMode(nextMode);
661
- window.requestAnimationFrame(() => {
662
- isSyncingScrollRef.current = true;
663
- if (nextMode === "preview") {
664
- setScrollByRatio(previewRef.current, nextRatio);
665
- } else {
666
- setScrollByRatio(editorTextareaRef.current, nextRatio);
667
- setScrollByRatio(editorLineNumbersRef.current, nextRatio);
668
- setScrollByRatio(editorHighlightRef.current, nextRatio);
669
- }
670
- window.requestAnimationFrame(() => {
671
- isSyncingScrollRef.current = false;
672
- });
673
- });
674
- };
675
-
676
- if (!hasSelectedPath) {
677
- return html`
678
- <div class="file-viewer-empty">
679
- <div class="file-viewer-empty-mark">[ ]</div>
680
- <div class="file-viewer-empty-title">
681
- Browse and edit files<br />Syncs to git
682
- </div>
683
- </div>
684
- `;
685
- }
686
-
687
- return html`
688
- <div class="file-viewer">
689
- <div class="file-viewer-tabbar">
690
- <div class="file-viewer-tab active">
691
- <span class="file-icon">f</span>
692
- <span class="file-viewer-breadcrumb">
693
- ${pathSegments.map(
694
- (segment, index) => html`
695
- <span class="file-viewer-breadcrumb-item">
696
- <span
697
- class=${index === pathSegments.length - 1
698
- ? "is-current"
699
- : ""}
700
- >
701
- ${segment}
702
- </span>
703
- ${index < pathSegments.length - 1 &&
704
- html`<span class="file-viewer-sep">></span>`}
705
- </span>
706
- `,
707
- )}
708
- </span>
709
- ${isDirty
710
- ? html`<span
711
- class="file-viewer-dirty-dot"
712
- aria-hidden="true"
713
- ></span>`
714
- : null}
715
- </div>
716
- <div class="file-viewer-tabbar-spacer"></div>
717
- ${isPreviewOnly
718
- ? html`<div class="file-viewer-preview-pill">Preview</div>`
719
- : null}
720
- ${!isDiffView &&
721
- isMarkdownFile &&
722
- html`
723
- <${SegmentedControl}
724
- className="mr-2.5"
725
- options=${[
726
- { label: "edit", value: "edit" },
727
- { label: "preview", value: "preview" },
728
- ]}
729
- value=${viewMode}
730
- onChange=${handleChangeViewMode}
731
- />
732
- `}
733
- ${!isDiffView
734
- ? html`
735
- <${ActionButton}
736
- onClick=${handleSave}
737
- disabled=${loading || !isDirty || !canEditFile || isEditBlocked}
738
- loading=${saving}
739
- tone=${isDirty ? "primary" : "secondary"}
740
- size="sm"
741
- idleLabel="Save"
742
- loadingLabel="Saving..."
743
- idleIcon=${SaveFillIcon}
744
- idleIconClassName="file-viewer-save-icon"
745
- className="file-viewer-save-action"
746
- />
747
- `
748
- : null}
749
- </div>
750
- ${isDiffView
751
- ? html`
752
- <div class="file-viewer-protected-banner file-viewer-diff-banner">
753
- <div class="file-viewer-protected-banner-text">
754
- Viewing unsynced changes
755
- </div>
756
- <${ActionButton}
757
- onClick=${() => onRequestEdit(normalizedPath)}
758
- tone="secondary"
759
- size="sm"
760
- idleLabel="View file"
761
- />
762
- </div>
763
- `
764
- : null}
765
- ${!isDiffView && isLockedFile
766
- ? html`
767
- <div class="file-viewer-protected-banner is-locked">
768
- <${LockLineIcon} className="file-viewer-protected-banner-icon" />
769
- <div class="file-viewer-protected-banner-text">
770
- This file is managed by Alpha Claw and cannot be edited.
771
- </div>
772
- </div>
773
- `
774
- : null}
775
- ${!isDiffView && isProtectedFile
776
- ? html`
777
- <div class="file-viewer-protected-banner">
778
- <div class="file-viewer-protected-banner-text">
779
- Protected file. Changes may break workspace behavior.
780
- </div>
781
- ${isProtectedLocked
782
- ? html`
783
- <${ActionButton}
784
- onClick=${handleEditProtectedFile}
785
- tone="warning"
786
- size="sm"
787
- idleLabel="Edit anyway"
788
- />
789
- `
790
- : null}
791
- </div>
792
- `
793
- : null}
794
- ${isMarkdownFile && parsedFrontmatter.entries.length > 0
795
- ? html`
796
- <div class="frontmatter-box">
797
- <button
798
- type="button"
799
- class="frontmatter-title"
800
- onclick=${() =>
801
- setFrontmatterCollapsed((collapsed) => !collapsed)}
802
- >
803
- <span
804
- class=${`frontmatter-chevron ${frontmatterCollapsed ? "" : "open"}`}
805
- aria-hidden="true"
806
- >
807
- <svg viewBox="0 0 20 20" focusable="false">
808
- <path d="M7 4l6 6-6 6" />
809
- </svg>
810
- </span>
811
- <span>frontmatter</span>
812
- </button>
813
- ${!frontmatterCollapsed
814
- ? html`
815
- <div class="frontmatter-grid">
816
- ${parsedFrontmatter.entries.map((entry) => {
817
- const formattedValue = formatFrontmatterValue(
818
- entry.rawValue,
819
- );
820
- const isMultilineValue = formattedValue.includes("\n");
821
- return html`
822
- <div class="frontmatter-row" key=${entry.key}>
823
- <div class="frontmatter-key">${entry.key}</div>
824
- ${isMultilineValue
825
- ? html`
826
- <pre
827
- class="frontmatter-value frontmatter-value-pre"
828
- >
829
- ${formattedValue}</pre
830
- >
831
- `
832
- : html`<div class="frontmatter-value">
833
- ${formattedValue}
834
- </div>`}
835
- </div>
836
- `;
837
- })}
838
- </div>
839
- `
840
- : null}
841
- </div>
842
- `
843
- : null}
844
- ${loading
845
- ? html`
846
- <div class="file-viewer-loading-shell">
847
- ${showDelayedLoadingSpinner
848
- ? html`<${LoadingSpinner} className="h-4 w-4" />`
849
- : null}
850
- </div>
851
- `
852
- : error
853
- ? html`<div class="file-viewer-state file-viewer-state-error">
854
- ${error}
855
- </div>`
856
- : isFolderPath
857
- ? html`
858
- <div class="file-viewer-state">
859
- Folder selected. Choose a file from this folder in the tree.
860
- </div>
861
- `
862
- : isDiffView
863
- ? html`
864
- <div class="file-viewer-diff-shell">
865
- ${diffLoading
866
- ? html`
867
- <div class="file-viewer-loading-shell">
868
- <${LoadingSpinner} className="h-4 w-4" />
869
- </div>
870
- `
871
- : diffError
872
- ? html`
873
- <div
874
- class="file-viewer-state file-viewer-state-error"
875
- >
876
- ${diffError}
877
- </div>
878
- `
879
- : html`
880
- <pre class="file-viewer-diff-pre">
881
- ${(diffContent || "").split("\n").map((line, lineIndex) => {
882
- const lineClass =
883
- line.startsWith("+") &&
884
- !line.startsWith("+++")
885
- ? "is-added"
886
- : line.startsWith("-") &&
887
- !line.startsWith("---")
888
- ? "is-removed"
889
- : line.startsWith("@@")
890
- ? "is-hunk"
891
- : line.startsWith("diff ") ||
892
- line.startsWith("index ") ||
893
- line.startsWith("--- ") ||
894
- line.startsWith("+++ ")
895
- ? "is-header"
896
- : "";
897
- return html`
898
- <div
899
- key=${`${lineIndex}:${line.slice(0, 20)}`}
900
- class=${`file-viewer-diff-line ${lineClass}`.trim()}
901
- >
902
- ${line || " "}
903
- </div>
904
- `;
905
- })}
906
- </pre
907
- >
908
- `}
909
- </div>
910
- `
911
- : html`
912
- ${isMarkdownFile
913
- ? html`
914
- <div
915
- class=${`file-viewer-preview ${viewMode === "preview" ? "" : "file-viewer-pane-hidden"}`}
916
- ref=${previewRef}
917
- onscroll=${handlePreviewScroll}
918
- aria-hidden=${viewMode === "preview"
919
- ? "false"
920
- : "true"}
921
- dangerouslySetInnerHTML=${{ __html: previewHtml }}
922
- ></div>
923
- <div
924
- class=${`file-viewer-editor-shell ${viewMode === "edit" ? "" : "file-viewer-pane-hidden"}`}
925
- aria-hidden=${viewMode === "edit" ? "false" : "true"}
926
- >
927
- <div
928
- class="file-viewer-editor-line-num-col"
929
- ref=${editorLineNumbersRef}
930
- >
931
- ${editorLineNumbers.map(
932
- (lineNumber) => html`
933
- <div
934
- class="file-viewer-editor-line-num"
935
- key=${lineNumber}
936
- ref=${(element) => {
937
- editorLineNumberRowRefs.current[
938
- lineNumber - 1
939
- ] = element;
940
- }}
941
- >
942
- ${lineNumber}
943
- </div>
944
- `,
945
- )}
946
- </div>
947
- <div class="file-viewer-editor-stack">
948
- <div
949
- class="file-viewer-editor-highlight"
950
- ref=${editorHighlightRef}
951
- >
952
- ${highlightedEditorLines.map(
953
- (line) => html`
954
- <div
955
- class="file-viewer-editor-highlight-line"
956
- key=${line.lineNumber}
957
- ref=${(element) => {
958
- editorHighlightLineRefs.current[
959
- line.lineNumber - 1
960
- ] = element;
961
- }}
962
- >
963
- <span
964
- class="file-viewer-editor-highlight-line-content"
965
- dangerouslySetInnerHTML=${{
966
- __html: line.html,
967
- }}
968
- ></span>
969
- </div>
970
- `,
971
- )}
972
- </div>
973
- <textarea
974
- class="file-viewer-editor file-viewer-editor-overlay"
975
- ref=${editorTextareaRef}
976
- value=${content}
977
- onInput=${handleContentInput}
978
- onScroll=${handleEditorScroll}
979
- onSelect=${handleEditorSelectionChange}
980
- onKeyUp=${handleEditorSelectionChange}
981
- onClick=${handleEditorSelectionChange}
982
- disabled=${isEditBlocked || isPreviewOnly}
983
- readonly=${isEditBlocked || isPreviewOnly}
984
- spellcheck=${false}
985
- autocorrect="off"
986
- autocapitalize="off"
987
- autocomplete="off"
988
- data-gramm="false"
989
- data-gramm_editor="false"
990
- data-enable-grammarly="false"
991
- wrap="soft"
992
- ></textarea>
993
- </div>
994
- </div>
995
- `
996
- : html`
997
- <div class="file-viewer-editor-shell">
998
- <div
999
- class="file-viewer-editor-line-num-col"
1000
- ref=${editorLineNumbersRef}
1001
- >
1002
- ${editorLineNumbers.map(
1003
- (lineNumber) => html`
1004
- <div
1005
- class="file-viewer-editor-line-num"
1006
- key=${lineNumber}
1007
- ref=${(element) => {
1008
- editorLineNumberRowRefs.current[
1009
- lineNumber - 1
1010
- ] = element;
1011
- }}
1012
- >
1013
- ${lineNumber}
1014
- </div>
1015
- `,
1016
- )}
1017
- </div>
1018
- ${shouldUseHighlightedEditor
1019
- ? html`
1020
- <div class="file-viewer-editor-stack">
1021
- <div
1022
- class="file-viewer-editor-highlight"
1023
- ref=${editorHighlightRef}
1024
- >
1025
- ${highlightedEditorLines.map(
1026
- (line) => html`
1027
- <div
1028
- class="file-viewer-editor-highlight-line"
1029
- key=${line.lineNumber}
1030
- ref=${(element) => {
1031
- editorHighlightLineRefs.current[
1032
- line.lineNumber - 1
1033
- ] = element;
1034
- }}
1035
- >
1036
- <span
1037
- class="file-viewer-editor-highlight-line-content"
1038
- dangerouslySetInnerHTML=${{
1039
- __html: line.html,
1040
- }}
1041
- ></span>
1042
- </div>
1043
- `,
1044
- )}
1045
- </div>
1046
- <textarea
1047
- class="file-viewer-editor file-viewer-editor-overlay"
1048
- ref=${editorTextareaRef}
1049
- value=${content}
1050
- onInput=${handleContentInput}
1051
- onScroll=${handleEditorScroll}
1052
- onSelect=${handleEditorSelectionChange}
1053
- onKeyUp=${handleEditorSelectionChange}
1054
- onClick=${handleEditorSelectionChange}
1055
- disabled=${isEditBlocked || isPreviewOnly}
1056
- readonly=${isEditBlocked || isPreviewOnly}
1057
- spellcheck=${false}
1058
- autocorrect="off"
1059
- autocapitalize="off"
1060
- autocomplete="off"
1061
- data-gramm="false"
1062
- data-gramm_editor="false"
1063
- data-enable-grammarly="false"
1064
- wrap="soft"
1065
- ></textarea>
1066
- </div>
1067
- `
1068
- : html`
1069
- <textarea
1070
- class="file-viewer-editor"
1071
- ref=${editorTextareaRef}
1072
- value=${content}
1073
- onInput=${handleContentInput}
1074
- onScroll=${handleEditorScroll}
1075
- onSelect=${handleEditorSelectionChange}
1076
- onKeyUp=${handleEditorSelectionChange}
1077
- onClick=${handleEditorSelectionChange}
1078
- disabled=${isEditBlocked || isPreviewOnly}
1079
- readonly=${isEditBlocked || isPreviewOnly}
1080
- spellcheck=${false}
1081
- autocorrect="off"
1082
- autocapitalize="off"
1083
- autocomplete="off"
1084
- data-gramm="false"
1085
- data-gramm_editor="false"
1086
- data-enable-grammarly="false"
1087
- wrap="soft"
1088
- ></textarea>
1089
- `}
1090
- </div>
1091
- `}
1092
- `}
1093
- </div>
1094
- `;
1095
- };