@bibobonking/opensol 0.0.0-stage → 3.0.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.
- package/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Eye, FolderOpen, Package, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'
|
|
3
|
+
import type { InstalledPlugin } from '../lib/daemon'
|
|
4
|
+
import {
|
|
5
|
+
getInstalledPluginArchive,
|
|
6
|
+
installPluginArchive,
|
|
7
|
+
listInstalledPlugins,
|
|
8
|
+
removeInstalledPlugin,
|
|
9
|
+
savePluginGrants,
|
|
10
|
+
setPluginActivation,
|
|
11
|
+
setPluginTrust,
|
|
12
|
+
} from '../lib/daemon'
|
|
13
|
+
import { attachPreviewBridge, previewDocument } from '../plugins/preview'
|
|
14
|
+
import type { PluginCapability } from '../../shared/plugin-package.mjs'
|
|
15
|
+
import { readPluginArchive } from '../../shared/plugin-package.mjs'
|
|
16
|
+
import { useUi } from '../store/ui'
|
|
17
|
+
import { cn } from '../lib/utils'
|
|
18
|
+
import { Switch } from './ui'
|
|
19
|
+
import { PluginBuilder } from './PluginBuilder'
|
|
20
|
+
|
|
21
|
+
const PREVIEW_LOGS = 6
|
|
22
|
+
|
|
23
|
+
function PluginPreview({ plugin }: { plugin: InstalledPlugin }) {
|
|
24
|
+
const notify = useUi((state) => state.notify)
|
|
25
|
+
const [html, setHtml] = useState<string | null>(null)
|
|
26
|
+
const [log, setLog] = useState<string[]>([])
|
|
27
|
+
const frameRef = useRef<HTMLIFrameElement>(null)
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!frameRef.current || !html) return
|
|
32
|
+
return attachPreviewBridge(frameRef.current, (message) => {
|
|
33
|
+
const entry = message as { type: string; message?: string; label?: string }
|
|
34
|
+
const text =
|
|
35
|
+
entry.type === 'ready'
|
|
36
|
+
? 'Sandbox ready — no app access, no network.'
|
|
37
|
+
: `${entry.type}: ${entry.message ?? entry.label ?? ''}`
|
|
38
|
+
setLog((current) => [...current.slice(-(PREVIEW_LOGS - 1)), text])
|
|
39
|
+
})
|
|
40
|
+
}, [html])
|
|
41
|
+
|
|
42
|
+
const start = async () => {
|
|
43
|
+
setLoading(true)
|
|
44
|
+
try {
|
|
45
|
+
const archive = await getInstalledPluginArchive(plugin.id)
|
|
46
|
+
const { files, manifest } = readPluginArchive(archive)
|
|
47
|
+
setLog([])
|
|
48
|
+
setHtml(previewDocument(manifest, files[manifest.entry] ?? '', files[manifest.stylesheet] ?? ''))
|
|
49
|
+
} catch (error) {
|
|
50
|
+
notify(error instanceof Error ? error.message : 'This plugin could not be previewed.', 'error')
|
|
51
|
+
} finally {
|
|
52
|
+
setLoading(false)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
disabled={loading}
|
|
61
|
+
onClick={() => (html ? setHtml(null) : void start())}
|
|
62
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line px-2.5 text-[10.5px] font-medium text-muted hover:text-ink disabled:opacity-40"
|
|
63
|
+
>
|
|
64
|
+
<Eye size={11} />
|
|
65
|
+
{html ? 'Stop preview' : 'Preview in sandbox'}
|
|
66
|
+
</button>
|
|
67
|
+
{html && (
|
|
68
|
+
<div className="w-full overflow-hidden rounded-lg border border-line">
|
|
69
|
+
<iframe
|
|
70
|
+
ref={frameRef}
|
|
71
|
+
title={`${plugin.manifest.name} preview`}
|
|
72
|
+
sandbox="allow-scripts"
|
|
73
|
+
srcDoc={html}
|
|
74
|
+
className="h-52 w-full bg-panel"
|
|
75
|
+
/>
|
|
76
|
+
<div className="max-h-24 overflow-y-auto border-t border-line bg-raised/40 px-2.5 py-1.5 font-mono text-[9.5px] leading-relaxed text-faint">
|
|
77
|
+
{log.map((entry, index) => (
|
|
78
|
+
<p key={index} className="truncate">
|
|
79
|
+
{entry}
|
|
80
|
+
</p>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CAPABILITY_LABELS: Record<PluginCapability, string> = {
|
|
90
|
+
'workspace.list': 'List files in the open workspace',
|
|
91
|
+
'workspace.read': 'Read workspace files',
|
|
92
|
+
'workspace.write': 'Create and overwrite workspace files',
|
|
93
|
+
'workspace.edit': 'Edit workspace files in place',
|
|
94
|
+
'ui.commands': 'Add commands to OpenSol',
|
|
95
|
+
'ui.panels': 'Add panels to OpenSol',
|
|
96
|
+
'ui.themes': 'Add themes to OpenSol',
|
|
97
|
+
'network.request': 'Call the HTTPS domains it declared',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function PluginManager() {
|
|
101
|
+
const notify = useUi((state) => state.notify)
|
|
102
|
+
const askConfirm = useUi((state) => state.askConfirm)
|
|
103
|
+
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
|
|
104
|
+
const [busy, setBusy] = useState(false)
|
|
105
|
+
const [open, setOpen] = useState<string | null>(null)
|
|
106
|
+
const fileRef = useRef<HTMLInputElement>(null)
|
|
107
|
+
|
|
108
|
+
const refresh = async () => {
|
|
109
|
+
try {
|
|
110
|
+
setPlugins((await listInstalledPlugins()).plugins)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
notify(error instanceof Error ? error.message : 'Could not list plugins.', 'error')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
void refresh()
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
const replace = (plugin: InstalledPlugin) => {
|
|
121
|
+
setPlugins((current) => current.map((entry) => (entry.id === plugin.id ? plugin : entry)))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const install = async (file: File) => {
|
|
125
|
+
setBusy(true)
|
|
126
|
+
try {
|
|
127
|
+
// Read it locally first: a malformed file is rejected before it reaches the daemon.
|
|
128
|
+
readPluginArchive(new Uint8Array(await file.arrayBuffer()))
|
|
129
|
+
const { plugin } = await installPluginArchive(new Uint8Array(await file.arrayBuffer()))
|
|
130
|
+
setPlugins((current) => [...current.filter((entry) => entry.id !== plugin.id), plugin])
|
|
131
|
+
setOpen(plugin.id)
|
|
132
|
+
notify(`${plugin.manifest.name} installed. It starts sandboxed and disabled.`, 'success')
|
|
133
|
+
} catch (error) {
|
|
134
|
+
notify(error instanceof Error ? error.message : 'That file is not a valid plugin package.', 'error')
|
|
135
|
+
} finally {
|
|
136
|
+
setBusy(false)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const grant = async (plugin: InstalledPlugin, capabilities: PluginCapability[], domains: string[]) => {
|
|
141
|
+
try {
|
|
142
|
+
replace((await savePluginGrants(plugin.id, { capabilities, domains })).plugin)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
notify(error instanceof Error ? error.message : 'Could not save plugin permissions.', 'error')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const trust = async (plugin: InstalledPlugin) => {
|
|
149
|
+
if (plugin.safe) {
|
|
150
|
+
replace((await setPluginTrust(plugin.id, false)).plugin)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const ok = await askConfirm({
|
|
154
|
+
title: `Run ${plugin.manifest.name} outside the sandbox?`,
|
|
155
|
+
body: 'Safe plugins run as real JavaScript inside OpenSol. They can do anything this app can, so only mark plugins you wrote or have reviewed.',
|
|
156
|
+
confirmLabel: 'Mark as Safe',
|
|
157
|
+
tone: 'danger',
|
|
158
|
+
})
|
|
159
|
+
if (!ok) return
|
|
160
|
+
replace((await setPluginTrust(plugin.id, true)).plugin)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<>
|
|
165
|
+
<section className="rounded-xl border border-line bg-raised/20 p-3">
|
|
166
|
+
<h3 className="text-[12px] font-medium text-ink">Install a local plugin</h3>
|
|
167
|
+
<p className="mt-1 text-[10.5px] leading-relaxed text-faint">
|
|
168
|
+
OpenSol installs <code className="font-mono">.plugin.json</code> packages you pick from this machine. New plugins
|
|
169
|
+
start sandboxed and disabled until you review them.
|
|
170
|
+
</p>
|
|
171
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
disabled={busy}
|
|
175
|
+
onClick={() => fileRef.current?.click()}
|
|
176
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[10.5px] font-medium text-on-accent hover:bg-accent/90 disabled:opacity-40"
|
|
177
|
+
>
|
|
178
|
+
<FolderOpen size={12} />
|
|
179
|
+
Choose plugin package
|
|
180
|
+
</button>
|
|
181
|
+
<input
|
|
182
|
+
ref={fileRef}
|
|
183
|
+
type="file"
|
|
184
|
+
accept=".plugin.json,.pluginmax,application/json,application/zip"
|
|
185
|
+
className="hidden"
|
|
186
|
+
onChange={(event) => {
|
|
187
|
+
const file = event.target.files?.[0]
|
|
188
|
+
event.target.value = ''
|
|
189
|
+
if (file) void install(file)
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</section>
|
|
194
|
+
|
|
195
|
+
{plugins.length === 0 ? (
|
|
196
|
+
<p className="rounded-lg border border-line bg-raised/30 px-3 py-3 text-[10.5px] text-faint">
|
|
197
|
+
No plugins installed.
|
|
198
|
+
</p>
|
|
199
|
+
) : (
|
|
200
|
+
<div className="space-y-2">
|
|
201
|
+
{plugins.map((plugin) => {
|
|
202
|
+
const expanded = open === plugin.id
|
|
203
|
+
return (
|
|
204
|
+
<div key={plugin.id} className="overflow-hidden rounded-xl border border-line">
|
|
205
|
+
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
206
|
+
<Package size={14} className="shrink-0 text-faint" />
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => setOpen(expanded ? null : plugin.id)}
|
|
210
|
+
className="min-w-0 flex-1 text-left"
|
|
211
|
+
>
|
|
212
|
+
<span className="block truncate text-[11.5px] font-medium text-ink">{plugin.manifest.name}</span>
|
|
213
|
+
<span className="block truncate text-[9.5px] text-faint">
|
|
214
|
+
{plugin.id} · v{plugin.manifest.version} ·{' '}
|
|
215
|
+
{plugin.safe ? 'Safe — runs unsandboxed' : 'Sandboxed'}
|
|
216
|
+
</span>
|
|
217
|
+
</button>
|
|
218
|
+
<Switch
|
|
219
|
+
checked={plugin.enabled}
|
|
220
|
+
disabled={!plugin.safe}
|
|
221
|
+
label={`Enable ${plugin.manifest.name}`}
|
|
222
|
+
onChange={async (enabled) => {
|
|
223
|
+
replace((await setPluginActivation(plugin.id, enabled)).plugin)
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{expanded && (
|
|
229
|
+
<div className="space-y-2.5 border-t border-line px-3 py-3">
|
|
230
|
+
<div className={cn('flex items-start gap-2 rounded-lg px-2.5 py-2 text-[10px] leading-relaxed', plugin.safe ? 'bg-warning/10 text-ink' : 'bg-raised/60 text-muted')}>
|
|
231
|
+
{plugin.safe ? <ShieldAlert size={13} className="mt-0.5 shrink-0 text-warning" /> : <ShieldCheck size={13} className="mt-0.5 shrink-0 text-faint" />}
|
|
232
|
+
<span>
|
|
233
|
+
{plugin.safe
|
|
234
|
+
? 'This plugin runs as real JavaScript in OpenSol with the permissions below. It is not sandboxed any more.'
|
|
235
|
+
: 'This plugin is sandboxed. Mark it Safe to run its code, or preview it first.'}
|
|
236
|
+
</span>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div>
|
|
240
|
+
<p className="text-[10.5px] font-medium text-ink">Capabilities</p>
|
|
241
|
+
<div className="mt-1.5 space-y-1">
|
|
242
|
+
{plugin.manifest.capabilities.length === 0 && (
|
|
243
|
+
<p className="text-[10px] text-faint">This plugin requests no capabilities.</p>
|
|
244
|
+
)}
|
|
245
|
+
{plugin.manifest.capabilities.map((capability) => (
|
|
246
|
+
<label key={capability} className="flex items-center gap-2 text-[10.5px] text-muted">
|
|
247
|
+
<input
|
|
248
|
+
type="checkbox"
|
|
249
|
+
className="accent-[rgb(var(--accent))]"
|
|
250
|
+
checked={plugin.grants.capabilities.includes(capability)}
|
|
251
|
+
onChange={(event) =>
|
|
252
|
+
void grant(
|
|
253
|
+
plugin,
|
|
254
|
+
event.target.checked
|
|
255
|
+
? [...plugin.grants.capabilities, capability]
|
|
256
|
+
: plugin.grants.capabilities.filter((entry) => entry !== capability),
|
|
257
|
+
plugin.grants.domains,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
/>
|
|
261
|
+
<span className="flex-1">{CAPABILITY_LABELS[capability]}</span>
|
|
262
|
+
<code className="font-mono text-[9.5px] text-faint">{capability}</code>
|
|
263
|
+
</label>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div>
|
|
269
|
+
<p className="text-[10.5px] font-medium text-ink">Network domains</p>
|
|
270
|
+
{plugin.manifest.domains.length === 0 ? (
|
|
271
|
+
<p className="mt-1 text-[10px] text-faint">This plugin declares no network access.</p>
|
|
272
|
+
) : (
|
|
273
|
+
<div className="mt-1.5 space-y-1">
|
|
274
|
+
{plugin.manifest.domains.map((domain) => (
|
|
275
|
+
<label key={domain} className="flex items-center gap-2 text-[10.5px] text-muted">
|
|
276
|
+
<input
|
|
277
|
+
type="checkbox"
|
|
278
|
+
className="accent-[rgb(var(--accent))]"
|
|
279
|
+
checked={plugin.grants.domains.includes(domain)}
|
|
280
|
+
onChange={(event) =>
|
|
281
|
+
void grant(
|
|
282
|
+
plugin,
|
|
283
|
+
plugin.grants.capabilities,
|
|
284
|
+
event.target.checked
|
|
285
|
+
? [...plugin.grants.domains, domain]
|
|
286
|
+
: plugin.grants.domains.filter((entry) => entry !== domain),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
/>
|
|
290
|
+
<code className="flex-1 font-mono text-[10px]">{domain}</code>
|
|
291
|
+
</label>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
|
298
|
+
<PluginPreview plugin={plugin} />
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={() => void trust(plugin)}
|
|
302
|
+
className={cn(
|
|
303
|
+
'inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-[10.5px] font-medium',
|
|
304
|
+
plugin.safe
|
|
305
|
+
? 'border-line bg-panel text-muted hover:text-ink'
|
|
306
|
+
: 'border-warning/40 bg-warning/10 text-ink',
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
309
|
+
{plugin.safe ? 'Return to sandbox' : 'Mark as Safe'}
|
|
310
|
+
</button>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
onClick={async () => {
|
|
314
|
+
const ok = await askConfirm({
|
|
315
|
+
title: `Uninstall ${plugin.manifest.name}?`,
|
|
316
|
+
body: 'Its permissions, styles and commands are removed from OpenSol.',
|
|
317
|
+
confirmLabel: 'Uninstall',
|
|
318
|
+
tone: 'danger',
|
|
319
|
+
})
|
|
320
|
+
if (!ok) return
|
|
321
|
+
await removeInstalledPlugin(plugin.id)
|
|
322
|
+
setPlugins((current) => current.filter((entry) => entry.id !== plugin.id))
|
|
323
|
+
}}
|
|
324
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line px-2.5 text-[10.5px] font-medium text-muted hover:text-danger"
|
|
325
|
+
>
|
|
326
|
+
<Trash2 size={11} />
|
|
327
|
+
Uninstall
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
})}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
<PluginBuilder onInstalled={(plugin) => setPlugins((current) => [...current.filter((entry) => entry.id !== plugin.id), plugin])} />
|
|
339
|
+
</>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { ExternalLink, Monitor, RefreshCw, Smartphone, Tablet } from 'lucide-react'
|
|
3
|
+
import { getPreviewToken } from '../lib/daemon'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { useWorkspace } from '../store/workspace'
|
|
6
|
+
import { Spinner } from './ui'
|
|
7
|
+
|
|
8
|
+
const DEVICES = [
|
|
9
|
+
{ id: 'desktop', label: 'Desktop', icon: Monitor, width: '100%' },
|
|
10
|
+
{ id: 'tablet', label: 'Tablet', icon: Tablet, width: '768px' },
|
|
11
|
+
{ id: 'phone', label: 'Phone', icon: Smartphone, width: '390px' },
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
type DeviceId = (typeof DEVICES)[number]['id']
|
|
15
|
+
|
|
16
|
+
function pickEntry(pages: string[]): string {
|
|
17
|
+
if (pages.includes('index.html')) return 'index.html'
|
|
18
|
+
return [...pages].sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b))[0] ?? ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Live, sandboxed preview of an HTML file in the open workspace. Reloads when files change. */
|
|
22
|
+
export function PreviewPane() {
|
|
23
|
+
const entries = useWorkspace((state) => state.entries)
|
|
24
|
+
const [token, setToken] = useState('')
|
|
25
|
+
const [error, setError] = useState('')
|
|
26
|
+
const [selected, setSelected] = useState('')
|
|
27
|
+
const [device, setDevice] = useState<DeviceId>('desktop')
|
|
28
|
+
const [nonce, setNonce] = useState(0)
|
|
29
|
+
|
|
30
|
+
const pages = useMemo(
|
|
31
|
+
() => entries.filter((entry) => entry.type === 'file' && /\.html?$/i.test(entry.path)).map((entry) => entry.path),
|
|
32
|
+
[entries],
|
|
33
|
+
)
|
|
34
|
+
// Any file change reloads the page, so edits from the agent or the editor show up on their own.
|
|
35
|
+
const version = useMemo(() => {
|
|
36
|
+
const files = entries.filter((entry) => entry.type === 'file')
|
|
37
|
+
return `${files.length}-${files.reduce((latest, entry) => Math.max(latest, entry.mtime), 0)}`
|
|
38
|
+
}, [entries])
|
|
39
|
+
|
|
40
|
+
const page = pages.includes(selected) ? selected : pickEntry(pages)
|
|
41
|
+
|
|
42
|
+
const fetchToken = useCallback(async () => {
|
|
43
|
+
setError('')
|
|
44
|
+
try {
|
|
45
|
+
setToken((await getPreviewToken()).token)
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
setToken('')
|
|
48
|
+
setError(cause instanceof Error ? cause.message : 'Could not start the preview.')
|
|
49
|
+
}
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
void fetchToken()
|
|
54
|
+
}, [fetchToken])
|
|
55
|
+
|
|
56
|
+
const src = token && page ? `/preview/${token}/${encodeURI(page)}?v=${version}.${nonce}` : ''
|
|
57
|
+
const width = DEVICES.find((entry) => entry.id === device)?.width ?? '100%'
|
|
58
|
+
|
|
59
|
+
if (pages.length === 0) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-8 text-center">
|
|
62
|
+
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-raised text-faint">
|
|
63
|
+
<Monitor size={18} />
|
|
64
|
+
</span>
|
|
65
|
+
<p className="text-[13px] font-medium text-ink">Nothing to preview yet</p>
|
|
66
|
+
<p className="max-w-[260px] text-[11.5px] leading-relaxed text-muted">
|
|
67
|
+
Ask OpenSol to build a page or a game. The first HTML file it writes shows up here and reloads as it changes.
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
75
|
+
<div className="flex shrink-0 items-center gap-1.5 border-b border-line px-2.5 py-2">
|
|
76
|
+
<select
|
|
77
|
+
value={page}
|
|
78
|
+
onChange={(event) => setSelected(event.target.value)}
|
|
79
|
+
className="h-8 min-w-0 flex-1 truncate rounded-lg border border-line bg-transparent px-2 font-mono text-[11.5px] text-ink outline-none focus:border-accent/50"
|
|
80
|
+
aria-label="Page to preview"
|
|
81
|
+
>
|
|
82
|
+
{pages.map((entry) => (
|
|
83
|
+
<option key={entry} value={entry}>
|
|
84
|
+
{entry}
|
|
85
|
+
</option>
|
|
86
|
+
))}
|
|
87
|
+
</select>
|
|
88
|
+
<div className="flex items-center rounded-lg bg-raised/70 p-0.5">
|
|
89
|
+
{DEVICES.map((entry) => (
|
|
90
|
+
<button
|
|
91
|
+
key={entry.id}
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => setDevice(entry.id)}
|
|
94
|
+
aria-pressed={device === entry.id}
|
|
95
|
+
title={entry.label}
|
|
96
|
+
aria-label={entry.label}
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
|
|
99
|
+
device === entry.id ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<entry.icon size={14} strokeWidth={1.7} />
|
|
103
|
+
</button>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className="icon-button h-8 w-8"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
void fetchToken()
|
|
111
|
+
setNonce((value) => value + 1)
|
|
112
|
+
}}
|
|
113
|
+
title="Reload preview"
|
|
114
|
+
aria-label="Reload preview"
|
|
115
|
+
>
|
|
116
|
+
<RefreshCw size={14} strokeWidth={1.7} />
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className="icon-button h-8 w-8"
|
|
121
|
+
disabled={!src}
|
|
122
|
+
onClick={() => window.open(src, '_blank', 'noopener')}
|
|
123
|
+
title="Open in a new tab"
|
|
124
|
+
aria-label="Open in a new tab"
|
|
125
|
+
>
|
|
126
|
+
<ExternalLink size={14} strokeWidth={1.7} />
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="relative min-h-0 flex-1 overflow-auto bg-raised/40 p-2">
|
|
131
|
+
{error && (
|
|
132
|
+
<div className="mx-auto mt-6 max-w-[300px] rounded-xl border border-danger/25 bg-danger/[0.05] px-3.5 py-3 text-center">
|
|
133
|
+
<p className="text-[12px] font-medium text-ink">The preview could not start</p>
|
|
134
|
+
<p className="mt-1 text-[11.5px] leading-relaxed text-muted">{error}</p>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => void fetchToken()}
|
|
138
|
+
className="mt-2 text-[11.5px] font-medium text-accent hover:underline"
|
|
139
|
+
>
|
|
140
|
+
Try again
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
{!error && !src && (
|
|
145
|
+
<p className="flex items-center justify-center gap-2 py-8 text-[11.5px] text-faint">
|
|
146
|
+
<Spinner size={12} /> Starting preview…
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
{src && (
|
|
150
|
+
<iframe
|
|
151
|
+
key={`${page}-${nonce}`}
|
|
152
|
+
title={`Preview of ${page}`}
|
|
153
|
+
src={src}
|
|
154
|
+
sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"
|
|
155
|
+
className="mx-auto block h-full min-h-[320px] max-w-full rounded-lg border border-line bg-white shadow-pill"
|
|
156
|
+
style={{ width }}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<p className="shrink-0 border-t border-line px-3 py-1.5 text-[11px] text-faint">
|
|
162
|
+
Runs in a sandbox: it can load your files and public CDNs, but cannot reach OpenSol or your keys.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|