@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.
- package/bin/alphaclaw.js +1 -31
- package/lib/public/assets/icons/google_icon.svg +8 -0
- package/lib/public/css/explorer.css +53 -0
- package/lib/public/css/shell.css +21 -19
- package/lib/public/css/theme.css +17 -0
- package/lib/public/js/app.js +205 -109
- package/lib/public/js/components/credentials-modal.js +36 -8
- package/lib/public/js/components/file-tree.js +212 -22
- package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
- package/lib/public/js/components/file-viewer/index.js +47 -6
- package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
- package/lib/public/js/components/file-viewer/status-banners.js +11 -6
- package/lib/public/js/components/file-viewer/toolbar.js +56 -1
- package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
- package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
- package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
- package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
- package/lib/public/js/components/google/account-row.js +131 -0
- package/lib/public/js/components/google/add-account-modal.js +93 -0
- package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
- package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
- package/lib/public/js/components/google/index.js +553 -0
- package/lib/public/js/components/google/use-gmail-watch.js +140 -0
- package/lib/public/js/components/google/use-google-accounts.js +41 -0
- package/lib/public/js/components/icons.js +26 -0
- package/lib/public/js/components/scope-picker.js +1 -1
- package/lib/public/js/components/sidebar-git-panel.js +48 -20
- package/lib/public/js/components/sidebar.js +93 -75
- package/lib/public/js/components/toast.js +11 -7
- package/lib/public/js/components/usage-tab/constants.js +31 -0
- package/lib/public/js/components/usage-tab/formatters.js +24 -0
- package/lib/public/js/components/usage-tab/index.js +72 -0
- package/lib/public/js/components/usage-tab/overview-section.js +147 -0
- package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
- package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
- package/lib/public/js/components/webhooks.js +182 -129
- package/lib/public/js/lib/api.js +178 -9
- package/lib/public/js/lib/browse-file-policies.js +29 -11
- package/lib/public/js/lib/format.js +71 -0
- package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
- package/lib/public/shared/browse-file-policies.json +13 -0
- package/lib/server/constants.js +47 -7
- package/lib/server/gmail-push.js +109 -0
- package/lib/server/gmail-serve.js +254 -0
- package/lib/server/gmail-watch.js +725 -0
- package/lib/server/google-state.js +317 -0
- package/lib/server/helpers.js +17 -11
- package/lib/server/internal-files-migration.js +31 -3
- package/lib/server/onboarding/github.js +21 -2
- package/lib/server/onboarding/index.js +1 -3
- package/lib/server/onboarding/openclaw.js +3 -0
- package/lib/server/onboarding/workspace.js +40 -0
- package/lib/server/routes/browse/index.js +90 -2
- package/lib/server/routes/gmail.js +128 -0
- package/lib/server/routes/google.js +433 -213
- package/lib/server/routes/system.js +107 -0
- package/lib/server/routes/usage.js +29 -2
- package/lib/server/routes/webhooks.js +52 -17
- package/lib/server/usage-db.js +283 -15
- package/lib/server/watchdog.js +66 -0
- package/lib/server/webhook-middleware.js +99 -1
- package/lib/server/webhooks.js +214 -65
- package/lib/server.js +27 -0
- package/lib/setup/gitignore +6 -0
- package/lib/setup/hourly-git-sync.sh +29 -2
- package/package.json +1 -1
- package/lib/public/js/components/google.js +0 -228
- 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 {
|
|
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 = ({
|
|
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(
|
|
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("
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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(
|
|
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 (
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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=${() =>
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
};
|