@chrysb/alphaclaw 0.3.4 → 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 (55) hide show
  1. package/bin/alphaclaw.js +82 -3
  2. package/lib/public/css/explorer.css +385 -9
  3. package/lib/public/css/theme.css +1 -1
  4. package/lib/public/js/app.js +102 -8
  5. package/lib/public/js/components/channels.js +1 -0
  6. package/lib/public/js/components/file-tree.js +74 -38
  7. package/lib/public/js/components/file-viewer/constants.js +6 -0
  8. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  9. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  10. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  11. package/lib/public/js/components/file-viewer/index.js +164 -0
  12. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  13. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  14. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  15. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  16. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  17. package/lib/public/js/components/file-viewer/storage.js +58 -0
  18. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  19. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  20. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  21. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  24. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  25. package/lib/public/js/components/file-viewer/utils.js +11 -0
  26. package/lib/public/js/components/gateway.js +95 -48
  27. package/lib/public/js/components/icons.js +26 -0
  28. package/lib/public/js/components/sidebar-git-panel.js +219 -31
  29. package/lib/public/js/components/sidebar.js +1 -1
  30. package/lib/public/js/components/usage-tab.js +4 -1
  31. package/lib/public/js/components/watchdog-tab.js +6 -2
  32. package/lib/public/js/lib/api.js +31 -0
  33. package/lib/public/js/lib/browse-file-policies.js +34 -0
  34. package/lib/scripts/git +40 -0
  35. package/lib/scripts/git-askpass +6 -0
  36. package/lib/server/constants.js +8 -0
  37. package/lib/server/helpers.js +18 -5
  38. package/lib/server/internal-files-migration.js +93 -0
  39. package/lib/server/onboarding/cron.js +6 -4
  40. package/lib/server/onboarding/index.js +7 -0
  41. package/lib/server/onboarding/openclaw.js +6 -1
  42. package/lib/server/routes/browse/constants.js +51 -0
  43. package/lib/server/routes/browse/file-helpers.js +43 -0
  44. package/lib/server/routes/browse/git.js +131 -0
  45. package/lib/server/routes/browse/index.js +572 -0
  46. package/lib/server/routes/browse/path-utils.js +53 -0
  47. package/lib/server/routes/browse/sqlite.js +140 -0
  48. package/lib/server/routes/pairings.js +8 -2
  49. package/lib/server/routes/proxy.js +11 -5
  50. package/lib/server/routes/system.js +5 -1
  51. package/lib/server.js +7 -0
  52. package/lib/setup/core-prompts/TOOLS.md +0 -4
  53. package/package.json +1 -1
  54. package/lib/public/js/components/file-viewer.js +0 -864
  55. package/lib/server/routes/browse.js +0 -295
@@ -0,0 +1,58 @@
1
+ import {
2
+ kEditorSelectionStorageKey,
3
+ kFileViewerModeStorageKey,
4
+ kLegacyFileViewerModeStorageKey,
5
+ } from "./constants.js";
6
+
7
+ export const readStoredFileViewerMode = () => {
8
+ try {
9
+ const storedMode = String(
10
+ window.localStorage.getItem(kFileViewerModeStorageKey) ||
11
+ window.localStorage.getItem(kLegacyFileViewerModeStorageKey) ||
12
+ "",
13
+ ).trim();
14
+ return storedMode === "preview" ? "preview" : "edit";
15
+ } catch {
16
+ return "edit";
17
+ }
18
+ };
19
+
20
+ export const readEditorSelectionStorageMap = () => {
21
+ try {
22
+ const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey);
23
+ if (!rawStorageValue) return {};
24
+ const parsedStorageValue = JSON.parse(rawStorageValue);
25
+ if (!parsedStorageValue || typeof parsedStorageValue !== "object") return {};
26
+ return parsedStorageValue;
27
+ } catch {
28
+ return {};
29
+ }
30
+ };
31
+
32
+ export const readStoredEditorSelection = (filePath) => {
33
+ const safePath = String(filePath || "").trim();
34
+ if (!safePath) return null;
35
+ const storageMap = readEditorSelectionStorageMap();
36
+ const selection = storageMap[safePath];
37
+ if (!selection || typeof selection !== "object") return null;
38
+ return {
39
+ start: selection.start,
40
+ end: selection.end,
41
+ };
42
+ };
43
+
44
+ export const writeStoredEditorSelection = (filePath, selection) => {
45
+ const safePath = String(filePath || "").trim();
46
+ if (!safePath || !selection || typeof selection !== "object") return;
47
+ try {
48
+ const nextStorageValue = readEditorSelectionStorageMap();
49
+ nextStorageValue[safePath] = {
50
+ start: selection.start,
51
+ end: selection.end,
52
+ };
53
+ window.localStorage.setItem(
54
+ kEditorSelectionStorageKey,
55
+ JSON.stringify(nextStorageValue),
56
+ );
57
+ } catch {}
58
+ };
@@ -0,0 +1,77 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../action-button.js";
4
+ import { SegmentedControl } from "../segmented-control.js";
5
+ import { SaveFillIcon } from "../icons.js";
6
+
7
+ const html = htm.bind(h);
8
+
9
+ export const FileViewerToolbar = ({
10
+ pathSegments,
11
+ isDirty,
12
+ isPreviewOnly,
13
+ isDiffView,
14
+ isMarkdownFile,
15
+ viewMode,
16
+ handleChangeViewMode,
17
+ handleSave,
18
+ loading,
19
+ canEditFile,
20
+ isEditBlocked,
21
+ isImageFile,
22
+ isAudioFile,
23
+ isSqliteFile,
24
+ saving,
25
+ }) => html`
26
+ <div class="file-viewer-tabbar">
27
+ <div class="file-viewer-tab active">
28
+ <span class="file-icon">f</span>
29
+ <span class="file-viewer-breadcrumb">
30
+ ${pathSegments.map(
31
+ (segment, index) => html`
32
+ <span class="file-viewer-breadcrumb-item">
33
+ <span class=${index === pathSegments.length - 1 ? "is-current" : ""}>
34
+ ${segment}
35
+ </span>
36
+ ${index < pathSegments.length - 1 && html`<span class="file-viewer-sep">></span>`}
37
+ </span>
38
+ `,
39
+ )}
40
+ </span>
41
+ ${isDirty ? html`<span class="file-viewer-dirty-dot" aria-hidden="true"></span>` : null}
42
+ </div>
43
+ <div class="file-viewer-tabbar-spacer"></div>
44
+ ${isPreviewOnly ? html`<div class="file-viewer-preview-pill">Preview</div>` : null}
45
+ ${!isDiffView &&
46
+ isMarkdownFile &&
47
+ html`
48
+ <${SegmentedControl}
49
+ className="mr-2.5"
50
+ options=${[
51
+ { label: "edit", value: "edit" },
52
+ { label: "preview", value: "preview" },
53
+ ]}
54
+ value=${viewMode}
55
+ onChange=${handleChangeViewMode}
56
+ />
57
+ `}
58
+ ${!isDiffView
59
+ ? !isImageFile && !isAudioFile && !isSqliteFile
60
+ ? html`
61
+ <${ActionButton}
62
+ onClick=${handleSave}
63
+ disabled=${loading || !isDirty || !canEditFile || isEditBlocked}
64
+ loading=${saving}
65
+ tone=${isDirty ? "primary" : "secondary"}
66
+ size="sm"
67
+ idleLabel="Save"
68
+ loadingLabel="Saving..."
69
+ idleIcon=${SaveFillIcon}
70
+ idleIconClassName="file-viewer-save-icon"
71
+ className="file-viewer-save-action"
72
+ />
73
+ `
74
+ : null
75
+ : null}
76
+ </div>
77
+ `;
@@ -0,0 +1,87 @@
1
+ import { useEffect } from "https://esm.sh/preact/hooks";
2
+ import { readStoredEditorSelection } from "./storage.js";
3
+ import { clampSelectionIndex } from "./utils.js";
4
+ import { getScrollRatio } from "./scroll-sync.js";
5
+
6
+ export const useEditorSelectionRestore = ({
7
+ canEditFile,
8
+ loading,
9
+ hasSelectedPath,
10
+ normalizedPath,
11
+ loadedFilePathRef,
12
+ restoredSelectionPathRef,
13
+ viewMode,
14
+ content,
15
+ editorTextareaRef,
16
+ editorLineNumbersRef,
17
+ editorHighlightRef,
18
+ viewScrollRatioRef,
19
+ }) => {
20
+ useEffect(() => {
21
+ if (!canEditFile || loading || !hasSelectedPath) return () => {};
22
+ if (loadedFilePathRef.current !== normalizedPath) return () => {};
23
+ if (restoredSelectionPathRef.current === normalizedPath) return () => {};
24
+ if (viewMode !== "edit") return () => {};
25
+ const storedSelection = readStoredEditorSelection(normalizedPath);
26
+ if (!storedSelection) {
27
+ restoredSelectionPathRef.current = normalizedPath;
28
+ return () => {};
29
+ }
30
+ let frameId = 0;
31
+ let attempts = 0;
32
+ const restoreSelection = () => {
33
+ const textareaElement = editorTextareaRef.current;
34
+ if (!textareaElement) {
35
+ attempts += 1;
36
+ if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection);
37
+ return;
38
+ }
39
+ const maxIndex = String(content || "").length;
40
+ const start = clampSelectionIndex(storedSelection.start, maxIndex);
41
+ const end = clampSelectionIndex(storedSelection.end, maxIndex);
42
+ textareaElement.focus();
43
+ textareaElement.setSelectionRange(start, Math.max(start, end));
44
+ window.requestAnimationFrame(() => {
45
+ const nextTextareaElement = editorTextareaRef.current;
46
+ if (!nextTextareaElement) return;
47
+ const safeContent = String(content || "");
48
+ const safeStart = clampSelectionIndex(start, safeContent.length);
49
+ const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1;
50
+ const computedStyle = window.getComputedStyle(nextTextareaElement);
51
+ const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || "");
52
+ const lineHeight =
53
+ Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20;
54
+ const nextScrollTop = Math.max(
55
+ 0,
56
+ lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4,
57
+ );
58
+ nextTextareaElement.scrollTop = nextScrollTop;
59
+ if (editorLineNumbersRef.current) {
60
+ editorLineNumbersRef.current.scrollTop = nextScrollTop;
61
+ }
62
+ if (editorHighlightRef.current) {
63
+ editorHighlightRef.current.scrollTop = nextScrollTop;
64
+ }
65
+ viewScrollRatioRef.current = getScrollRatio(nextTextareaElement);
66
+ });
67
+ restoredSelectionPathRef.current = normalizedPath;
68
+ };
69
+ frameId = window.requestAnimationFrame(restoreSelection);
70
+ return () => {
71
+ if (frameId) window.cancelAnimationFrame(frameId);
72
+ };
73
+ }, [
74
+ canEditFile,
75
+ loading,
76
+ hasSelectedPath,
77
+ normalizedPath,
78
+ content,
79
+ viewMode,
80
+ loadedFilePathRef,
81
+ restoredSelectionPathRef,
82
+ editorTextareaRef,
83
+ editorLineNumbersRef,
84
+ editorHighlightRef,
85
+ viewScrollRatioRef,
86
+ ]);
87
+ };
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchBrowseFileDiff } from "../../lib/api.js";
3
+
4
+ export const useFileDiff = ({
5
+ hasSelectedPath,
6
+ isDiffView,
7
+ isPreviewOnly,
8
+ normalizedPath,
9
+ }) => {
10
+ const [diffLoading, setDiffLoading] = useState(false);
11
+ const [diffError, setDiffError] = useState("");
12
+ const [diffContent, setDiffContent] = useState("");
13
+
14
+ useEffect(() => {
15
+ let active = true;
16
+ if (!hasSelectedPath || !isDiffView || isPreviewOnly) {
17
+ setDiffLoading(false);
18
+ setDiffError("");
19
+ setDiffContent("");
20
+ return () => {
21
+ active = false;
22
+ };
23
+ }
24
+ const loadDiff = async () => {
25
+ setDiffLoading(true);
26
+ setDiffError("");
27
+ try {
28
+ const data = await fetchBrowseFileDiff(normalizedPath);
29
+ if (!active) return;
30
+ setDiffContent(String(data?.content || ""));
31
+ } catch (nextError) {
32
+ if (!active) return;
33
+ setDiffError(nextError.message || "Could not load diff");
34
+ } finally {
35
+ if (active) setDiffLoading(false);
36
+ }
37
+ };
38
+ loadDiff();
39
+ return () => {
40
+ active = false;
41
+ };
42
+ }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]);
43
+
44
+ return {
45
+ diffLoading,
46
+ diffError,
47
+ diffContent,
48
+ };
49
+ };
@@ -0,0 +1,302 @@
1
+ import { useEffect, useRef } from "https://esm.sh/preact/hooks";
2
+ import { fetchBrowseSqliteTable, fetchFileContent } from "../../lib/api.js";
3
+ import {
4
+ clearStoredFileDraft,
5
+ readStoredFileDraft,
6
+ updateDraftIndex,
7
+ } from "../../lib/browse-draft-state.js";
8
+ import { showToast } from "../toast.js";
9
+ import { kFileRefreshIntervalMs, kSqlitePageSize } from "./constants.js";
10
+
11
+ export const useFileLoader = ({
12
+ hasSelectedPath,
13
+ normalizedPath,
14
+ isSqliteFile,
15
+ sqliteSelectedTable,
16
+ sqliteTableOffset,
17
+ canEditFile,
18
+ isFolderPath,
19
+ loading,
20
+ saving,
21
+ initialContent,
22
+ isDirty,
23
+ setContent,
24
+ setInitialContent,
25
+ setFileKind,
26
+ setImageDataUrl,
27
+ setAudioDataUrl,
28
+ setSqliteSummary,
29
+ setSqliteSelectedTable,
30
+ setSqliteTableOffset,
31
+ setSqliteTableLoading,
32
+ setSqliteTableError,
33
+ setSqliteTableData,
34
+ setError,
35
+ setIsFolderPath,
36
+ setExternalChangeNoticeShown,
37
+ externalChangeNoticeShown,
38
+ viewScrollRatioRef,
39
+ setLoading,
40
+ }) => {
41
+ const loadedFilePathRef = useRef("");
42
+ const restoredSelectionPathRef = useRef("");
43
+ const fileRefreshInFlightRef = useRef(false);
44
+
45
+ useEffect(() => {
46
+ let active = true;
47
+ loadedFilePathRef.current = "";
48
+ restoredSelectionPathRef.current = "";
49
+ if (!hasSelectedPath) {
50
+ setContent("");
51
+ setInitialContent("");
52
+ setFileKind("text");
53
+ setImageDataUrl("");
54
+ setAudioDataUrl("");
55
+ setSqliteSummary(null);
56
+ setSqliteSelectedTable("");
57
+ setSqliteTableOffset(0);
58
+ setSqliteTableLoading(false);
59
+ setSqliteTableError("");
60
+ setSqliteTableData(null);
61
+ setError("");
62
+ setIsFolderPath(false);
63
+ viewScrollRatioRef.current = 0;
64
+ loadedFilePathRef.current = "";
65
+ return () => {
66
+ active = false;
67
+ };
68
+ }
69
+ // Clear previous file state immediately so large content from the last
70
+ // file is never rendered/parses under the next file's syntax mode.
71
+ setContent("");
72
+ setInitialContent("");
73
+ setImageDataUrl("");
74
+ setAudioDataUrl("");
75
+ setSqliteSummary(null);
76
+ setSqliteSelectedTable("");
77
+ setSqliteTableOffset(0);
78
+ setSqliteTableLoading(false);
79
+ setSqliteTableError("");
80
+ setSqliteTableData(null);
81
+ setFileKind("text");
82
+ setError("");
83
+ setIsFolderPath(false);
84
+ setExternalChangeNoticeShown(false);
85
+ viewScrollRatioRef.current = 0;
86
+
87
+ const loadFile = async () => {
88
+ setLoading(true);
89
+ setError("");
90
+ setIsFolderPath(false);
91
+ try {
92
+ const data = await fetchFileContent(normalizedPath);
93
+ if (!active) return;
94
+ const nextFileKind =
95
+ data?.kind === "image"
96
+ ? "image"
97
+ : data?.kind === "audio"
98
+ ? "audio"
99
+ : data?.kind === "sqlite"
100
+ ? "sqlite"
101
+ : "text";
102
+ setFileKind(nextFileKind);
103
+ if (nextFileKind === "image") {
104
+ setImageDataUrl(String(data?.imageDataUrl || ""));
105
+ setAudioDataUrl("");
106
+ setSqliteSummary(null);
107
+ setSqliteSelectedTable("");
108
+ setSqliteTableOffset(0);
109
+ setSqliteTableLoading(false);
110
+ setSqliteTableError("");
111
+ setSqliteTableData(null);
112
+ setContent("");
113
+ setInitialContent("");
114
+ setExternalChangeNoticeShown(false);
115
+ viewScrollRatioRef.current = 0;
116
+ loadedFilePathRef.current = normalizedPath;
117
+ restoredSelectionPathRef.current = "";
118
+ return;
119
+ }
120
+ if (nextFileKind === "audio") {
121
+ setAudioDataUrl(String(data?.audioDataUrl || ""));
122
+ setImageDataUrl("");
123
+ setSqliteSummary(null);
124
+ setSqliteSelectedTable("");
125
+ setSqliteTableOffset(0);
126
+ setSqliteTableLoading(false);
127
+ setSqliteTableError("");
128
+ setSqliteTableData(null);
129
+ setContent("");
130
+ setInitialContent("");
131
+ setExternalChangeNoticeShown(false);
132
+ viewScrollRatioRef.current = 0;
133
+ loadedFilePathRef.current = normalizedPath;
134
+ restoredSelectionPathRef.current = "";
135
+ return;
136
+ }
137
+ if (nextFileKind === "sqlite") {
138
+ const nextSqliteSummary = data?.sqliteSummary || null;
139
+ const nextObjects = nextSqliteSummary?.objects || [];
140
+ const defaultTable =
141
+ nextObjects.find((entry) => entry?.type === "table")?.name ||
142
+ nextObjects[0]?.name ||
143
+ "";
144
+ setSqliteSummary(nextSqliteSummary);
145
+ setSqliteSelectedTable(defaultTable);
146
+ setSqliteTableOffset(0);
147
+ setSqliteTableLoading(false);
148
+ setSqliteTableError("");
149
+ setSqliteTableData(null);
150
+ setImageDataUrl("");
151
+ setAudioDataUrl("");
152
+ setContent("");
153
+ setInitialContent("");
154
+ setExternalChangeNoticeShown(false);
155
+ viewScrollRatioRef.current = 0;
156
+ loadedFilePathRef.current = normalizedPath;
157
+ restoredSelectionPathRef.current = "";
158
+ return;
159
+ }
160
+ setImageDataUrl("");
161
+ setAudioDataUrl("");
162
+ setSqliteSummary(null);
163
+ setSqliteSelectedTable("");
164
+ setSqliteTableOffset(0);
165
+ setSqliteTableLoading(false);
166
+ setSqliteTableError("");
167
+ setSqliteTableData(null);
168
+ const nextContent = data.content || "";
169
+ const draftContent = readStoredFileDraft(normalizedPath);
170
+ setContent(draftContent || nextContent);
171
+ updateDraftIndex(normalizedPath, Boolean(draftContent && draftContent !== nextContent), {
172
+ dispatchEvent: (event) => window.dispatchEvent(event),
173
+ });
174
+ setInitialContent(nextContent);
175
+ setExternalChangeNoticeShown(false);
176
+ viewScrollRatioRef.current = 0;
177
+ loadedFilePathRef.current = normalizedPath;
178
+ restoredSelectionPathRef.current = "";
179
+ } catch (loadError) {
180
+ if (!active) return;
181
+ setFileKind("text");
182
+ setImageDataUrl("");
183
+ setAudioDataUrl("");
184
+ setSqliteSummary(null);
185
+ setSqliteSelectedTable("");
186
+ setSqliteTableOffset(0);
187
+ setSqliteTableLoading(false);
188
+ setSqliteTableError("");
189
+ setSqliteTableData(null);
190
+ const message = loadError.message || "Could not load file";
191
+ if (/path is not a file/i.test(message)) {
192
+ setContent("");
193
+ setInitialContent("");
194
+ setIsFolderPath(true);
195
+ setError("");
196
+ loadedFilePathRef.current = normalizedPath;
197
+ restoredSelectionPathRef.current = "";
198
+ return;
199
+ }
200
+ setError(message);
201
+ } finally {
202
+ if (active) setLoading(false);
203
+ }
204
+ };
205
+ loadFile();
206
+ return () => {
207
+ active = false;
208
+ };
209
+ }, [hasSelectedPath, normalizedPath]);
210
+
211
+ useEffect(() => {
212
+ if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) {
213
+ setSqliteTableData(null);
214
+ setSqliteTableError("");
215
+ setSqliteTableLoading(false);
216
+ return () => {};
217
+ }
218
+ let active = true;
219
+ const loadSqliteTable = async () => {
220
+ setSqliteTableLoading(true);
221
+ setSqliteTableError("");
222
+ try {
223
+ const tableData = await fetchBrowseSqliteTable({
224
+ filePath: normalizedPath,
225
+ table: sqliteSelectedTable,
226
+ limit: kSqlitePageSize,
227
+ offset: sqliteTableOffset,
228
+ });
229
+ if (!active) return;
230
+ setSqliteTableData(tableData);
231
+ } catch (nextError) {
232
+ if (!active) return;
233
+ setSqliteTableError(nextError.message || "Could not load sqlite table");
234
+ } finally {
235
+ if (active) setSqliteTableLoading(false);
236
+ }
237
+ };
238
+ loadSqliteTable();
239
+ return () => {
240
+ active = false;
241
+ };
242
+ }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]);
243
+
244
+ useEffect(() => {
245
+ if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {};
246
+ const refreshFromDisk = async () => {
247
+ if (loading || saving) return;
248
+ if (fileRefreshInFlightRef.current) return;
249
+ fileRefreshInFlightRef.current = true;
250
+ try {
251
+ const data = await fetchFileContent(normalizedPath);
252
+ const diskContent = data.content || "";
253
+ if (diskContent === initialContent) {
254
+ setExternalChangeNoticeShown(false);
255
+ return;
256
+ }
257
+ // Auto-refresh only when editor has no unsaved work.
258
+ if (!isDirty) {
259
+ setContent(diskContent);
260
+ setInitialContent(diskContent);
261
+ clearStoredFileDraft(normalizedPath);
262
+ updateDraftIndex(normalizedPath, false, {
263
+ dispatchEvent: (event) => window.dispatchEvent(event),
264
+ });
265
+ setExternalChangeNoticeShown(false);
266
+ window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));
267
+ return;
268
+ }
269
+ if (!externalChangeNoticeShown) {
270
+ showToast(
271
+ "This file changed on disk. Save to overwrite or reload by re-opening.",
272
+ "error",
273
+ );
274
+ setExternalChangeNoticeShown(true);
275
+ }
276
+ } catch {
277
+ // Ignore transient refresh errors to avoid interrupting editing.
278
+ } finally {
279
+ fileRefreshInFlightRef.current = false;
280
+ }
281
+ };
282
+ const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs);
283
+ return () => {
284
+ window.clearInterval(intervalId);
285
+ };
286
+ }, [
287
+ hasSelectedPath,
288
+ isFolderPath,
289
+ canEditFile,
290
+ loading,
291
+ saving,
292
+ normalizedPath,
293
+ initialContent,
294
+ isDirty,
295
+ externalChangeNoticeShown,
296
+ ]);
297
+
298
+ return {
299
+ loadedFilePathRef,
300
+ restoredSelectionPathRef,
301
+ };
302
+ };
@@ -0,0 +1,32 @@
1
+ import { useEffect } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ clearStoredFileDraft,
4
+ updateDraftIndex,
5
+ writeStoredFileDraft,
6
+ } from "../../lib/browse-draft-state.js";
7
+
8
+ export const useFileViewerDraftSync = ({
9
+ loadedFilePathRef,
10
+ normalizedPath,
11
+ canEditFile,
12
+ hasSelectedPath,
13
+ loading,
14
+ content,
15
+ initialContent,
16
+ }) => {
17
+ useEffect(() => {
18
+ if (loadedFilePathRef.current !== normalizedPath) return;
19
+ if (!canEditFile || !hasSelectedPath || loading) return;
20
+ if (content === initialContent) {
21
+ clearStoredFileDraft(normalizedPath);
22
+ updateDraftIndex(normalizedPath, false, {
23
+ dispatchEvent: (event) => window.dispatchEvent(event),
24
+ });
25
+ return;
26
+ }
27
+ writeStoredFileDraft(normalizedPath, content);
28
+ updateDraftIndex(normalizedPath, true, {
29
+ dispatchEvent: (event) => window.dispatchEvent(event),
30
+ });
31
+ }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]);
32
+ };
@@ -0,0 +1,25 @@
1
+ import { useEffect } from "https://esm.sh/preact/hooks";
2
+
3
+ export const useFileViewerHotkeys = ({
4
+ canEditFile,
5
+ isPreviewOnly,
6
+ isDiffView,
7
+ viewMode,
8
+ handleSave,
9
+ }) => {
10
+ useEffect(() => {
11
+ const handleKeyDown = (event) => {
12
+ const isSaveShortcut =
13
+ (event.metaKey || event.ctrlKey) &&
14
+ !event.shiftKey &&
15
+ !event.altKey &&
16
+ String(event.key || "").toLowerCase() === "s";
17
+ if (!isSaveShortcut) return;
18
+ if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return;
19
+ event.preventDefault();
20
+ void handleSave();
21
+ };
22
+ window.addEventListener("keydown", handleKeyDown);
23
+ return () => window.removeEventListener("keydown", handleKeyDown);
24
+ }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]);
25
+ };