@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
@@ -0,0 +1,167 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { LoadingSpinner } from "../loading-spinner.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const SqliteViewer = ({
8
+ sqliteSummary,
9
+ sqliteSelectedTable,
10
+ setSqliteSelectedTable,
11
+ sqliteTableOffset,
12
+ setSqliteTableOffset,
13
+ sqliteTableLoading,
14
+ sqliteTableError,
15
+ sqliteTableData,
16
+ kSqlitePageSize,
17
+ }) => {
18
+ const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : [];
19
+ const sqliteColumns =
20
+ Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length
21
+ ? sqliteTableData.columns
22
+ : (sqliteSummary?.objects || []).find(
23
+ (entry) => entry?.name === sqliteSelectedTable,
24
+ )?.columns || [];
25
+ const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0);
26
+ const sqliteCanGoPrev = sqliteTableOffset > 0;
27
+ const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows;
28
+
29
+ return html`
30
+ <div class="file-viewer-sqlite-shell">
31
+ ${sqliteSummary?.objects?.length
32
+ ? html`
33
+ <div class="file-viewer-sqlite-layout">
34
+ <div class="file-viewer-sqlite-list">
35
+ ${sqliteSummary.objects.map(
36
+ (entry) => html`
37
+ <button
38
+ type="button"
39
+ class=${`file-viewer-sqlite-card ${sqliteSelectedTable === entry.name ? "is-active" : ""}`}
40
+ onclick=${() => {
41
+ if (!entry?.name || sqliteSelectedTable === entry.name) return;
42
+ setSqliteSelectedTable(entry.name);
43
+ setSqliteTableOffset(0);
44
+ }}
45
+ >
46
+ <div class="file-viewer-sqlite-title">
47
+ <span>${entry.name}</span>
48
+ <span class="file-viewer-sqlite-type">${entry.type}</span>
49
+ </div>
50
+ </button>
51
+ `,
52
+ )}
53
+ </div>
54
+ <div class="file-viewer-sqlite-table-shell">
55
+ ${sqliteSelectedTable
56
+ ? html`
57
+ <div class="file-viewer-sqlite-table-header">
58
+ <span class="file-viewer-sqlite-table-name">
59
+ ${sqliteSelectedTable}
60
+ </span>
61
+ <div class="file-viewer-sqlite-table-nav">
62
+ <button
63
+ type="button"
64
+ class="ac-btn-secondary text-xs px-2 py-1 rounded-md"
65
+ disabled=${!sqliteCanGoPrev}
66
+ onclick=${() =>
67
+ setSqliteTableOffset((previousOffset) =>
68
+ Math.max(0, previousOffset - kSqlitePageSize),
69
+ )}
70
+ >
71
+ Prev
72
+ </button>
73
+ <button
74
+ type="button"
75
+ class="ac-btn-secondary text-xs px-2 py-1 rounded-md"
76
+ disabled=${!sqliteCanGoNext}
77
+ onclick=${() =>
78
+ setSqliteTableOffset(
79
+ (previousOffset) => previousOffset + kSqlitePageSize,
80
+ )}
81
+ >
82
+ Next
83
+ </button>
84
+ </div>
85
+ </div>
86
+ <div class="file-viewer-sqlite-table-meta">
87
+ ${sqliteTotalRows
88
+ ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows`
89
+ : "No rows"}
90
+ </div>
91
+ ${sqliteTableLoading
92
+ ? html`
93
+ <div class="file-viewer-loading-shell">
94
+ <${LoadingSpinner} className="h-4 w-4" />
95
+ </div>
96
+ `
97
+ : sqliteTableError
98
+ ? html`
99
+ <div class="file-viewer-state file-viewer-state-error">
100
+ ${sqliteTableError}
101
+ </div>
102
+ `
103
+ : html`
104
+ <div class="file-viewer-sqlite-table-wrap">
105
+ <table class="file-viewer-sqlite-table">
106
+ <thead>
107
+ <tr>
108
+ ${sqliteColumns.map(
109
+ (column) => html`<th>${column.name}</th>`,
110
+ )}
111
+ </tr>
112
+ </thead>
113
+ <tbody>
114
+ ${sqliteRows.length
115
+ ? sqliteRows.map(
116
+ (row, rowIndex) => html`
117
+ <tr key=${rowIndex}>
118
+ ${sqliteColumns.map((column) => {
119
+ const cellValue = row?.[column.name];
120
+ const displayValue =
121
+ cellValue === null
122
+ ? "NULL"
123
+ : typeof cellValue === "object"
124
+ ? JSON.stringify(cellValue)
125
+ : String(cellValue ?? "");
126
+ return html`
127
+ <td title=${displayValue}>
128
+ ${displayValue}
129
+ </td>
130
+ `;
131
+ })}
132
+ </tr>
133
+ `,
134
+ )
135
+ : html`
136
+ <tr>
137
+ <td colspan=${Math.max(1, sqliteColumns.length)}>
138
+ <span class="file-viewer-sqlite-table-empty">
139
+ No rows
140
+ </span>
141
+ </td>
142
+ </tr>
143
+ `}
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+ `}
148
+ `
149
+ : html`
150
+ <div class="file-viewer-state">Select a table to view rows.</div>
151
+ `}
152
+ </div>
153
+ </div>
154
+ <div class="file-viewer-sqlite-footer">
155
+ ${sqliteSummary.truncated
156
+ ? `Showing ${sqliteSummary.objects.length} of ${sqliteSummary.totalObjects} tables/views`
157
+ : `${sqliteSummary.totalObjects} tables/views`}
158
+ </div>
159
+ `
160
+ : html`
161
+ <div class="file-viewer-state">
162
+ SQLite database loaded, but no tables/views were found.
163
+ </div>
164
+ `}
165
+ </div>
166
+ `;
167
+ };
@@ -0,0 +1,59 @@
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 { LockLineIcon } from "../icons.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ export const FileViewerStatusBanners = ({
9
+ isDiffView,
10
+ onRequestEdit,
11
+ normalizedPath,
12
+ isLockedFile,
13
+ isProtectedFile,
14
+ isProtectedLocked,
15
+ handleEditProtectedFile,
16
+ }) => html`
17
+ ${isDiffView
18
+ ? html`
19
+ <div class="file-viewer-protected-banner file-viewer-diff-banner">
20
+ <div class="file-viewer-protected-banner-text">Viewing unsynced changes</div>
21
+ <${ActionButton}
22
+ onClick=${() => onRequestEdit(normalizedPath)}
23
+ tone="secondary"
24
+ size="sm"
25
+ idleLabel="View file"
26
+ />
27
+ </div>
28
+ `
29
+ : null}
30
+ ${!isDiffView && isLockedFile
31
+ ? html`
32
+ <div class="file-viewer-protected-banner is-locked">
33
+ <${LockLineIcon} className="file-viewer-protected-banner-icon" />
34
+ <div class="file-viewer-protected-banner-text">
35
+ This file is managed by AlphaClaw and cannot be edited.
36
+ </div>
37
+ </div>
38
+ `
39
+ : null}
40
+ ${!isDiffView && isProtectedFile
41
+ ? html`
42
+ <div class="file-viewer-protected-banner">
43
+ <div class="file-viewer-protected-banner-text">
44
+ Protected file. Changes may break workspace behavior.
45
+ </div>
46
+ ${isProtectedLocked
47
+ ? html`
48
+ <${ActionButton}
49
+ onClick=${handleEditProtectedFile}
50
+ tone="warning"
51
+ size="sm"
52
+ idleLabel="Edit anyway"
53
+ />
54
+ `
55
+ : null}
56
+ </div>
57
+ `
58
+ : null}
59
+ `;
@@ -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
+ };