clawcontainer 1.0.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/CONTRIBUTING.md +76 -0
- package/DOCS.md +370 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/black_logo.png +0 -0
- package/dist/assets/abap-DLDM7-KI.js +1 -0
- package/dist/assets/apex-DNDY2TF8.js +1 -0
- package/dist/assets/azcli-Y6nb8tq_.js +1 -0
- package/dist/assets/bat-BwHxbl9M.js +1 -0
- package/dist/assets/bicep-CFznDFnq.js +2 -0
- package/dist/assets/cameligo-Bf6VGUru.js +1 -0
- package/dist/assets/clojure-Dnu-v4kV.js +1 -0
- package/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/assets/coffee-Bd8akH9Z.js +1 -0
- package/dist/assets/cpp-BbWJElDN.js +1 -0
- package/dist/assets/csharp-Co3qMtFm.js +1 -0
- package/dist/assets/csp-D-4FJmMZ.js +1 -0
- package/dist/assets/css-DdJfP1eB.js +3 -0
- package/dist/assets/css.worker-GxEd3MMM.js +93 -0
- package/dist/assets/cssMode-DM_ONlf-.js +1 -0
- package/dist/assets/cypher-cTPe9QuQ.js +1 -0
- package/dist/assets/dart-BOtBlQCF.js +1 -0
- package/dist/assets/dockerfile-BG73LgW2.js +1 -0
- package/dist/assets/ecl-BEgZUVRK.js +1 -0
- package/dist/assets/elixir-BkW5O-1t.js +1 -0
- package/dist/assets/flow9-BeJ5waoc.js +1 -0
- package/dist/assets/freemarker2-VbwzOQPq.js +3 -0
- package/dist/assets/fsharp-PahG7c26.js +1 -0
- package/dist/assets/go-acbASCJo.js +1 -0
- package/dist/assets/graphql-BxJiqAUM.js +1 -0
- package/dist/assets/handlebars-DLvQ802u.js +1 -0
- package/dist/assets/hcl-DtV1sZF8.js +1 -0
- package/dist/assets/html-DuEPBzmS.js +1 -0
- package/dist/assets/html.worker-lU17Tx2m.js +470 -0
- package/dist/assets/htmlMode-BfeYTJaB.js +1 -0
- package/dist/assets/index-BnBKg8GZ.js +1291 -0
- package/dist/assets/index-Dq3FlPWe.css +32 -0
- package/dist/assets/ini-Kd9XrMLS.js +1 -0
- package/dist/assets/java-CXBNlu9o.js +1 -0
- package/dist/assets/javascript-DQO1Leza.js +1 -0
- package/dist/assets/json.worker-CUJs-dtA.js +58 -0
- package/dist/assets/jsonMode--qsURhHr.js +7 -0
- package/dist/assets/julia-cl7-CwDS.js +1 -0
- package/dist/assets/kotlin-s7OhZKlX.js +1 -0
- package/dist/assets/less-9HpZscsL.js +2 -0
- package/dist/assets/lexon-OrD6JF1K.js +1 -0
- package/dist/assets/liquid-PL6MZtM8.js +1 -0
- package/dist/assets/lspLanguageFeatures-Cy5rDFeq.js +4 -0
- package/dist/assets/lua-Cyyb5UIc.js +1 -0
- package/dist/assets/m3-B8OfTtLu.js +1 -0
- package/dist/assets/markdown-BFxVWTOG.js +1 -0
- package/dist/assets/mdx-Cb3Jy14X.js +1 -0
- package/dist/assets/mips-CiqrrVzr.js +1 -0
- package/dist/assets/msdax-DmeGPVcC.js +1 -0
- package/dist/assets/mysql-C_tMU-Nz.js +1 -0
- package/dist/assets/objective-c-BDtDVThU.js +1 -0
- package/dist/assets/pascal-vHIfCaH5.js +1 -0
- package/dist/assets/pascaligo-DtZ0uQbO.js +1 -0
- package/dist/assets/perl-Ub6l9XKa.js +1 -0
- package/dist/assets/pgsql-BlNEE0v7.js +1 -0
- package/dist/assets/php-BBUBE1dy.js +1 -0
- package/dist/assets/pla-DSh2-awV.js +1 -0
- package/dist/assets/postiats-CocnycG-.js +1 -0
- package/dist/assets/powerquery-tScXyioY.js +1 -0
- package/dist/assets/powershell-COWaemsV.js +1 -0
- package/dist/assets/protobuf-Brw8urJB.js +2 -0
- package/dist/assets/pug-8SOpv6rk.js +1 -0
- package/dist/assets/python-Usm4OUwq.js +1 -0
- package/dist/assets/qsharp-Bw9ernYp.js +1 -0
- package/dist/assets/r-j7ic8hl3.js +1 -0
- package/dist/assets/razor-BIOole7a.js +1 -0
- package/dist/assets/redis-Bu5POkcn.js +1 -0
- package/dist/assets/redshift-Bs9aos_-.js +1 -0
- package/dist/assets/restructuredtext-CqXO7rUv.js +1 -0
- package/dist/assets/ruby-zBfavPgS.js +1 -0
- package/dist/assets/rust-BzKRNQWT.js +1 -0
- package/dist/assets/sb-BBc9UKZt.js +1 -0
- package/dist/assets/scala-D9hQfWCl.js +1 -0
- package/dist/assets/scheme-BPhDTwHR.js +1 -0
- package/dist/assets/scss-CBJaRo0y.js +3 -0
- package/dist/assets/shell-DiJ1NA_G.js +1 -0
- package/dist/assets/solidity-Db0IVjzk.js +1 -0
- package/dist/assets/sophia-CnS9iZB_.js +1 -0
- package/dist/assets/sparql-CJmd_6j2.js +1 -0
- package/dist/assets/sql-ClhHkBeG.js +1 -0
- package/dist/assets/st-CHwy0fLd.js +1 -0
- package/dist/assets/swift-Bqt4WxQ4.js +3 -0
- package/dist/assets/systemverilog-Bs9z6M-B.js +1 -0
- package/dist/assets/tcl-Dm6ycUr_.js +1 -0
- package/dist/assets/ts.worker-Dy9lDQQT.js +67731 -0
- package/dist/assets/tsMode-CDjF3DWK.js +11 -0
- package/dist/assets/twig-Csy3S7wG.js +1 -0
- package/dist/assets/typescript-CJR4sLnG.js +1 -0
- package/dist/assets/typespec-Btyra-wh.js +1 -0
- package/dist/assets/vb-Db0cS2oM.js +1 -0
- package/dist/assets/wgsl-DumH7NcR.js +298 -0
- package/dist/assets/xml-CJZS3uh7.js +1 -0
- package/dist/assets/yaml-DB88cW5z.js +1 -0
- package/dist/audit.d.ts +48 -0
- package/dist/container.d.ts +100 -0
- package/dist/event-emitter.d.ts +7 -0
- package/dist/favicon.png +0 -0
- package/dist/git-service.d.ts +31 -0
- package/dist/index.html +188 -0
- package/dist/logo-sm.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/main.d.ts +1 -0
- package/dist/monaco-editor.d.ts +11 -0
- package/dist/monacoeditorwork/css.worker.bundle.js +54264 -0
- package/dist/monacoeditorwork/editor.worker.bundle.js +14317 -0
- package/dist/monacoeditorwork/html.worker.bundle.js +30449 -0
- package/dist/monacoeditorwork/json.worker.bundle.js +22085 -0
- package/dist/monacoeditorwork/ts.worker.bundle.js +225552 -0
- package/dist/net-intercept.d.ts +2 -0
- package/dist/network-hook.d.ts +1 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/policy.d.ts +58 -0
- package/dist/sdk.d.ts +61 -0
- package/dist/tab-manager.d.ts +11 -0
- package/dist/templates.d.ts +46 -0
- package/dist/terminal.d.ts +19 -0
- package/dist/types.d.ts +109 -0
- package/dist/ui.d.ts +81 -0
- package/dist/workspace.d.ts +16 -0
- package/index.html +159 -0
- package/logo.png +0 -0
- package/package.json +31 -0
- package/public/favicon.png +0 -0
- package/public/logo-sm.png +0 -0
- package/public/logo.png +0 -0
- package/src/audit.ts +196 -0
- package/src/container.ts +723 -0
- package/src/event-emitter.ts +28 -0
- package/src/git-service.ts +202 -0
- package/src/main.ts +9 -0
- package/src/monaco-editor.ts +111 -0
- package/src/net-intercept.ts +74 -0
- package/src/network-hook.ts +248 -0
- package/src/plugin.ts +63 -0
- package/src/policy.ts +403 -0
- package/src/sdk.ts +355 -0
- package/src/style.css +432 -0
- package/src/tab-manager.ts +30 -0
- package/src/templates.ts +271 -0
- package/src/terminal.ts +78 -0
- package/src/types.ts +113 -0
- package/src/ui.ts +1266 -0
- package/src/workspace.ts +107 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +52 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
import type { ContainerManager, ContainerStatus } from './container.js';
|
|
2
|
+
import { createEditorInstance, openFileModel, getModelContent, closeFileModel, disposeAll, initMonacoTheme } from './monaco-editor.js';
|
|
3
|
+
import type { AuditLog } from './audit.js';
|
|
4
|
+
import { PolicyEngine } from './policy.js';
|
|
5
|
+
import type { TabDefinition } from './types.js';
|
|
6
|
+
|
|
7
|
+
// ─── Provider → env var key ──────────────────────────────────────────────────
|
|
8
|
+
function providerEnvKey(provider: string): string {
|
|
9
|
+
switch (provider) {
|
|
10
|
+
case 'openai': return 'OPENAI_API_KEY';
|
|
11
|
+
case 'google': return 'GOOGLE_API_KEY';
|
|
12
|
+
default: return 'ANTHROPIC_API_KEY';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── Provider → models ───────────────────────────────────────────────────────
|
|
17
|
+
const PROVIDER_MODELS: Record<string, string[]> = {
|
|
18
|
+
anthropic: ['anthropic:claude-opus-4-6', 'anthropic:claude-sonnet-4-6', 'anthropic:claude-haiku-4-5'],
|
|
19
|
+
openai: ['openai:gpt-4o', 'openai:gpt-4o-mini', 'openai:o3-mini'],
|
|
20
|
+
google: ['google:gemini-2.0-flash', 'google:gemini-2.5-pro'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const LS_PREFIX = 'clawchef_';
|
|
24
|
+
const PREVIEW_TAB_PATH = '__preview__';
|
|
25
|
+
const AUDIT_TAB_PATH = '__audit__';
|
|
26
|
+
const POLICY_TAB_PATH = '__policy__';
|
|
27
|
+
const CLOUD_BROWSER_TAB_PATH = '__cloud_browser__';
|
|
28
|
+
|
|
29
|
+
interface Tab {
|
|
30
|
+
filePath: string;
|
|
31
|
+
filename: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class UIManager {
|
|
35
|
+
private container: ContainerManager;
|
|
36
|
+
private audit: AuditLog;
|
|
37
|
+
private policy: PolicyEngine;
|
|
38
|
+
private activePanelId: string | null = null;
|
|
39
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
40
|
+
|
|
41
|
+
// Tab state
|
|
42
|
+
private tabs: Tab[] = [];
|
|
43
|
+
private activeTabPath: string | null = null;
|
|
44
|
+
|
|
45
|
+
// Audit tab state
|
|
46
|
+
private auditUnsubscribe: (() => void) | null = null;
|
|
47
|
+
|
|
48
|
+
// Git sync state
|
|
49
|
+
private syncInProgress = false;
|
|
50
|
+
private autoSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
private cloneInProgress = false;
|
|
52
|
+
|
|
53
|
+
// Custom tab tracking
|
|
54
|
+
private customTabPaths = new Set<string>();
|
|
55
|
+
|
|
56
|
+
constructor(container: ContainerManager, audit: AuditLog, policy: PolicyEngine) {
|
|
57
|
+
this.container = container;
|
|
58
|
+
this.audit = audit;
|
|
59
|
+
this.policy = policy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
init(): void {
|
|
63
|
+
initMonacoTheme();
|
|
64
|
+
this.bindTopbarButtons();
|
|
65
|
+
this.bindConfigPanel();
|
|
66
|
+
this.bindPolicyPanel();
|
|
67
|
+
this.bindFileTree();
|
|
68
|
+
this.bindKeyboard();
|
|
69
|
+
this.bindResizeHandles();
|
|
70
|
+
this.populateModelOptions();
|
|
71
|
+
this.restoreConfig();
|
|
72
|
+
this.bindRepoControls();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Auto-open the browser preview tab on initial load. */
|
|
76
|
+
openPreviewOnLoad(): void {
|
|
77
|
+
this.openPreviewTab();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setStatus(status: ContainerStatus): void {
|
|
81
|
+
const badge = document.getElementById('container-status')!;
|
|
82
|
+
badge.className = 'status-badge';
|
|
83
|
+
badge.classList.add(`status-${status}`);
|
|
84
|
+
const labels: Record<ContainerStatus, string> = {
|
|
85
|
+
booting: 'booting', installing: 'installing', ready: 'ready', error: 'error',
|
|
86
|
+
};
|
|
87
|
+
badge.textContent = labels[status] ?? status;
|
|
88
|
+
|
|
89
|
+
if (status === 'ready') {
|
|
90
|
+
this.startFileTreeRefresh();
|
|
91
|
+
this.startHtmlFileWatcher();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getSavedConfig() {
|
|
96
|
+
const provider = localStorage.getItem(`${LS_PREFIX}provider`);
|
|
97
|
+
const model = localStorage.getItem(`${LS_PREFIX}model`);
|
|
98
|
+
const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
|
|
99
|
+
|
|
100
|
+
// New format
|
|
101
|
+
if (provider && model && envJson) {
|
|
102
|
+
try {
|
|
103
|
+
const envVars = JSON.parse(envJson) as Record<string, string>;
|
|
104
|
+
if (Object.keys(envVars).length > 0) return { provider, model, envVars };
|
|
105
|
+
} catch { /* fall through */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Migrate old format
|
|
109
|
+
const apiKey = localStorage.getItem(`${LS_PREFIX}apiKey`);
|
|
110
|
+
if (provider && apiKey && model) {
|
|
111
|
+
const envVars: Record<string, string> = { [providerEnvKey(provider)]: apiKey };
|
|
112
|
+
const voiceKey = localStorage.getItem(`${LS_PREFIX}openaiVoiceKey`);
|
|
113
|
+
if (voiceKey) envVars['OPENAI_API_KEY'] = voiceKey;
|
|
114
|
+
return { provider, model, envVars };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
showConfigPanel(): void {
|
|
121
|
+
this.openPanel('config-panel');
|
|
122
|
+
document.getElementById('btn-config')!.classList.add('active');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
private bindKeyboard(): void {
|
|
128
|
+
document.addEventListener('keydown', (e) => {
|
|
129
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
130
|
+
if (this.activeTabPath) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
this.saveActiveFile();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Resize handles ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
private bindResizeHandles(): void {
|
|
141
|
+
// Filetree ↔ main-content (horizontal)
|
|
142
|
+
this.initHResize('resize-filetree', 'filetree', 'before', 120, 600);
|
|
143
|
+
|
|
144
|
+
// Editor ↔ Terminal (vertical)
|
|
145
|
+
this.initVResize('resize-editor-terminal', 'editor-panel', 'terminal-panel', 80, 80);
|
|
146
|
+
|
|
147
|
+
// Main-content ↔ sidebar (horizontal)
|
|
148
|
+
this.initHResize('resize-sidebar', 'sidebar', 'after', 150, 600);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private initHResize(handleId: string, targetId: string, side: 'before' | 'after', min: number, max: number): void {
|
|
152
|
+
const handle = document.getElementById(handleId)!;
|
|
153
|
+
const target = document.getElementById(targetId)!;
|
|
154
|
+
|
|
155
|
+
let startX = 0;
|
|
156
|
+
let startW = 0;
|
|
157
|
+
|
|
158
|
+
const onMove = (e: MouseEvent) => {
|
|
159
|
+
const delta = e.clientX - startX;
|
|
160
|
+
const newW = Math.min(max, Math.max(min, side === 'before' ? startW + delta : startW - delta));
|
|
161
|
+
target.style.width = `${newW}px`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const onUp = () => {
|
|
165
|
+
handle.classList.remove('active');
|
|
166
|
+
document.body.classList.remove('resizing-col');
|
|
167
|
+
document.removeEventListener('mousemove', onMove);
|
|
168
|
+
document.removeEventListener('mouseup', onUp);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
handle.addEventListener('mousedown', (e) => {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
startX = e.clientX;
|
|
174
|
+
startW = target.getBoundingClientRect().width;
|
|
175
|
+
handle.classList.add('active');
|
|
176
|
+
document.body.classList.add('resizing-col');
|
|
177
|
+
document.addEventListener('mousemove', onMove);
|
|
178
|
+
document.addEventListener('mouseup', onUp);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private initVResize(handleId: string, topId: string, _bottomId: string, minTop: number, minBottom: number): void {
|
|
183
|
+
const handle = document.getElementById(handleId)!;
|
|
184
|
+
const topEl = document.getElementById(topId)!;
|
|
185
|
+
const parent = topEl.parentElement!;
|
|
186
|
+
|
|
187
|
+
let startY = 0;
|
|
188
|
+
let startH = 0;
|
|
189
|
+
|
|
190
|
+
const onMove = (e: MouseEvent) => {
|
|
191
|
+
const parentH = parent.getBoundingClientRect().height;
|
|
192
|
+
const delta = e.clientY - startY;
|
|
193
|
+
const maxH = parentH - minBottom - handle.offsetHeight;
|
|
194
|
+
const newH = Math.min(maxH, Math.max(minTop, startH + delta));
|
|
195
|
+
topEl.style.height = `${newH}px`;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const onUp = () => {
|
|
199
|
+
handle.classList.remove('active');
|
|
200
|
+
document.body.classList.remove('resizing-row');
|
|
201
|
+
document.removeEventListener('mousemove', onMove);
|
|
202
|
+
document.removeEventListener('mouseup', onUp);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
handle.addEventListener('mousedown', (e) => {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
startY = e.clientY;
|
|
208
|
+
startH = topEl.getBoundingClientRect().height;
|
|
209
|
+
handle.classList.add('active');
|
|
210
|
+
document.body.classList.add('resizing-row');
|
|
211
|
+
document.addEventListener('mousemove', onMove);
|
|
212
|
+
document.addEventListener('mouseup', onUp);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── File Tree ─────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
private bindFileTree(): void {
|
|
219
|
+
document.getElementById('btn-refresh-tree')!.addEventListener('click', () =>
|
|
220
|
+
this.refreshFileTree());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private htmlWatcherStarted = false;
|
|
224
|
+
|
|
225
|
+
private startHtmlFileWatcher(): void {
|
|
226
|
+
if (this.htmlWatcherStarted) return;
|
|
227
|
+
this.htmlWatcherStarted = true;
|
|
228
|
+
|
|
229
|
+
this.container.onFileChange((path: string) => {
|
|
230
|
+
if (/\.html?$/i.test(path)) {
|
|
231
|
+
this.openHtmlInPreview(path);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Also start the native fs watcher in the container
|
|
236
|
+
this.container.startWatching();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async openHtmlInPreview(filePath: string): Promise<void> {
|
|
240
|
+
try {
|
|
241
|
+
const content = await this.container.readFile(filePath);
|
|
242
|
+
// Open or switch to the preview tab
|
|
243
|
+
await this.openPreviewTab();
|
|
244
|
+
const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
|
|
245
|
+
const urlInput = document.getElementById('preview-url') as HTMLInputElement;
|
|
246
|
+
const loading = document.getElementById('preview-loading')!;
|
|
247
|
+
iframe.srcdoc = content;
|
|
248
|
+
urlInput.value = filePath;
|
|
249
|
+
loading.classList.add('hidden');
|
|
250
|
+
iframe.style.visibility = 'visible';
|
|
251
|
+
} catch {
|
|
252
|
+
// file may have been deleted or unreadable — ignore
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private startFileTreeRefresh(): void {
|
|
257
|
+
this.refreshFileTree();
|
|
258
|
+
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
259
|
+
this.refreshTimer = setInterval(() => this.refreshFileTree(), 4000);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async refreshFileTree(): Promise<void> {
|
|
263
|
+
const files = await this.container.listWorkspaceFiles();
|
|
264
|
+
const list = document.getElementById('filetree-list')!;
|
|
265
|
+
list.innerHTML = '';
|
|
266
|
+
|
|
267
|
+
for (const f of files) {
|
|
268
|
+
const isDir = f.endsWith('/');
|
|
269
|
+
const name = f.replace(/\/$/, '').split('/').pop() ?? f;
|
|
270
|
+
const depth = f.split('/').length - 1;
|
|
271
|
+
const item = document.createElement('div');
|
|
272
|
+
item.className = `ft-item${isDir ? ' is-dir' : ''}`;
|
|
273
|
+
item.dataset['depth'] = String(Math.min(depth, 3));
|
|
274
|
+
|
|
275
|
+
const icon = isDir ? svgIcon('folder') : fileIcon(name);
|
|
276
|
+
item.innerHTML = `
|
|
277
|
+
<span class="ft-icon">${icon}</span>
|
|
278
|
+
<span class="ft-name" title="${f}">${name}</span>
|
|
279
|
+
`;
|
|
280
|
+
|
|
281
|
+
if (!isDir) {
|
|
282
|
+
item.addEventListener('click', () => this.openFile(f, name));
|
|
283
|
+
}
|
|
284
|
+
list.appendChild(item);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (files.length === 0) {
|
|
288
|
+
list.innerHTML = '<div style="padding:12px;color:var(--text-dim);font-size:11px;">No files yet</div>';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Tab management ─────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
private async openFile(relativePath: string, filename: string): Promise<void> {
|
|
295
|
+
const fullPath = `workspace/${relativePath}`;
|
|
296
|
+
|
|
297
|
+
// If tab already exists, just switch to it
|
|
298
|
+
if (this.tabs.some(t => t.filePath === fullPath)) {
|
|
299
|
+
this.switchTab(fullPath);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Create new tab
|
|
304
|
+
this.tabs.push({ filePath: fullPath, filename });
|
|
305
|
+
|
|
306
|
+
// Show editor panel if hidden
|
|
307
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
308
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
309
|
+
editorPanel.classList.remove('hidden');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Create editor instance if this is the first file tab
|
|
313
|
+
const editorContainer = document.getElementById('editor-container')!;
|
|
314
|
+
createEditorInstance(editorContainer); // no-op if already created
|
|
315
|
+
|
|
316
|
+
// Load file content
|
|
317
|
+
let content = '';
|
|
318
|
+
try {
|
|
319
|
+
content = await this.container.readFile(fullPath) || '(empty)';
|
|
320
|
+
} catch (e) {
|
|
321
|
+
content = `Error: ${(e as Error).message}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
openFileModel(fullPath, filename, content);
|
|
325
|
+
this.activeTabPath = fullPath;
|
|
326
|
+
this.updateContentVisibility();
|
|
327
|
+
this.renderTabBar();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private closeTab(filePath: string): void {
|
|
331
|
+
const idx = this.tabs.findIndex(t => t.filePath === filePath);
|
|
332
|
+
if (idx === -1) return;
|
|
333
|
+
|
|
334
|
+
if (filePath === AUDIT_TAB_PATH) {
|
|
335
|
+
if (this.auditUnsubscribe) { this.auditUnsubscribe(); this.auditUnsubscribe = null; }
|
|
336
|
+
document.getElementById('audit-log-list')!.innerHTML = '';
|
|
337
|
+
} else if (filePath !== PREVIEW_TAB_PATH && filePath !== POLICY_TAB_PATH && filePath !== CLOUD_BROWSER_TAB_PATH && !this.customTabPaths.has(filePath)) {
|
|
338
|
+
closeFileModel(filePath);
|
|
339
|
+
}
|
|
340
|
+
this.tabs.splice(idx, 1);
|
|
341
|
+
|
|
342
|
+
// Check if any file tabs remain (need editor instance)
|
|
343
|
+
const isSpecial = (p: string) => p === PREVIEW_TAB_PATH || p === AUDIT_TAB_PATH || p === POLICY_TAB_PATH || p === CLOUD_BROWSER_TAB_PATH || this.customTabPaths.has(p);
|
|
344
|
+
const hasFileTabs = this.tabs.some(t => !isSpecial(t.filePath));
|
|
345
|
+
|
|
346
|
+
if (this.tabs.length === 0) {
|
|
347
|
+
this.activeTabPath = null;
|
|
348
|
+
disposeAll();
|
|
349
|
+
document.getElementById('editor-panel')!.classList.add('hidden');
|
|
350
|
+
} else if (this.activeTabPath === filePath) {
|
|
351
|
+
const newIdx = Math.min(idx, this.tabs.length - 1);
|
|
352
|
+
this.switchTab(this.tabs[newIdx].filePath);
|
|
353
|
+
return; // switchTab already renders tab bar
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If no file tabs left, dispose the editor instance
|
|
357
|
+
if (!hasFileTabs) {
|
|
358
|
+
disposeAll();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.renderTabBar();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private switchTab(filePath: string): void {
|
|
365
|
+
const tab = this.tabs.find(t => t.filePath === filePath);
|
|
366
|
+
if (!tab) return;
|
|
367
|
+
|
|
368
|
+
this.activeTabPath = filePath;
|
|
369
|
+
if (filePath !== PREVIEW_TAB_PATH && filePath !== AUDIT_TAB_PATH && filePath !== POLICY_TAB_PATH && filePath !== CLOUD_BROWSER_TAB_PATH && !this.customTabPaths.has(filePath)) {
|
|
370
|
+
openFileModel(filePath, tab.filename, ''); // model already cached, content ignored
|
|
371
|
+
}
|
|
372
|
+
if (filePath === AUDIT_TAB_PATH) {
|
|
373
|
+
this.renderAuditLog();
|
|
374
|
+
}
|
|
375
|
+
this.updateContentVisibility();
|
|
376
|
+
this.renderTabBar();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private renderTabBar(): void {
|
|
380
|
+
const bar = document.getElementById('tab-bar')!;
|
|
381
|
+
bar.innerHTML = '';
|
|
382
|
+
|
|
383
|
+
for (const tab of this.tabs) {
|
|
384
|
+
const el = document.createElement('div');
|
|
385
|
+
el.className = `tab${tab.filePath === this.activeTabPath ? ' active' : ''}`;
|
|
386
|
+
|
|
387
|
+
const nameSpan = document.createElement('span');
|
|
388
|
+
const isCustom = this.customTabPaths.has(tab.filePath);
|
|
389
|
+
if (isCustom) {
|
|
390
|
+
const id = tab.filePath.replace('__custom_', '').replace('__', '');
|
|
391
|
+
el.setAttribute('data-custom-tab-id', id);
|
|
392
|
+
}
|
|
393
|
+
nameSpan.innerHTML = tab.filePath === PREVIEW_TAB_PATH
|
|
394
|
+
? `${svgIcon('globe')} Browser`
|
|
395
|
+
: tab.filePath === AUDIT_TAB_PATH
|
|
396
|
+
? `${svgIcon('activity')} Audit Log`
|
|
397
|
+
: tab.filePath === POLICY_TAB_PATH
|
|
398
|
+
? `${svgIcon('shield')} Policy`
|
|
399
|
+
: tab.filePath === CLOUD_BROWSER_TAB_PATH
|
|
400
|
+
? `${svgIcon('cloud')} Cloud Browser`
|
|
401
|
+
: tab.filename;
|
|
402
|
+
el.appendChild(nameSpan);
|
|
403
|
+
|
|
404
|
+
const closeBtn = document.createElement('button');
|
|
405
|
+
closeBtn.className = 'tab-close';
|
|
406
|
+
closeBtn.textContent = '×';
|
|
407
|
+
closeBtn.addEventListener('click', (e) => {
|
|
408
|
+
e.stopPropagation();
|
|
409
|
+
this.closeTab(tab.filePath);
|
|
410
|
+
});
|
|
411
|
+
el.appendChild(closeBtn);
|
|
412
|
+
|
|
413
|
+
el.addEventListener('click', () => this.switchTab(tab.filePath));
|
|
414
|
+
bar.appendChild(el);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async saveActiveFile(): Promise<void> {
|
|
419
|
+
if (!this.activeTabPath || this.activeTabPath === PREVIEW_TAB_PATH || this.activeTabPath === AUDIT_TAB_PATH || this.activeTabPath === POLICY_TAB_PATH || this.activeTabPath === CLOUD_BROWSER_TAB_PATH || this.customTabPaths.has(this.activeTabPath)) return;
|
|
420
|
+
const content = getModelContent(this.activeTabPath);
|
|
421
|
+
try {
|
|
422
|
+
await this.container.writeFile(this.activeTabPath, content);
|
|
423
|
+
} catch {
|
|
424
|
+
// silent fail for now
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Preview tab ──────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
private previewBound = false;
|
|
431
|
+
|
|
432
|
+
private async openPreviewTab(): Promise<void> {
|
|
433
|
+
// If already open, switch to it
|
|
434
|
+
if (this.tabs.some(t => t.filePath === PREVIEW_TAB_PATH)) {
|
|
435
|
+
this.switchTab(PREVIEW_TAB_PATH);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.tabs.push({ filePath: PREVIEW_TAB_PATH, filename: 'Browser' });
|
|
440
|
+
|
|
441
|
+
// Show editor panel if hidden
|
|
442
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
443
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
444
|
+
editorPanel.classList.remove('hidden');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.activeTabPath = PREVIEW_TAB_PATH;
|
|
448
|
+
this.updateContentVisibility();
|
|
449
|
+
this.renderTabBar();
|
|
450
|
+
|
|
451
|
+
const urlInput = document.getElementById('preview-url') as HTMLInputElement;
|
|
452
|
+
const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
|
|
453
|
+
const loading = document.getElementById('preview-loading')!;
|
|
454
|
+
|
|
455
|
+
if (!this.previewBound) {
|
|
456
|
+
this.previewBound = true;
|
|
457
|
+
|
|
458
|
+
// Navigate on Enter
|
|
459
|
+
urlInput.addEventListener('keydown', (e) => {
|
|
460
|
+
if (e.key === 'Enter') {
|
|
461
|
+
let val = urlInput.value.trim();
|
|
462
|
+
if (val && !val.startsWith('http')) val = 'https://' + val;
|
|
463
|
+
if (val) {
|
|
464
|
+
iframe.src = val;
|
|
465
|
+
loading.classList.remove('hidden');
|
|
466
|
+
iframe.style.visibility = 'hidden';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Nav buttons
|
|
472
|
+
document.getElementById('preview-reload')!.addEventListener('click', () => {
|
|
473
|
+
if (iframe.src) iframe.src = iframe.src;
|
|
474
|
+
});
|
|
475
|
+
document.getElementById('preview-back')!.addEventListener('click', () => {
|
|
476
|
+
try { iframe.contentWindow?.history.back(); } catch { /* cross-origin */ }
|
|
477
|
+
});
|
|
478
|
+
document.getElementById('preview-forward')!.addEventListener('click', () => {
|
|
479
|
+
try { iframe.contentWindow?.history.forward(); } catch { /* cross-origin */ }
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Sync URL bar on iframe load
|
|
483
|
+
iframe.addEventListener('load', () => {
|
|
484
|
+
loading.classList.add('hidden');
|
|
485
|
+
iframe.style.visibility = 'visible';
|
|
486
|
+
try { urlInput.value = iframe.contentWindow?.location.href ?? ''; } catch { /* cross-origin */ }
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Show welcome page immediately instead of waiting for a server
|
|
491
|
+
iframe.srcdoc = `<!DOCTYPE html>
|
|
492
|
+
<html lang="en">
|
|
493
|
+
<head>
|
|
494
|
+
<meta charset="UTF-8">
|
|
495
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
496
|
+
<style>
|
|
497
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
498
|
+
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding-top: 2.5rem; }
|
|
499
|
+
.welcome { text-align: center; max-width: 480px; padding: 2rem; }
|
|
500
|
+
.welcome-logo { width: 80px; height: 80px; margin-bottom: 0.25rem; border-radius: 16px; }
|
|
501
|
+
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
|
|
502
|
+
h1 span { color: #f78166; }
|
|
503
|
+
p.tagline { color: #8b949e; margin-bottom: 1.5rem; font-size: 1rem; }
|
|
504
|
+
ul { list-style: none; text-align: left; }
|
|
505
|
+
ul li { padding: 0.4rem 0; color: #e6edf3; }
|
|
506
|
+
ul li::before { content: '▸ '; color: #f78166; }
|
|
507
|
+
.note-bar { background: #161b22; border-bottom: 1px solid #30363d; padding: 0.5rem 1rem; text-align: center; font-size: 0.8rem; color: #8b949e; position: fixed; top: 0; left: 0; right: 0; z-index: 10; }
|
|
508
|
+
.note-bar span { color: #f78166; }
|
|
509
|
+
</style>
|
|
510
|
+
</head>
|
|
511
|
+
<body>
|
|
512
|
+
<div class="note-bar">Please wait while the Claw Agent gets installed — by default it installs <span>GitClaw</span>, a variant of <span>OpenClaw</span>, but better :)</div>
|
|
513
|
+
<div class="welcome">
|
|
514
|
+
<img class="welcome-logo" src="${window.location.origin}/logo.png" alt="ClawLess logo" />
|
|
515
|
+
<h1>Welcome to <span>ClawLess</span></h1>
|
|
516
|
+
<p class="tagline" style="margin-bottom:0.5rem;font-size:0.75rem;">MIT Licensed | Made with ❤️ by Shreyas Kapale @ <span style="color:#f78166;">Lyzr</span></p>
|
|
517
|
+
<p class="tagline">A ClawContainer — serverless AI agent runtime, entirely in your browser.</p>
|
|
518
|
+
<ul>
|
|
519
|
+
<li>Powered by WebAssembly</li>
|
|
520
|
+
<li>Secure sandboxed execution</li>
|
|
521
|
+
<li>Full auditability of every action</li>
|
|
522
|
+
<li>No remote servers required</li>
|
|
523
|
+
<li>MIT Licensed</li>
|
|
524
|
+
</ul>
|
|
525
|
+
<p class="tagline" style="margin-top:1.5rem;font-size:0.85rem;">Use the address bar above to navigate to any URL.</p>
|
|
526
|
+
</div>
|
|
527
|
+
</body>
|
|
528
|
+
</html>`;
|
|
529
|
+
loading.classList.add('hidden');
|
|
530
|
+
iframe.style.visibility = 'visible';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ─── Cloud Browser tab ──────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
private cloudBrowserBound = false;
|
|
536
|
+
|
|
537
|
+
private async openCloudBrowserTab(): Promise<void> {
|
|
538
|
+
// If already open, switch to it
|
|
539
|
+
if (this.tabs.some(t => t.filePath === CLOUD_BROWSER_TAB_PATH)) {
|
|
540
|
+
this.switchTab(CLOUD_BROWSER_TAB_PATH);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
this.tabs.push({ filePath: CLOUD_BROWSER_TAB_PATH, filename: 'Cloud Browser' });
|
|
545
|
+
|
|
546
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
547
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
548
|
+
editorPanel.classList.remove('hidden');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
this.activeTabPath = CLOUD_BROWSER_TAB_PATH;
|
|
552
|
+
this.updateContentVisibility();
|
|
553
|
+
this.renderTabBar();
|
|
554
|
+
|
|
555
|
+
const iframe = document.getElementById('cloud-browser-iframe') as HTMLIFrameElement;
|
|
556
|
+
const loading = document.getElementById('cloud-browser-loading')!;
|
|
557
|
+
const status = document.getElementById('cloud-browser-status')!;
|
|
558
|
+
const urlInput = document.getElementById('cloud-browser-url') as HTMLInputElement;
|
|
559
|
+
|
|
560
|
+
if (!this.cloudBrowserBound) {
|
|
561
|
+
this.cloudBrowserBound = true;
|
|
562
|
+
|
|
563
|
+
document.getElementById('cloud-browser-reload')!.addEventListener('click', () => {
|
|
564
|
+
if (iframe.src) iframe.src = iframe.src;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const navigateCloud = () => {
|
|
568
|
+
const val = urlInput.value.trim();
|
|
569
|
+
if (!val) return;
|
|
570
|
+
// The Browserbase live view is interactive — user navigates directly in the iframe.
|
|
571
|
+
// The URL bar here is informational; navigation happens inside the session.
|
|
572
|
+
status.textContent = 'Navigate inside the cloud browser directly.';
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
document.getElementById('cloud-browser-go')!.addEventListener('click', navigateCloud);
|
|
576
|
+
urlInput.addEventListener('keydown', (e) => {
|
|
577
|
+
if (e.key === 'Enter') navigateCloud();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Scale iframe to fit viewport
|
|
581
|
+
const viewport = document.getElementById('cloud-browser-viewport')!;
|
|
582
|
+
const scaleIframe = () => {
|
|
583
|
+
const vw = viewport.clientWidth;
|
|
584
|
+
const vh = viewport.clientHeight;
|
|
585
|
+
if (vw === 0 || vh === 0) return;
|
|
586
|
+
const scale = Math.min(vw / 1920, vh / 1080);
|
|
587
|
+
iframe.style.transform = `scale(${scale})`;
|
|
588
|
+
iframe.style.width = '1920px';
|
|
589
|
+
iframe.style.height = '1080px';
|
|
590
|
+
};
|
|
591
|
+
const resizeObserver = new ResizeObserver(scaleIframe);
|
|
592
|
+
resizeObserver.observe(viewport);
|
|
593
|
+
scaleIframe();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Read API credentials from localStorage env vars
|
|
597
|
+
const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
|
|
598
|
+
let apiKey = '';
|
|
599
|
+
let projectId = '';
|
|
600
|
+
if (envJson) {
|
|
601
|
+
try {
|
|
602
|
+
const envVars = JSON.parse(envJson) as Record<string, string>;
|
|
603
|
+
apiKey = envVars['BROWSERBASE_API_KEY'] || '';
|
|
604
|
+
projectId = envVars['BROWSERBASE_PROJECT_ID'] || '';
|
|
605
|
+
} catch { /* ignore */ }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!apiKey || !projectId) {
|
|
609
|
+
loading.textContent = 'Missing BROWSERBASE_API_KEY or BROWSERBASE_PROJECT_ID. Add them in Config panel.';
|
|
610
|
+
status.textContent = 'Not configured';
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Create Browserbase session
|
|
615
|
+
loading.textContent = 'Starting cloud browser…';
|
|
616
|
+
loading.classList.remove('hidden');
|
|
617
|
+
iframe.style.visibility = 'hidden';
|
|
618
|
+
status.textContent = 'Connecting…';
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
let res: Response;
|
|
622
|
+
try {
|
|
623
|
+
res = await fetch('/api/browserbase/v1/sessions', {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: {
|
|
626
|
+
'X-BB-API-Key': apiKey,
|
|
627
|
+
'Content-Type': 'application/json',
|
|
628
|
+
},
|
|
629
|
+
body: JSON.stringify({ projectId }),
|
|
630
|
+
});
|
|
631
|
+
} catch (fetchErr) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`Network error calling Browserbase API: ${(fetchErr as Error).message}. ` +
|
|
634
|
+
'Check the browser console (F12 → Console) for details.'
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!res.ok) {
|
|
639
|
+
let errBody = '';
|
|
640
|
+
try { errBody = await res.text(); } catch { /* ignore */ }
|
|
641
|
+
const detail = errBody ? `: ${errBody}` : '';
|
|
642
|
+
if (res.status === 401 || res.status === 403) {
|
|
643
|
+
throw new Error(`Authentication failed (${res.status})${detail}. Check your BROWSERBASE_API_KEY.`);
|
|
644
|
+
} else if (res.status === 404) {
|
|
645
|
+
throw new Error(`Not found (${res.status})${detail}. Check your BROWSERBASE_PROJECT_ID.`);
|
|
646
|
+
} else if (res.status === 429) {
|
|
647
|
+
throw new Error(`Rate limited (${res.status})${detail}. Try again in a moment.`);
|
|
648
|
+
}
|
|
649
|
+
throw new Error(`Session creation failed (${res.status})${detail}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const session = await res.json();
|
|
653
|
+
if (!session.id) {
|
|
654
|
+
throw new Error(`Unexpected response — no session ID returned: ${JSON.stringify(session)}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Get debug/live view URL
|
|
658
|
+
let debugRes: Response;
|
|
659
|
+
try {
|
|
660
|
+
debugRes = await fetch(`/api/browserbase/v1/sessions/${session.id}/debug`, {
|
|
661
|
+
headers: { 'X-BB-API-Key': apiKey },
|
|
662
|
+
});
|
|
663
|
+
} catch (fetchErr) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`Network error fetching debug URL: ${(fetchErr as Error).message}. ` +
|
|
666
|
+
'Check the browser console (F12 → Console) for details.'
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!debugRes.ok) {
|
|
671
|
+
let errBody = '';
|
|
672
|
+
try { errBody = await debugRes.text(); } catch { /* ignore */ }
|
|
673
|
+
throw new Error(`Debug URL fetch failed (${debugRes.status})${errBody ? ': ' + errBody : ''}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const debug = await debugRes.json();
|
|
677
|
+
const liveUrl = debug.debuggerFullscreenUrl;
|
|
678
|
+
|
|
679
|
+
if (!liveUrl) {
|
|
680
|
+
throw new Error(`No debuggerFullscreenUrl in response: ${JSON.stringify(debug)}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
iframe.src = liveUrl;
|
|
684
|
+
urlInput.value = liveUrl;
|
|
685
|
+
status.textContent = 'Connected';
|
|
686
|
+
|
|
687
|
+
iframe.addEventListener('load', () => {
|
|
688
|
+
loading.classList.add('hidden');
|
|
689
|
+
iframe.style.visibility = 'visible';
|
|
690
|
+
}, { once: true });
|
|
691
|
+
} catch (e) {
|
|
692
|
+
const errMsg = (e as Error).message;
|
|
693
|
+
loading.textContent = errMsg;
|
|
694
|
+
status.textContent = 'Error';
|
|
695
|
+
console.error('[Cloud Browser]', e);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Show editor-container, preview-container, audit-container, policy-container, cloud-browser-container, or custom tab based on active tab type. */
|
|
700
|
+
private updateContentVisibility(): void {
|
|
701
|
+
const editorContainer = document.getElementById('editor-container')!;
|
|
702
|
+
const previewContainer = document.getElementById('preview-container')!;
|
|
703
|
+
const auditContainer = document.getElementById('audit-container')!;
|
|
704
|
+
const policyContainer = document.getElementById('policy-container')!;
|
|
705
|
+
const cloudBrowserContainer = document.getElementById('cloud-browser-container')!;
|
|
706
|
+
|
|
707
|
+
editorContainer.classList.add('hidden');
|
|
708
|
+
previewContainer.classList.add('hidden');
|
|
709
|
+
auditContainer.classList.add('hidden');
|
|
710
|
+
policyContainer.classList.add('hidden');
|
|
711
|
+
cloudBrowserContainer.classList.add('hidden');
|
|
712
|
+
|
|
713
|
+
// Hide all custom tab content
|
|
714
|
+
for (const path of this.customTabPaths) {
|
|
715
|
+
const id = path.replace('__custom_', '').replace('__', '');
|
|
716
|
+
document.getElementById(`custom-tab-${id}`)?.classList.add('hidden');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (this.activeTabPath === PREVIEW_TAB_PATH) {
|
|
720
|
+
previewContainer.classList.remove('hidden');
|
|
721
|
+
} else if (this.activeTabPath === AUDIT_TAB_PATH) {
|
|
722
|
+
auditContainer.classList.remove('hidden');
|
|
723
|
+
} else if (this.activeTabPath === POLICY_TAB_PATH) {
|
|
724
|
+
policyContainer.classList.remove('hidden');
|
|
725
|
+
} else if (this.activeTabPath === CLOUD_BROWSER_TAB_PATH) {
|
|
726
|
+
cloudBrowserContainer.classList.remove('hidden');
|
|
727
|
+
} else if (this.activeTabPath && this.customTabPaths.has(this.activeTabPath)) {
|
|
728
|
+
const id = this.activeTabPath.replace('__custom_', '').replace('__', '');
|
|
729
|
+
document.getElementById(`custom-tab-${id}`)?.classList.remove('hidden');
|
|
730
|
+
} else {
|
|
731
|
+
editorContainer.classList.remove('hidden');
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ─── Audit tab ───────────────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
private openAuditTab(): void {
|
|
738
|
+
// If already open, switch to it
|
|
739
|
+
if (this.tabs.some(t => t.filePath === AUDIT_TAB_PATH)) {
|
|
740
|
+
this.switchTab(AUDIT_TAB_PATH);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
this.tabs.push({ filePath: AUDIT_TAB_PATH, filename: 'Audit Log' });
|
|
745
|
+
|
|
746
|
+
// Show editor panel if hidden
|
|
747
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
748
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
749
|
+
editorPanel.classList.remove('hidden');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
this.activeTabPath = AUDIT_TAB_PATH;
|
|
753
|
+
this.updateContentVisibility();
|
|
754
|
+
this.renderTabBar();
|
|
755
|
+
this.renderAuditLog();
|
|
756
|
+
this.bindAuditFilters();
|
|
757
|
+
|
|
758
|
+
// Subscribe to live entries
|
|
759
|
+
this.auditUnsubscribe = this.audit.onEntry((entry) => {
|
|
760
|
+
if (this.activeTabPath !== AUDIT_TAB_PATH) return;
|
|
761
|
+
if (!this.matchesAuditFilters(entry)) return;
|
|
762
|
+
const list = document.getElementById('audit-log-list')!;
|
|
763
|
+
const wasAtBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 30;
|
|
764
|
+
list.appendChild(this.createAuditRow(entry));
|
|
765
|
+
if (wasAtBottom) list.scrollTop = list.scrollHeight;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private bindAuditFilters(): void {
|
|
770
|
+
const ids = ['audit-search', 'audit-filter-source', 'audit-filter-level', 'audit-filter-event', 'audit-date-from', 'audit-date-to'];
|
|
771
|
+
for (const id of ids) {
|
|
772
|
+
const el = document.getElementById(id)!;
|
|
773
|
+
el.addEventListener('input', () => this.renderAuditLog());
|
|
774
|
+
el.addEventListener('change', () => this.renderAuditLog());
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
document.getElementById('btn-audit-download')!.addEventListener('click', () => {
|
|
778
|
+
const json = this.audit.toJSON();
|
|
779
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
780
|
+
const url = URL.createObjectURL(blob);
|
|
781
|
+
const a = document.createElement('a');
|
|
782
|
+
a.href = url; a.download = 'audit.json'; a.click();
|
|
783
|
+
URL.revokeObjectURL(url);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private matchesAuditFilters(e: import('./audit.js').AuditEntry): boolean {
|
|
788
|
+
const source = (document.getElementById('audit-filter-source') as HTMLSelectElement).value;
|
|
789
|
+
const level = (document.getElementById('audit-filter-level') as HTMLSelectElement).value;
|
|
790
|
+
const event = (document.getElementById('audit-filter-event') as HTMLSelectElement).value;
|
|
791
|
+
const search = (document.getElementById('audit-search') as HTMLInputElement).value.toLowerCase();
|
|
792
|
+
const dateFrom = (document.getElementById('audit-date-from') as HTMLInputElement).value;
|
|
793
|
+
const dateTo = (document.getElementById('audit-date-to') as HTMLInputElement).value;
|
|
794
|
+
|
|
795
|
+
if (source && e.source !== source) return false;
|
|
796
|
+
if (level && e.level !== level) return false;
|
|
797
|
+
if (event && e.event !== event) return false;
|
|
798
|
+
if (dateFrom && e.timestamp < new Date(dateFrom).toISOString()) return false;
|
|
799
|
+
if (dateTo && e.timestamp > new Date(dateTo).toISOString()) return false;
|
|
800
|
+
if (search) {
|
|
801
|
+
const hay = `${e.detail} ${e.event} ${e.source ?? ''} ${e.meta ? JSON.stringify(e.meta) : ''}`.toLowerCase();
|
|
802
|
+
if (!hay.includes(search)) return false;
|
|
803
|
+
}
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private renderAuditLog(): void {
|
|
808
|
+
const list = document.getElementById('audit-log-list')!;
|
|
809
|
+
list.innerHTML = '';
|
|
810
|
+
const entries = this.audit.getEntries().filter(e => this.matchesAuditFilters(e));
|
|
811
|
+
for (const entry of entries) {
|
|
812
|
+
list.appendChild(this.createAuditRow(entry));
|
|
813
|
+
}
|
|
814
|
+
list.scrollTop = list.scrollHeight;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private createAuditRow(e: import('./audit.js').AuditEntry): HTMLElement {
|
|
818
|
+
const row = document.createElement('div');
|
|
819
|
+
row.className = 'audit-row';
|
|
820
|
+
|
|
821
|
+
const ts = e.timestamp.slice(11, 23); // HH:mm:ss.mmm
|
|
822
|
+
const lvl = (e.level ?? 'info');
|
|
823
|
+
const src = e.source ?? 'system';
|
|
824
|
+
|
|
825
|
+
row.innerHTML =
|
|
826
|
+
`<span class="audit-ts">${ts}</span>` +
|
|
827
|
+
`<span class="audit-level audit-level-${lvl}">${lvl}</span>` +
|
|
828
|
+
`<span class="audit-source">${src}</span>` +
|
|
829
|
+
`<span class="audit-event">${e.event}</span>` +
|
|
830
|
+
`<span class="audit-detail" title="${this.escHtml(e.detail)}">${this.escHtml(e.detail)}</span>` +
|
|
831
|
+
(e.meta ? `<span class="audit-meta">${this.escHtml(JSON.stringify(e.meta))}</span>` : '');
|
|
832
|
+
|
|
833
|
+
return row;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private escHtml(s: string): string {
|
|
837
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ─── Topbar ────────────────────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
private bindTopbarButtons(): void {
|
|
843
|
+
document.getElementById('btn-preview')!.addEventListener('click', () =>
|
|
844
|
+
this.openPreviewTab());
|
|
845
|
+
|
|
846
|
+
document.getElementById('btn-config')!.addEventListener('click', () =>
|
|
847
|
+
this.togglePanel('config-panel', 'btn-config'));
|
|
848
|
+
|
|
849
|
+
document.getElementById('btn-policy')!.addEventListener('click', () =>
|
|
850
|
+
this.openPolicyTab());
|
|
851
|
+
|
|
852
|
+
document.getElementById('btn-audit')!.addEventListener('click', () =>
|
|
853
|
+
this.openAuditTab());
|
|
854
|
+
|
|
855
|
+
document.getElementById('btn-cloud-browser')!.addEventListener('click', () =>
|
|
856
|
+
this.openCloudBrowserTab());
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ─── Repo controls (clone / sync / auto-sync) ────────────────────────────
|
|
860
|
+
|
|
861
|
+
private bindRepoControls(): void {
|
|
862
|
+
const urlInput = document.getElementById('repo-url-input') as HTMLInputElement;
|
|
863
|
+
const btnClone = document.getElementById('btn-clone')!;
|
|
864
|
+
const btnSync = document.getElementById('btn-sync')!;
|
|
865
|
+
const autoSyncCb = document.getElementById('auto-sync-checkbox') as HTMLInputElement;
|
|
866
|
+
|
|
867
|
+
btnClone.addEventListener('click', () => this.handleClone(urlInput));
|
|
868
|
+
btnSync.addEventListener('click', () => this.handleSync(btnSync));
|
|
869
|
+
|
|
870
|
+
autoSyncCb.addEventListener('change', () => {
|
|
871
|
+
if (autoSyncCb.checked && this.container.hasClonedRepo) {
|
|
872
|
+
this.startAutoSync();
|
|
873
|
+
} else {
|
|
874
|
+
this.stopAutoSync();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private async handleClone(urlInput: HTMLInputElement): Promise<void> {
|
|
880
|
+
if (this.cloneInProgress) return;
|
|
881
|
+
const url = urlInput.value.trim();
|
|
882
|
+
if (!url) { urlInput.focus(); return; }
|
|
883
|
+
|
|
884
|
+
// Get GITHUB_TOKEN from saved env vars
|
|
885
|
+
const config = this.getSavedConfig();
|
|
886
|
+
const token = config?.envVars?.['GITHUB_TOKEN'] ?? '';
|
|
887
|
+
if (!token) {
|
|
888
|
+
alert('Please add a GITHUB_TOKEN in API Keys & Config before cloning.');
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
this.cloneInProgress = true;
|
|
893
|
+
const btnClone = document.getElementById('btn-clone')!;
|
|
894
|
+
btnClone.classList.add('syncing');
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
await this.container.cloneRepo(url, token);
|
|
898
|
+
document.getElementById('btn-sync')!.removeAttribute('disabled');
|
|
899
|
+
this.refreshFileTree();
|
|
900
|
+
} catch (e) {
|
|
901
|
+
alert(`Clone failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
902
|
+
} finally {
|
|
903
|
+
this.cloneInProgress = false;
|
|
904
|
+
btnClone.classList.remove('syncing');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private async handleSync(btnSync: HTMLElement): Promise<void> {
|
|
909
|
+
if (this.syncInProgress || !this.container.hasClonedRepo) return;
|
|
910
|
+
this.syncInProgress = true;
|
|
911
|
+
btnSync.classList.add('syncing');
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
await this.container.syncToRepo();
|
|
915
|
+
} catch (e) {
|
|
916
|
+
alert(`Sync failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
917
|
+
} finally {
|
|
918
|
+
this.syncInProgress = false;
|
|
919
|
+
btnSync.classList.remove('syncing');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private startAutoSync(): void {
|
|
924
|
+
if (!this.container.hasClonedRepo) return;
|
|
925
|
+
// Listen for file changes with debounce
|
|
926
|
+
this.container.onFileChange((path: string) => {
|
|
927
|
+
// Skip ignored paths
|
|
928
|
+
if (/node_modules\/|\.git\/|\.env$/.test(path)) return;
|
|
929
|
+
if (this.cloneInProgress || this.syncInProgress) return;
|
|
930
|
+
|
|
931
|
+
if (this.autoSyncTimer) clearTimeout(this.autoSyncTimer);
|
|
932
|
+
this.autoSyncTimer = setTimeout(() => {
|
|
933
|
+
this.autoSyncTimer = null;
|
|
934
|
+
const btnSync = document.getElementById('btn-sync')!;
|
|
935
|
+
this.handleSync(btnSync);
|
|
936
|
+
}, 3000);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private stopAutoSync(): void {
|
|
941
|
+
if (this.autoSyncTimer) {
|
|
942
|
+
clearTimeout(this.autoSyncTimer);
|
|
943
|
+
this.autoSyncTimer = null;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ─── Config panel ──────────────────────────────────────────────────────────
|
|
948
|
+
|
|
949
|
+
private bindConfigPanel(): void {
|
|
950
|
+
const providerSel = document.getElementById('provider-select') as HTMLSelectElement;
|
|
951
|
+
providerSel.addEventListener('change', () => {
|
|
952
|
+
this.populateModelOptions();
|
|
953
|
+
this.syncDefaultEnvKey();
|
|
954
|
+
});
|
|
955
|
+
document.getElementById('btn-add-env')!.addEventListener('click', () =>
|
|
956
|
+
this.addEnvRow('', ''));
|
|
957
|
+
document.getElementById('btn-save-config')!.addEventListener('click', () =>
|
|
958
|
+
this.saveConfig());
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private populateModelOptions(): void {
|
|
962
|
+
const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
|
|
963
|
+
const modelSel = document.getElementById('model-select') as HTMLSelectElement;
|
|
964
|
+
modelSel.innerHTML = '';
|
|
965
|
+
for (const m of PROVIDER_MODELS[provider] ?? []) {
|
|
966
|
+
const opt = document.createElement('option');
|
|
967
|
+
opt.value = m; opt.textContent = m.split(':')[1];
|
|
968
|
+
modelSel.appendChild(opt);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Ensure the first env row key matches the selected provider. */
|
|
973
|
+
private syncDefaultEnvKey(): void {
|
|
974
|
+
const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
|
|
975
|
+
const keyName = providerEnvKey(provider);
|
|
976
|
+
const rows = document.getElementById('env-rows')!;
|
|
977
|
+
const firstKey = rows.querySelector('.env-key') as HTMLInputElement | null;
|
|
978
|
+
if (firstKey) firstKey.value = keyName;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private addEnvRow(key: string, value: string): HTMLDivElement {
|
|
982
|
+
const rows = document.getElementById('env-rows')!;
|
|
983
|
+
const row = document.createElement('div');
|
|
984
|
+
row.className = 'env-row';
|
|
985
|
+
|
|
986
|
+
const keyInput = document.createElement('input');
|
|
987
|
+
keyInput.type = 'text';
|
|
988
|
+
keyInput.className = 'env-key';
|
|
989
|
+
keyInput.placeholder = 'KEY_NAME';
|
|
990
|
+
keyInput.value = key;
|
|
991
|
+
|
|
992
|
+
const valInput = document.createElement('input');
|
|
993
|
+
valInput.type = 'password';
|
|
994
|
+
valInput.className = 'env-val';
|
|
995
|
+
valInput.placeholder = 'value';
|
|
996
|
+
valInput.value = value;
|
|
997
|
+
|
|
998
|
+
const removeBtn = document.createElement('button');
|
|
999
|
+
removeBtn.className = 'btn-remove-env';
|
|
1000
|
+
removeBtn.textContent = '\u00d7';
|
|
1001
|
+
removeBtn.title = 'Remove';
|
|
1002
|
+
removeBtn.addEventListener('click', () => row.remove());
|
|
1003
|
+
|
|
1004
|
+
row.appendChild(keyInput);
|
|
1005
|
+
row.appendChild(valInput);
|
|
1006
|
+
row.appendChild(removeBtn);
|
|
1007
|
+
rows.appendChild(row);
|
|
1008
|
+
return row;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private getEnvRows(): Record<string, string> {
|
|
1012
|
+
const vars: Record<string, string> = {};
|
|
1013
|
+
const rows = document.querySelectorAll('#env-rows .env-row');
|
|
1014
|
+
for (const row of rows) {
|
|
1015
|
+
const k = (row.querySelector('.env-key') as HTMLInputElement).value.trim();
|
|
1016
|
+
const v = (row.querySelector('.env-val') as HTMLInputElement).value.trim();
|
|
1017
|
+
if (k && v) vars[k] = v;
|
|
1018
|
+
}
|
|
1019
|
+
return vars;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private restoreConfig(): void {
|
|
1023
|
+
const provider = localStorage.getItem(`${LS_PREFIX}provider`);
|
|
1024
|
+
const model = localStorage.getItem(`${LS_PREFIX}model`);
|
|
1025
|
+
const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
|
|
1026
|
+
|
|
1027
|
+
if (provider) {
|
|
1028
|
+
(document.getElementById('provider-select') as HTMLSelectElement).value = provider;
|
|
1029
|
+
this.populateModelOptions();
|
|
1030
|
+
}
|
|
1031
|
+
if (model) setTimeout(() => {
|
|
1032
|
+
(document.getElementById('model-select') as HTMLSelectElement).value = model;
|
|
1033
|
+
}, 0);
|
|
1034
|
+
|
|
1035
|
+
// Restore env var rows
|
|
1036
|
+
let envVars: Record<string, string> = {};
|
|
1037
|
+
if (envJson) {
|
|
1038
|
+
try { envVars = JSON.parse(envJson); } catch { /* ignore */ }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Migrate from old format if no envVars saved yet
|
|
1042
|
+
if (!envJson) {
|
|
1043
|
+
const oldKey = localStorage.getItem(`${LS_PREFIX}apiKey`);
|
|
1044
|
+
const oldVoice = localStorage.getItem(`${LS_PREFIX}openaiVoiceKey`);
|
|
1045
|
+
const prov = provider ?? 'anthropic';
|
|
1046
|
+
if (oldKey) envVars[providerEnvKey(prov)] = oldKey;
|
|
1047
|
+
if (oldVoice) envVars['OPENAI_API_KEY'] = oldVoice;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (Object.keys(envVars).length === 0) {
|
|
1051
|
+
// Add a default empty row for the selected provider
|
|
1052
|
+
const prov = provider ?? 'anthropic';
|
|
1053
|
+
this.addEnvRow(providerEnvKey(prov), '');
|
|
1054
|
+
} else {
|
|
1055
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
1056
|
+
this.addEnvRow(k, v);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private async saveConfig(): Promise<void> {
|
|
1062
|
+
const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
|
|
1063
|
+
const model = (document.getElementById('model-select') as HTMLSelectElement).value;
|
|
1064
|
+
const envVars = this.getEnvRows();
|
|
1065
|
+
const msg = document.getElementById('config-message')!;
|
|
1066
|
+
|
|
1067
|
+
if (Object.keys(envVars).length === 0) {
|
|
1068
|
+
showMsg(msg, 'At least one environment variable is required.', 'error');
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
localStorage.setItem(`${LS_PREFIX}provider`, provider);
|
|
1073
|
+
localStorage.setItem(`${LS_PREFIX}model`, model);
|
|
1074
|
+
localStorage.setItem(`${LS_PREFIX}envVars`, JSON.stringify(envVars));
|
|
1075
|
+
|
|
1076
|
+
try {
|
|
1077
|
+
await this.container.configureEnv({ provider, model, envVars });
|
|
1078
|
+
showMsg(msg, 'Saved. Restart gitclaw (/quit) to apply.', 'success');
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
showMsg(msg, `Error: ${(e as Error).message}`, 'error');
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ─── Policy tab ───────────────────────────────────────────────────────────
|
|
1085
|
+
|
|
1086
|
+
private openPolicyTab(): void {
|
|
1087
|
+
if (this.tabs.some(t => t.filePath === POLICY_TAB_PATH)) {
|
|
1088
|
+
this.switchTab(POLICY_TAB_PATH);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
this.tabs.push({ filePath: POLICY_TAB_PATH, filename: 'Policy' });
|
|
1093
|
+
|
|
1094
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
1095
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
1096
|
+
editorPanel.classList.remove('hidden');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
this.loadPolicyYaml();
|
|
1100
|
+
this.activeTabPath = POLICY_TAB_PATH;
|
|
1101
|
+
this.updateContentVisibility();
|
|
1102
|
+
this.renderTabBar();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private bindPolicyPanel(): void {
|
|
1106
|
+
document.getElementById('btn-apply-policy')!.addEventListener('click', () =>
|
|
1107
|
+
this.applyPolicyYaml());
|
|
1108
|
+
document.getElementById('btn-reset-policy')!.addEventListener('click', () =>
|
|
1109
|
+
this.resetPolicy());
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
private loadPolicyYaml(): void {
|
|
1113
|
+
(document.getElementById('policy-yaml-editor') as HTMLTextAreaElement).value =
|
|
1114
|
+
this.policy.toYaml();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
private applyPolicyYaml(): void {
|
|
1118
|
+
const yaml = (document.getElementById('policy-yaml-editor') as HTMLTextAreaElement).value;
|
|
1119
|
+
const msg = document.getElementById('policy-message')!;
|
|
1120
|
+
try {
|
|
1121
|
+
const parsed = PolicyEngine.fromYaml(yaml);
|
|
1122
|
+
this.policy.loadPolicy(parsed);
|
|
1123
|
+
localStorage.setItem('clawchef_policy', yaml);
|
|
1124
|
+
this.audit.log('policy.load', 'Policy updated from UI', undefined, { source: 'user' });
|
|
1125
|
+
showMsg(msg, 'Policy applied.', 'success');
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
showMsg(msg, `Invalid policy: ${(e as Error).message}`, 'error');
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private resetPolicy(): void {
|
|
1132
|
+
const defaultPolicy = PolicyEngine.defaultPolicy();
|
|
1133
|
+
this.policy.loadPolicy(defaultPolicy);
|
|
1134
|
+
localStorage.removeItem('clawchef_policy');
|
|
1135
|
+
this.audit.log('policy.load', 'Policy reset to default', undefined, { source: 'user' });
|
|
1136
|
+
this.loadPolicyYaml();
|
|
1137
|
+
const msg = document.getElementById('policy-message')!;
|
|
1138
|
+
showMsg(msg, 'Policy reset to default.', 'success');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ─── Panel helpers ─────────────────────────────────────────────────────────
|
|
1142
|
+
|
|
1143
|
+
private togglePanel(panelId: string, btnId: string): void {
|
|
1144
|
+
const sidebar = document.getElementById('sidebar')!;
|
|
1145
|
+
const btn = document.getElementById(btnId)!;
|
|
1146
|
+
|
|
1147
|
+
if (this.activePanelId === panelId) {
|
|
1148
|
+
sidebar.classList.add('sidebar-hidden');
|
|
1149
|
+
btn.classList.remove('active');
|
|
1150
|
+
this.activePanelId = null;
|
|
1151
|
+
} else {
|
|
1152
|
+
this.openPanel(panelId);
|
|
1153
|
+
document.querySelectorAll('.btn-icon').forEach(b => b.classList.remove('active'));
|
|
1154
|
+
btn.classList.add('active');
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private openPanel(panelId: string): void {
|
|
1159
|
+
document.getElementById('sidebar')!.classList.remove('sidebar-hidden');
|
|
1160
|
+
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
|
|
1161
|
+
document.getElementById(panelId)!.classList.remove('hidden');
|
|
1162
|
+
this.activePanelId = panelId;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ─── Custom tab API ─────────────────────────────────────────────────────────
|
|
1166
|
+
|
|
1167
|
+
/** Add a custom tab with user-defined content. */
|
|
1168
|
+
addCustomTab(def: TabDefinition): void {
|
|
1169
|
+
const tabPath = `__custom_${def.id}__`;
|
|
1170
|
+
|
|
1171
|
+
// If tab already open, switch to it
|
|
1172
|
+
if (this.tabs.some(t => t.filePath === tabPath)) {
|
|
1173
|
+
this.switchTab(tabPath);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Create content container inside editor-panel
|
|
1178
|
+
const editorPanel = document.getElementById('editor-panel')!;
|
|
1179
|
+
const contentDiv = document.createElement('div');
|
|
1180
|
+
contentDiv.id = `custom-tab-${def.id}`;
|
|
1181
|
+
contentDiv.className = 'custom-tab-content hidden';
|
|
1182
|
+
contentDiv.style.cssText = 'position:absolute;inset:0;overflow:auto;padding:1rem;';
|
|
1183
|
+
|
|
1184
|
+
if (typeof def.render === 'string') {
|
|
1185
|
+
contentDiv.innerHTML = def.render;
|
|
1186
|
+
} else {
|
|
1187
|
+
def.render(contentDiv);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
editorPanel.appendChild(contentDiv);
|
|
1191
|
+
|
|
1192
|
+
// Track as custom
|
|
1193
|
+
this.customTabPaths.add(tabPath);
|
|
1194
|
+
|
|
1195
|
+
// Add tab
|
|
1196
|
+
this.tabs.push({ filePath: tabPath, filename: def.label });
|
|
1197
|
+
|
|
1198
|
+
if (editorPanel.classList.contains('hidden')) {
|
|
1199
|
+
editorPanel.classList.remove('hidden');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
this.activeTabPath = tabPath;
|
|
1203
|
+
this.updateContentVisibility();
|
|
1204
|
+
this.renderTabBar();
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Remove a custom tab by its definition id. */
|
|
1208
|
+
removeCustomTab(id: string): void {
|
|
1209
|
+
const tabPath = `__custom_${id}__`;
|
|
1210
|
+
this.customTabPaths.delete(tabPath);
|
|
1211
|
+
|
|
1212
|
+
// Remove content div
|
|
1213
|
+
const contentDiv = document.getElementById(`custom-tab-${id}`);
|
|
1214
|
+
contentDiv?.remove();
|
|
1215
|
+
|
|
1216
|
+
// Close the tab
|
|
1217
|
+
this.closeTab(tabPath);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1222
|
+
|
|
1223
|
+
function showMsg(el: HTMLElement, text: string, type: 'success'|'error'|'info'): void {
|
|
1224
|
+
el.textContent = text;
|
|
1225
|
+
el.className = `message ${type}`;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function fileIcon(name: string): string {
|
|
1229
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
1230
|
+
if (ext === 'md') return svgIcon('file-text');
|
|
1231
|
+
if (ext === 'yaml' || ext === 'yml') return svgIcon('settings');
|
|
1232
|
+
if (ext === 'json') return svgIcon('braces');
|
|
1233
|
+
if (ext === 'ts' || ext === 'tsx') return svgIcon('file-code');
|
|
1234
|
+
if (ext === 'js' || ext === 'jsx') return svgIcon('file-code');
|
|
1235
|
+
if (ext === 'sh' || ext === 'bash') return svgIcon('terminal');
|
|
1236
|
+
if (ext === 'py') return svgIcon('file-code');
|
|
1237
|
+
if (['png','jpg','jpeg','gif','svg'].includes(ext)) return svgIcon('image');
|
|
1238
|
+
if (['pdf','pptx','ppt'].includes(ext)) return svgIcon('file-text');
|
|
1239
|
+
if (['xlsx','xls','csv'].includes(ext)) return svgIcon('table');
|
|
1240
|
+
if (['zip','tar','gz'].includes(ext)) return svgIcon('package');
|
|
1241
|
+
if (['mp4','mp3'].includes(ext)) return svgIcon('film');
|
|
1242
|
+
return svgIcon('file');
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Inline SVG icons — no external dependency needed. */
|
|
1246
|
+
function svgIcon(name: string): string {
|
|
1247
|
+
const s = `width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
|
|
1248
|
+
const icons: Record<string, string> = {
|
|
1249
|
+
folder: `<svg ${s}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
|
|
1250
|
+
globe: `<svg ${s}><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
|
|
1251
|
+
file: `<svg ${s}><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`,
|
|
1252
|
+
'file-text': `<svg ${s}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
|
1253
|
+
'file-code': `<svg ${s}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 15.5 8 13.5 10 11.5"/><polyline points="14 11.5 16 13.5 14 15.5"/></svg>`,
|
|
1254
|
+
settings: `<svg ${s}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
|
1255
|
+
braces: `<svg ${s}><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
|
|
1256
|
+
terminal: `<svg ${s}><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
|
|
1257
|
+
image: `<svg ${s}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
|
1258
|
+
table: `<svg ${s}><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>`,
|
|
1259
|
+
package: `<svg ${s}><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
|
|
1260
|
+
film: `<svg ${s}><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg>`,
|
|
1261
|
+
activity: `<svg ${s}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
|
|
1262
|
+
shield: `<svg ${s}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
|
1263
|
+
cloud: `<svg ${s}><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>`,
|
|
1264
|
+
};
|
|
1265
|
+
return icons[name] ?? icons['file'];
|
|
1266
|
+
}
|