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

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 (68) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. package/lib/public/js/components/usage-tab.js +0 -531
@@ -1,5 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useState, useRef } from "https://esm.sh/preact/hooks";
2
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { saveGoogleCredentials } from "../lib/api.js";
5
5
  import { SecretInput } from "./secret-input.js";
@@ -9,16 +9,37 @@ import { ActionButton } from "./action-button.js";
9
9
  import { CloseIcon } from "./icons.js";
10
10
  const html = htm.bind(h);
11
11
 
12
- export const CredentialsModal = ({ visible, onClose, onSaved }) => {
12
+ export const CredentialsModal = ({
13
+ visible,
14
+ onClose,
15
+ onSaved,
16
+ title = "Connect Google Workspace",
17
+ submitLabel = "Connect Google",
18
+ defaultInstrType = "workspace",
19
+ client = "default",
20
+ personal = false,
21
+ accountId = "",
22
+ initialValues = {},
23
+ }) => {
13
24
  const [clientId, setClientId] = useState("");
14
25
  const [clientSecret, setClientSecret] = useState("");
15
26
  const [email, setEmail] = useState("");
16
27
  const [error, setError] = useState("");
17
28
  const [saving, setSaving] = useState(false);
18
- const [instrType, setInstrType] = useState("workspace");
29
+ const [instrType, setInstrType] = useState(defaultInstrType);
19
30
  const [redirectUriCopied, setRedirectUriCopied] = useState(false);
20
31
  const fileRef = useRef(null);
21
32
 
33
+ useEffect(() => {
34
+ if (!visible) return;
35
+ setClientId(String(initialValues.clientId || ""));
36
+ setClientSecret(String(initialValues.clientSecret || ""));
37
+ setEmail(String(initialValues.email || ""));
38
+ setInstrType(defaultInstrType);
39
+ setError("");
40
+ setRedirectUriCopied(false);
41
+ }, [visible, initialValues, defaultInstrType]);
42
+
22
43
  if (!visible) return null;
23
44
 
24
45
  const redirectUri = `${window.location.origin}/auth/google/callback`;
@@ -50,15 +71,22 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
50
71
  const submit = async () => {
51
72
  setError("");
52
73
  if (!clientId || !clientSecret || !email) {
53
- setError("All fields required");
74
+ setError("Client ID, Client Secret, and Email are required");
54
75
  return;
55
76
  }
56
77
  setSaving(true);
57
78
  try {
58
- const data = await saveGoogleCredentials(clientId, clientSecret, email);
79
+ const data = await saveGoogleCredentials({
80
+ clientId,
81
+ clientSecret,
82
+ email,
83
+ client,
84
+ personal,
85
+ accountId,
86
+ });
59
87
  if (data.ok) {
60
88
  onClose();
61
- onSaved();
89
+ onSaved?.(data.account);
62
90
  } else setError(data.error || "Failed to save credentials");
63
91
  } catch {
64
92
  setError("Request failed");
@@ -104,7 +132,7 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
104
132
  panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
105
133
  >
106
134
  <${PageHeader}
107
- title="Connect Google Workspace"
135
+ title=${title}
108
136
  actions=${html`
109
137
  <button
110
138
  type="button"
@@ -338,7 +366,7 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
338
366
  loading=${saving}
339
367
  tone="primary"
340
368
  size="lg"
341
- idleLabel="Connect Google"
369
+ idleLabel=${submitLabel}
342
370
  loadingLabel="Saving..."
343
371
  className="w-full px-4 py-2 rounded-lg text-sm"
344
372
  />
@@ -8,12 +8,14 @@ import {
8
8
  } from "https://esm.sh/preact/hooks";
9
9
  import htm from "https://esm.sh/htm";
10
10
  import { fetchBrowseTree } from "../lib/api.js";
11
+ import { deleteBrowseFile } from "../lib/api.js";
11
12
  import {
12
13
  kDraftIndexChangedEventName,
13
14
  readStoredDraftPaths,
14
15
  } from "../lib/browse-draft-state.js";
15
16
  import {
16
17
  kLockedBrowsePaths,
18
+ kProtectedBrowsePaths,
17
19
  matchesBrowsePolicyPath,
18
20
  normalizeBrowsePolicyPath,
19
21
  } from "../lib/browse-file-policies.js";
@@ -32,6 +34,8 @@ import {
32
34
  LockLineIcon,
33
35
  } from "./icons.js";
34
36
  import { LoadingSpinner } from "./loading-spinner.js";
37
+ import { ConfirmDialog } from "./confirm-dialog.js";
38
+ import { showToast } from "./toast.js";
35
39
 
36
40
  const html = htm.bind(h);
37
41
  const kTreeIndentPx = 9;
@@ -71,6 +75,23 @@ const collectFilePaths = (node, filePaths) => {
71
75
  );
72
76
  };
73
77
 
78
+ const removeTreePath = (node, targetPath) => {
79
+ if (!node) return null;
80
+ const safeTargetPath = String(targetPath || "").trim();
81
+ if (!safeTargetPath) return node;
82
+ const nodePath = String(node.path || "").trim();
83
+ if (nodePath === safeTargetPath) return null;
84
+ if (node.type !== "folder") return node;
85
+ const nextChildren = (node.children || [])
86
+ .map((childNode) => removeTreePath(childNode, safeTargetPath))
87
+ .filter(Boolean);
88
+ if (nextChildren.length === (node.children || []).length) return node;
89
+ return {
90
+ ...node,
91
+ children: nextChildren,
92
+ };
93
+ };
94
+
74
95
  const filterTreeNode = (node, normalizedQuery) => {
75
96
  if (!node) return null;
76
97
  const query = String(normalizedQuery || "")
@@ -95,50 +116,68 @@ const filterTreeNode = (node, normalizedQuery) => {
95
116
 
96
117
  const getFileIconMeta = (fileName) => {
97
118
  const normalizedName = String(fileName || "").toLowerCase();
98
- if (normalizedName.endsWith(".md")) {
119
+ const normalizedNameWithoutBakSuffix = normalizedName.replace(/(\.bak)+$/i, "");
120
+ if (normalizedNameWithoutBakSuffix.endsWith(".md")) {
99
121
  return {
100
122
  icon: MarkdownFillIcon,
101
123
  className: "file-icon file-icon-md",
102
124
  };
103
125
  }
104
- if (normalizedName.endsWith(".js") || normalizedName.endsWith(".mjs")) {
126
+ if (
127
+ normalizedNameWithoutBakSuffix.endsWith(".js") ||
128
+ normalizedNameWithoutBakSuffix.endsWith(".mjs")
129
+ ) {
105
130
  return {
106
131
  icon: JavascriptFillIcon,
107
132
  className: "file-icon file-icon-js",
108
133
  };
109
134
  }
110
- if (normalizedName.endsWith(".json") || normalizedName.endsWith(".jsonl")) {
135
+ if (
136
+ normalizedNameWithoutBakSuffix.endsWith(".json") ||
137
+ normalizedNameWithoutBakSuffix.endsWith(".jsonl")
138
+ ) {
111
139
  return {
112
140
  icon: BracesLineIcon,
113
141
  className: "file-icon file-icon-json",
114
142
  };
115
143
  }
116
- if (normalizedName.endsWith(".css") || normalizedName.endsWith(".scss")) {
144
+ if (
145
+ normalizedNameWithoutBakSuffix.endsWith(".css") ||
146
+ normalizedNameWithoutBakSuffix.endsWith(".scss")
147
+ ) {
117
148
  return {
118
149
  icon: HashtagIcon,
119
150
  className: "file-icon file-icon-css",
120
151
  };
121
152
  }
122
- if (/\.(html?)$/i.test(normalizedName)) {
153
+ if (/\.(html?)$/i.test(normalizedNameWithoutBakSuffix)) {
123
154
  return {
124
155
  icon: FileCodeLineIcon,
125
156
  className: "file-icon file-icon-html",
126
157
  };
127
158
  }
128
- if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(normalizedName)) {
159
+ if (
160
+ /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(
161
+ normalizedNameWithoutBakSuffix,
162
+ )
163
+ ) {
129
164
  return {
130
165
  icon: Image2FillIcon,
131
166
  className: "file-icon file-icon-image",
132
167
  };
133
168
  }
134
- if (/\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(normalizedName)) {
169
+ if (
170
+ /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(
171
+ normalizedNameWithoutBakSuffix,
172
+ )
173
+ ) {
135
174
  return {
136
175
  icon: FileMusicLineIcon,
137
176
  className: "file-icon file-icon-audio",
138
177
  };
139
178
  }
140
179
  if (
141
- /\.(sh|bash|zsh|command)$/i.test(normalizedName) ||
180
+ /\.(sh|bash|zsh|command)$/i.test(normalizedNameWithoutBakSuffix) ||
142
181
  [
143
182
  ".bashrc",
144
183
  ".zshrc",
@@ -146,7 +185,7 @@ const getFileIconMeta = (fileName) => {
146
185
  ".bash_profile",
147
186
  ".zprofile",
148
187
  ".zshenv",
149
- ].includes(normalizedName)
188
+ ].includes(normalizedNameWithoutBakSuffix)
150
189
  ) {
151
190
  return {
152
191
  icon: TerminalFillIcon,
@@ -155,7 +194,7 @@ const getFileIconMeta = (fileName) => {
155
194
  }
156
195
  if (
157
196
  /\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test(
158
- normalizedName,
197
+ normalizedNameWithoutBakSuffix,
159
198
  )
160
199
  ) {
161
200
  return {
@@ -173,7 +212,9 @@ const TreeNode = ({
173
212
  node,
174
213
  depth = 0,
175
214
  expandedPaths,
176
- onToggleFolder,
215
+ onSetFolderExpanded,
216
+ onSelectFolder,
217
+ onRequestDelete,
177
218
  onSelectFile,
178
219
  selectedPath = "",
179
220
  draftPaths,
@@ -196,6 +237,15 @@ const TreeNode = ({
196
237
  <a
197
238
  class=${`${isActive ? "active" : ""} ${isSearchActiveNode && !isActive ? "soft-active" : ""}`.trim()}
198
239
  onclick=${() => onSelectFile(node.path)}
240
+ onKeyDown=${(event) => {
241
+ const isDeleteKey =
242
+ event.key === "Delete" || event.key === "Backspace";
243
+ if (!isDeleteKey || !isActive) return;
244
+ event.preventDefault();
245
+ onRequestDelete(node.path);
246
+ }}
247
+ tabindex="0"
248
+ role="button"
199
249
  style=${{
200
250
  paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`,
201
251
  }}
@@ -218,17 +268,44 @@ const TreeNode = ({
218
268
 
219
269
  const folderPath = node.path || "";
220
270
  const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath);
271
+ const isFolderActive = selectedPath === folderPath;
221
272
  return html`
222
273
  <li class="tree-item">
223
274
  <div
224
- class=${`tree-folder ${isCollapsed ? "collapsed" : ""}`}
225
- onclick=${() => onToggleFolder(folderPath)}
275
+ class=${`tree-folder ${isCollapsed ? "collapsed" : ""} ${isFolderActive ? "active" : ""}`.trim()}
276
+ onclick=${() => {
277
+ if (!folderPath) return;
278
+ if (isFolderActive) {
279
+ onSetFolderExpanded(folderPath, false);
280
+ onSelectFolder("");
281
+ return;
282
+ }
283
+ onSetFolderExpanded(folderPath, true);
284
+ onSelectFolder(folderPath);
285
+ }}
226
286
  style=${{
227
287
  paddingLeft: `${kFolderBasePaddingPx + depth * kTreeIndentPx}px`,
228
288
  }}
229
289
  title=${folderPath || node.name}
230
290
  >
231
- <span class="arrow">▼</span>
291
+ <button
292
+ type="button"
293
+ class="tree-folder-toggle"
294
+ aria-label=${`${isCollapsed ? "Expand" : "Collapse"} ${node.name || "folder"}`}
295
+ aria-expanded=${isCollapsed ? "false" : "true"}
296
+ onclick=${(event) => {
297
+ event.preventDefault();
298
+ event.stopPropagation();
299
+ if (!folderPath) return;
300
+ const shouldCollapse = !isCollapsed;
301
+ if (isFolderActive && shouldCollapse) {
302
+ onSelectFolder("");
303
+ }
304
+ onSetFolderExpanded(folderPath, isCollapsed);
305
+ }}
306
+ >
307
+ <span class="arrow">▼</span>
308
+ </button>
232
309
  <span class="tree-label">${node.name}</span>
233
310
  </div>
234
311
  <ul class=${`tree-children ${isCollapsed ? "hidden" : ""}`}>
@@ -239,7 +316,9 @@ const TreeNode = ({
239
316
  node=${childNode}
240
317
  depth=${depth + 1}
241
318
  expandedPaths=${expandedPaths}
242
- onToggleFolder=${onToggleFolder}
319
+ onSetFolderExpanded=${onSetFolderExpanded}
320
+ onSelectFolder=${onSelectFolder}
321
+ onRequestDelete=${onRequestDelete}
243
322
  onSelectFile=${onSelectFile}
244
323
  selectedPath=${selectedPath}
245
324
  draftPaths=${draftPaths}
@@ -257,6 +336,7 @@ export const FileTree = ({
257
336
  onSelectFile = () => {},
258
337
  selectedPath = "",
259
338
  onPreviewFile = () => {},
339
+ isActive = true,
260
340
  }) => {
261
341
  const [treeRoot, setTreeRoot] = useState(null);
262
342
  const [loading, setLoading] = useState(true);
@@ -265,6 +345,8 @@ export const FileTree = ({
265
345
  const [draftPaths, setDraftPaths] = useState(readStoredDraftPaths);
266
346
  const [searchQuery, setSearchQuery] = useState("");
267
347
  const [searchActivePath, setSearchActivePath] = useState("");
348
+ const [deleteTargetPath, setDeleteTargetPath] = useState("");
349
+ const [deletingFile, setDeletingFile] = useState(false);
268
350
  const searchInputRef = useRef(null);
269
351
  const treeSignatureRef = useRef("");
270
352
 
@@ -297,21 +379,30 @@ export const FileTree = ({
297
379
  }, [loadTree]);
298
380
 
299
381
  useEffect(() => {
382
+ if (!isActive) return () => {};
300
383
  const refreshTree = () => {
301
384
  loadTree({ showLoading: false });
302
385
  };
386
+ const handleFileDeleted = (event) => {
387
+ const deletedPath = String(event?.detail?.path || "").trim();
388
+ if (!deletedPath) return;
389
+ setTreeRoot((previousRoot) => removeTreePath(previousRoot, deletedPath));
390
+ };
391
+ refreshTree();
303
392
  const refreshInterval = window.setInterval(
304
393
  refreshTree,
305
394
  kTreeRefreshIntervalMs,
306
395
  );
307
396
  window.addEventListener("alphaclaw:browse-file-saved", refreshTree);
308
397
  window.addEventListener("alphaclaw:browse-tree-refresh", refreshTree);
398
+ window.addEventListener("alphaclaw:browse-file-deleted", handleFileDeleted);
309
399
  return () => {
310
400
  window.clearInterval(refreshInterval);
311
401
  window.removeEventListener("alphaclaw:browse-file-saved", refreshTree);
312
402
  window.removeEventListener("alphaclaw:browse-tree-refresh", refreshTree);
403
+ window.removeEventListener("alphaclaw:browse-file-deleted", handleFileDeleted);
313
404
  };
314
- }, [loadTree]);
405
+ }, [isActive, loadTree]);
315
406
 
316
407
  const normalizedSearchQuery = String(searchQuery || "")
317
408
  .trim()
@@ -331,6 +422,16 @@ export const FileTree = ({
331
422
  rootChildren.forEach((node) => collectFilePaths(node, filePaths));
332
423
  return filePaths;
333
424
  }, [rootChildren]);
425
+ const allTreeFilePaths = useMemo(() => {
426
+ const filePaths = [];
427
+ (treeRoot?.children || []).forEach((node) => collectFilePaths(node, filePaths));
428
+ return new Set(filePaths);
429
+ }, [treeRoot]);
430
+ const folderPaths = useMemo(() => {
431
+ const nextFolderPaths = new Set();
432
+ rootChildren.forEach((node) => collectFolderPaths(node, nextFolderPaths));
433
+ return nextFolderPaths;
434
+ }, [rootChildren]);
334
435
 
335
436
  useEffect(() => {
336
437
  if (!(expandedPaths instanceof Set)) return;
@@ -345,12 +446,16 @@ export const FileTree = ({
345
446
  useEffect(() => {
346
447
  if (!selectedPath) return;
347
448
  const ancestorFolderPaths = collectAncestorFolderPaths(selectedPath);
348
- if (!ancestorFolderPaths.length) return;
449
+ const selectedIsFolder = folderPaths.has(selectedPath);
450
+ const pathsToExpand = selectedIsFolder
451
+ ? [...ancestorFolderPaths, selectedPath]
452
+ : ancestorFolderPaths;
453
+ if (!pathsToExpand.length) return;
349
454
  setExpandedPaths((previousPaths) => {
350
455
  if (!(previousPaths instanceof Set)) return previousPaths;
351
456
  let didChange = false;
352
457
  const nextPaths = new Set(previousPaths);
353
- ancestorFolderPaths.forEach((ancestorPath) => {
458
+ pathsToExpand.forEach((ancestorPath) => {
354
459
  if (!nextPaths.has(ancestorPath)) {
355
460
  nextPaths.add(ancestorPath);
356
461
  didChange = true;
@@ -358,7 +463,7 @@ export const FileTree = ({
358
463
  });
359
464
  return didChange ? nextPaths : previousPaths;
360
465
  });
361
- }, [selectedPath]);
466
+ }, [selectedPath, folderPaths]);
362
467
 
363
468
  useEffect(() => {
364
469
  const handleDraftIndexChanged = (event) => {
@@ -390,6 +495,7 @@ export const FileTree = ({
390
495
  }, []);
391
496
 
392
497
  useEffect(() => {
498
+ if (!isActive) return () => {};
393
499
  const handleGlobalSearchShortcut = (event) => {
394
500
  if (event.key !== "/") return;
395
501
  if (event.metaKey || event.ctrlKey || event.altKey) return;
@@ -409,7 +515,7 @@ export const FileTree = ({
409
515
  return () => {
410
516
  window.removeEventListener("keydown", handleGlobalSearchShortcut);
411
517
  };
412
- }, []);
518
+ }, [isActive]);
413
519
 
414
520
  useEffect(() => {
415
521
  if (!isSearchActive) {
@@ -423,16 +529,82 @@ export const FileTree = ({
423
529
  onPreviewFile("");
424
530
  }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]);
425
531
 
426
- const toggleFolder = (folderPath) => {
532
+ const setFolderExpanded = (folderPath, nextExpanded) => {
427
533
  setExpandedPaths((previousPaths) => {
428
534
  const nextPaths =
429
535
  previousPaths instanceof Set ? new Set(previousPaths) : new Set();
536
+ if (nextExpanded === true) {
537
+ nextPaths.add(folderPath);
538
+ return nextPaths;
539
+ }
540
+ if (nextExpanded === false) {
541
+ nextPaths.delete(folderPath);
542
+ return nextPaths;
543
+ }
430
544
  if (nextPaths.has(folderPath)) nextPaths.delete(folderPath);
431
545
  else nextPaths.add(folderPath);
432
546
  return nextPaths;
433
547
  });
434
548
  };
435
549
 
550
+ const selectFolder = (folderPath) => {
551
+ onSelectFile(folderPath, {
552
+ directory: true,
553
+ preservePreview: true,
554
+ });
555
+ };
556
+
557
+ const requestDelete = (targetPath) => {
558
+ const normalizedTargetPath = normalizeBrowsePolicyPath(targetPath);
559
+ if (!normalizedTargetPath) return;
560
+ if (!allTreeFilePaths.has(targetPath)) {
561
+ showToast("Only files can be deleted", "warning");
562
+ return;
563
+ }
564
+ if (
565
+ matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedTargetPath) ||
566
+ matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedTargetPath)
567
+ ) {
568
+ showToast("Protected or locked files cannot be deleted", "warning");
569
+ return;
570
+ }
571
+ setDeleteTargetPath(targetPath);
572
+ };
573
+
574
+ const confirmDelete = async () => {
575
+ if (!deleteTargetPath || deletingFile) return;
576
+ setDeletingFile(true);
577
+ try {
578
+ await deleteBrowseFile(deleteTargetPath);
579
+ window.dispatchEvent(
580
+ new CustomEvent("alphaclaw:browse-file-saved", {
581
+ detail: { path: deleteTargetPath },
582
+ }),
583
+ );
584
+ window.dispatchEvent(
585
+ new CustomEvent("alphaclaw:browse-file-deleted", {
586
+ detail: { path: deleteTargetPath },
587
+ }),
588
+ );
589
+ setTreeRoot((previousRoot) =>
590
+ removeTreePath(previousRoot, deleteTargetPath),
591
+ );
592
+ window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));
593
+ onSelectFile("");
594
+ showToast("File deleted", "success");
595
+ setDeleteTargetPath("");
596
+ } catch (deleteError) {
597
+ const message = deleteError.message || "Could not delete file";
598
+ if (/path is not a file/i.test(message)) {
599
+ showToast("Only files can be deleted", "warning");
600
+ } else {
601
+ showToast(message, "error");
602
+ }
603
+ } finally {
604
+ setDeletingFile(false);
605
+ }
606
+ };
607
+
436
608
  const updateSearchQuery = (nextQuery) => {
437
609
  setSearchQuery(nextQuery);
438
610
  };
@@ -546,7 +718,9 @@ export const FileTree = ({
546
718
  key=${node.path || node.name}
547
719
  node=${node}
548
720
  expandedPaths=${safeExpandedPaths}
549
- onToggleFolder=${toggleFolder}
721
+ onSetFolderExpanded=${setFolderExpanded}
722
+ onSelectFolder=${selectFolder}
723
+ onRequestDelete=${requestDelete}
550
724
  onSelectFile=${onSelectFile}
551
725
  selectedPath=${selectedPath}
552
726
  draftPaths=${draftPaths}
@@ -556,6 +730,22 @@ export const FileTree = ({
556
730
  `,
557
731
  )}
558
732
  </ul>
733
+ <${ConfirmDialog}
734
+ visible=${!!deleteTargetPath}
735
+ title="Delete file?"
736
+ message=${`Delete ${deleteTargetPath || "this file"}? This can be restored from diff view before sync.`}
737
+ confirmLabel="Delete"
738
+ confirmLoadingLabel="Deleting..."
739
+ cancelLabel="Cancel"
740
+ confirmTone="warning"
741
+ confirmLoading=${deletingFile}
742
+ confirmDisabled=${deletingFile}
743
+ onCancel=${() => {
744
+ if (deletingFile) return;
745
+ setDeleteTargetPath("");
746
+ }}
747
+ onConfirm=${confirmDelete}
748
+ />
559
749
  </div>
560
750
  `;
561
751
  };
@@ -8,6 +8,7 @@ const EditorTextarea = ({
8
8
  editorTextareaRef,
9
9
  renderContent,
10
10
  handleContentInput,
11
+ handleEditorKeyDown,
11
12
  handleEditorScroll,
12
13
  handleEditorSelectionChange,
13
14
  isEditBlocked,
@@ -18,6 +19,7 @@ const EditorTextarea = ({
18
19
  ref=${editorTextareaRef}
19
20
  value=${renderContent}
20
21
  onInput=${handleContentInput}
22
+ onKeyDown=${handleEditorKeyDown}
21
23
  onScroll=${handleEditorScroll}
22
24
  onSelect=${handleEditorSelectionChange}
23
25
  onKeyUp=${handleEditorSelectionChange}
@@ -48,6 +50,7 @@ export const EditorSurface = ({
48
50
  editorTextareaRef,
49
51
  renderContent,
50
52
  handleContentInput,
53
+ handleEditorKeyDown,
51
54
  handleEditorScroll,
52
55
  handleEditorSelectionChange,
53
56
  isEditBlocked,
@@ -97,6 +100,7 @@ export const EditorSurface = ({
97
100
  editorTextareaRef=${editorTextareaRef}
98
101
  renderContent=${renderContent}
99
102
  handleContentInput=${handleContentInput}
103
+ handleEditorKeyDown=${handleEditorKeyDown}
100
104
  handleEditorScroll=${handleEditorScroll}
101
105
  handleEditorSelectionChange=${handleEditorSelectionChange}
102
106
  isEditBlocked=${isEditBlocked}
@@ -110,6 +114,7 @@ export const EditorSurface = ({
110
114
  editorTextareaRef=${editorTextareaRef}
111
115
  renderContent=${renderContent}
112
116
  handleContentInput=${handleContentInput}
117
+ handleEditorKeyDown=${handleEditorKeyDown}
113
118
  handleEditorScroll=${handleEditorScroll}
114
119
  handleEditorSelectionChange=${handleEditorSelectionChange}
115
120
  isEditBlocked=${isEditBlocked}
@@ -1,6 +1,8 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
  import { LoadingSpinner } from "../loading-spinner.js";
5
+ import { ConfirmDialog } from "../confirm-dialog.js";
4
6
  import { SqliteViewer } from "./sqlite-viewer.js";
5
7
  import { FileViewerToolbar } from "./toolbar.js";
6
8
  import { FileViewerStatusBanners } from "./status-banners.js";
@@ -19,11 +21,15 @@ export const FileViewer = ({
19
21
  isPreviewOnly = false,
20
22
  browseView = "edit",
21
23
  onRequestEdit = () => {},
24
+ onRequestClearSelection = () => {},
22
25
  }) => {
26
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
23
27
  const { state, derived, refs, actions, context } = useFileViewer({
24
28
  filePath,
25
29
  isPreviewOnly,
26
30
  browseView,
31
+ onRequestClearSelection,
32
+ onRequestEdit,
27
33
  });
28
34
 
29
35
  if (!state.hasSelectedPath) {
@@ -46,6 +52,7 @@ export const FileViewer = ({
46
52
  viewMode=${state.viewMode}
47
53
  handleChangeViewMode=${actions.handleChangeViewMode}
48
54
  handleSave=${actions.handleSave}
55
+ handleDiscard=${actions.handleDiscard}
49
56
  loading=${state.loading}
50
57
  canEditFile=${derived.canEditFile}
51
58
  isEditBlocked=${derived.isEditBlocked}
@@ -53,22 +60,35 @@ export const FileViewer = ({
53
60
  isAudioFile=${state.isAudioFile}
54
61
  isSqliteFile=${state.isSqliteFile}
55
62
  saving=${state.saving}
63
+ deleting=${state.deleting}
64
+ restoring=${state.restoring}
65
+ canDeleteFile=${derived.canDeleteFile}
66
+ isDeleteBlocked=${derived.isDeleteBlocked}
67
+ isProtectedFile=${derived.isProtectedFile}
68
+ canRestoreDeletedDiff=${state.isDiffView && !!state.diffStatus?.isDeleted}
69
+ onRequestDelete=${() => setDeleteConfirmOpen(true)}
70
+ onRequestRestore=${actions.handleRestore}
56
71
  />
57
72
  <${FileViewerStatusBanners}
58
73
  isDiffView=${state.isDiffView}
59
74
  onRequestEdit=${onRequestEdit}
60
75
  normalizedPath=${context.normalizedPath}
76
+ isDeletedDiff=${!!state.diffStatus?.isDeleted}
61
77
  isLockedFile=${derived.isLockedFile}
62
78
  isProtectedFile=${derived.isProtectedFile}
63
79
  isProtectedLocked=${derived.isProtectedLocked}
64
80
  handleEditProtectedFile=${actions.handleEditProtectedFile}
65
81
  />
66
- <${FrontmatterPanel}
67
- isMarkdownFile=${state.isMarkdownFile}
68
- parsedFrontmatter=${derived.parsedFrontmatter}
69
- frontmatterCollapsed=${state.frontmatterCollapsed}
70
- setFrontmatterCollapsed=${actions.setFrontmatterCollapsed}
71
- />
82
+ ${!state.isDiffView
83
+ ? html`
84
+ <${FrontmatterPanel}
85
+ isMarkdownFile=${state.isMarkdownFile}
86
+ parsedFrontmatter=${derived.parsedFrontmatter}
87
+ frontmatterCollapsed=${state.frontmatterCollapsed}
88
+ setFrontmatterCollapsed=${actions.setFrontmatterCollapsed}
89
+ />
90
+ `
91
+ : null}
72
92
  ${state.loading
73
93
  ? html`
74
94
  <div class="file-viewer-loading-shell">
@@ -134,6 +154,7 @@ export const FileViewer = ({
134
154
  editorTextareaRef=${refs.editorTextareaRef}
135
155
  renderContent=${state.renderContent}
136
156
  handleContentInput=${actions.handleContentInput}
157
+ handleEditorKeyDown=${actions.handleEditorKeyDown}
137
158
  handleEditorScroll=${actions.handleEditorScroll}
138
159
  handleEditorSelectionChange=${actions.handleEditorSelectionChange}
139
160
  isEditBlocked=${derived.isEditBlocked}
@@ -152,6 +173,7 @@ export const FileViewer = ({
152
173
  editorTextareaRef=${refs.editorTextareaRef}
153
174
  renderContent=${state.renderContent}
154
175
  handleContentInput=${actions.handleContentInput}
176
+ handleEditorKeyDown=${actions.handleEditorKeyDown}
155
177
  handleEditorScroll=${actions.handleEditorScroll}
156
178
  handleEditorSelectionChange=${actions.handleEditorSelectionChange}
157
179
  isEditBlocked=${derived.isEditBlocked}
@@ -159,6 +181,25 @@ export const FileViewer = ({
159
181
  />
160
182
  `}
161
183
  `}
184
+ <${ConfirmDialog}
185
+ visible=${deleteConfirmOpen}
186
+ title="Delete file?"
187
+ message=${`Delete ${context.normalizedPath || "this file"}? This can be restored from diff view before sync.`}
188
+ confirmLabel="Delete"
189
+ confirmLoadingLabel="Deleting..."
190
+ cancelLabel="Cancel"
191
+ confirmTone="warning"
192
+ confirmLoading=${state.deleting}
193
+ confirmDisabled=${!derived.canDeleteFile || state.deleting}
194
+ onCancel=${() => {
195
+ if (state.deleting) return;
196
+ setDeleteConfirmOpen(false);
197
+ }}
198
+ onConfirm=${async () => {
199
+ await actions.handleDelete();
200
+ setDeleteConfirmOpen(false);
201
+ }}
202
+ />
162
203
  </div>
163
204
  `;
164
205
  };