@bibobonking/opensol 0.0.0-stage → 3.0.2
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/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -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,710 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ArrowLeft,
|
|
4
|
+
Check,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
Copy,
|
|
7
|
+
FilePlus2,
|
|
8
|
+
FileText,
|
|
9
|
+
Folder,
|
|
10
|
+
FolderOpen,
|
|
11
|
+
FolderPlus,
|
|
12
|
+
GitBranch,
|
|
13
|
+
HardDrive,
|
|
14
|
+
LoaderCircle,
|
|
15
|
+
Pencil,
|
|
16
|
+
Plus,
|
|
17
|
+
RefreshCw,
|
|
18
|
+
Save,
|
|
19
|
+
Search,
|
|
20
|
+
Trash2,
|
|
21
|
+
X,
|
|
22
|
+
} from 'lucide-react'
|
|
23
|
+
import type { WorkspaceEntry } from '../types'
|
|
24
|
+
import { browseFolders } from '../lib/daemon'
|
|
25
|
+
import { cn, formatBytes } from '../lib/utils'
|
|
26
|
+
import { useUi } from '../store/ui'
|
|
27
|
+
import { useWorkspace } from '../store/workspace'
|
|
28
|
+
import { PreviewPane } from './PreviewPane'
|
|
29
|
+
import { Spinner } from './ui'
|
|
30
|
+
|
|
31
|
+
interface TreeNode {
|
|
32
|
+
entry: WorkspaceEntry
|
|
33
|
+
children: TreeNode[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildTree(entries: WorkspaceEntry[]): TreeNode[] {
|
|
37
|
+
const nodes = new Map<string, TreeNode>()
|
|
38
|
+
for (const entry of entries) nodes.set(entry.path, { entry, children: [] })
|
|
39
|
+
const roots: TreeNode[] = []
|
|
40
|
+
const sorted = [...entries].sort((a, b) => a.path.split('/').length - b.path.split('/').length)
|
|
41
|
+
for (const entry of sorted) {
|
|
42
|
+
const node = nodes.get(entry.path)
|
|
43
|
+
if (!node) continue
|
|
44
|
+
const slash = entry.path.lastIndexOf('/')
|
|
45
|
+
const parent = slash === -1 ? null : nodes.get(entry.path.slice(0, slash))
|
|
46
|
+
if (parent) parent.children.push(node)
|
|
47
|
+
else roots.push(node)
|
|
48
|
+
}
|
|
49
|
+
const sort = (items: TreeNode[]) => {
|
|
50
|
+
items.sort((a, b) =>
|
|
51
|
+
a.entry.type === b.entry.type
|
|
52
|
+
? a.entry.name.localeCompare(b.entry.name)
|
|
53
|
+
: a.entry.type === 'dir'
|
|
54
|
+
? -1
|
|
55
|
+
: 1,
|
|
56
|
+
)
|
|
57
|
+
items.forEach((item) => sort(item.children))
|
|
58
|
+
}
|
|
59
|
+
sort(roots)
|
|
60
|
+
return roots
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extension(path: string) {
|
|
64
|
+
const match = /\.([a-z0-9]+)$/i.exec(path)
|
|
65
|
+
return match ? match[1].toUpperCase() : 'TEXT'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function FolderPicker({ onDone }: { onDone?: () => void }) {
|
|
69
|
+
const addWorkspace = useWorkspace((state) => state.addWorkspace)
|
|
70
|
+
const workspaces = useWorkspace((state) => state.info.workspaces)
|
|
71
|
+
const notify = useUi((state) => state.notify)
|
|
72
|
+
const [path, setPath] = useState('')
|
|
73
|
+
const [parent, setParent] = useState('')
|
|
74
|
+
const [home, setHome] = useState('')
|
|
75
|
+
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
|
|
76
|
+
const [loading, setLoading] = useState(true)
|
|
77
|
+
const [error, setError] = useState('')
|
|
78
|
+
const [opening, setOpening] = useState(false)
|
|
79
|
+
|
|
80
|
+
const load = async (target: string) => {
|
|
81
|
+
setLoading(true)
|
|
82
|
+
setError('')
|
|
83
|
+
try {
|
|
84
|
+
const result = await browseFolders(target)
|
|
85
|
+
setPath(result.path)
|
|
86
|
+
setParent(result.parent)
|
|
87
|
+
setHome(result.home)
|
|
88
|
+
setEntries(result.entries)
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
setError(cause instanceof Error ? cause.message : 'Could not read that folder.')
|
|
91
|
+
} finally {
|
|
92
|
+
setLoading(false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
void load('')
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
const open = async (target: string) => {
|
|
101
|
+
setOpening(true)
|
|
102
|
+
try {
|
|
103
|
+
await addWorkspace(target)
|
|
104
|
+
onDone?.()
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
notify(cause instanceof Error ? cause.message : 'Could not open that folder.', 'error')
|
|
107
|
+
} finally {
|
|
108
|
+
setOpening(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
114
|
+
<div className="border-b border-line px-4 py-4">
|
|
115
|
+
<p className="font-display text-[19px] font-semibold tracking-[-0.02em] text-ink">Choose a real folder</p>
|
|
116
|
+
<p className="mt-1 text-[11.5px] leading-relaxed text-muted">
|
|
117
|
+
OpenSol works directly in this folder through the local daemon. Nothing is uploaded.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="flex items-center gap-1.5 border-b border-line px-3 py-2">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="icon-button h-7 w-7"
|
|
125
|
+
onClick={() => void load(parent)}
|
|
126
|
+
disabled={!parent || loading}
|
|
127
|
+
aria-label="Parent folder"
|
|
128
|
+
>
|
|
129
|
+
<ArrowLeft size={13} />
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="icon-button h-7 w-7"
|
|
134
|
+
onClick={() => void load(home)}
|
|
135
|
+
disabled={!home || loading}
|
|
136
|
+
aria-label="Home folder"
|
|
137
|
+
>
|
|
138
|
+
<HardDrive size={13} />
|
|
139
|
+
</button>
|
|
140
|
+
<p className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted" title={path}>
|
|
141
|
+
{path || 'Home'}
|
|
142
|
+
</p>
|
|
143
|
+
{loading && <Spinner size={12} />}
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => void open(path)}
|
|
147
|
+
disabled={!path || opening}
|
|
148
|
+
className="inline-flex h-7 items-center gap-1 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel disabled:opacity-40"
|
|
149
|
+
>
|
|
150
|
+
{opening ? <Spinner size={11} /> : <Check size={11} />}
|
|
151
|
+
Open
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{workspaces.length > 0 && (
|
|
156
|
+
<div className="border-b border-line px-3 py-2">
|
|
157
|
+
<p className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.13em] text-faint">Recent</p>
|
|
158
|
+
<div className="flex flex-wrap gap-1">
|
|
159
|
+
{workspaces.slice(0, 6).map((workspace) => (
|
|
160
|
+
<button
|
|
161
|
+
key={workspace.path}
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => void open(workspace.path)}
|
|
164
|
+
className="max-w-full truncate rounded-md border border-line bg-raised/60 px-2 py-1 text-left text-[10px] text-muted hover:text-ink"
|
|
165
|
+
title={workspace.path}
|
|
166
|
+
>
|
|
167
|
+
{workspace.name}
|
|
168
|
+
</button>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
<div className="thin-scroll min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
|
175
|
+
{error && <p className="px-2 py-3 text-[11px] text-danger">{error}</p>}
|
|
176
|
+
{!loading && !error && entries.length === 0 && (
|
|
177
|
+
<p className="px-2 py-4 text-[11px] text-faint">This folder has no subfolders.</p>
|
|
178
|
+
)}
|
|
179
|
+
{entries.map((entry) => (
|
|
180
|
+
<div key={entry.path} className="group flex items-center gap-1">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={() => void load(entry.path)}
|
|
184
|
+
className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted transition-colors hover:bg-raised hover:text-ink"
|
|
185
|
+
>
|
|
186
|
+
<Folder size={13} className="shrink-0 text-faint" />
|
|
187
|
+
<span className="truncate">{entry.name}</span>
|
|
188
|
+
</button>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
onClick={() => void open(entry.path)}
|
|
192
|
+
className="hidden h-7 rounded-md px-2 text-[10px] font-medium text-accent group-hover:block"
|
|
193
|
+
>
|
|
194
|
+
Use
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function WorkspacePanel() {
|
|
204
|
+
const open = useUi((state) => state.workspaceOpen)
|
|
205
|
+
const setOpen = useUi((state) => state.setWorkspaceOpen)
|
|
206
|
+
const notify = useUi((state) => state.notify)
|
|
207
|
+
const askConfirm = useUi((state) => state.askConfirm)
|
|
208
|
+
const info = useWorkspace((state) => state.info)
|
|
209
|
+
const root = useWorkspace((state) => state.root)
|
|
210
|
+
const entries = useWorkspace((state) => state.entries)
|
|
211
|
+
const loadingTree = useWorkspace((state) => state.loadingTree)
|
|
212
|
+
const treeError = useWorkspace((state) => state.treeError)
|
|
213
|
+
const activeFile = useWorkspace((state) => state.activeFile)
|
|
214
|
+
const file = useWorkspace((state) => state.file)
|
|
215
|
+
const content = useWorkspace((state) => state.content)
|
|
216
|
+
const dirty = useWorkspace((state) => state.dirty)
|
|
217
|
+
const saving = useWorkspace((state) => state.saving)
|
|
218
|
+
const openingFile = useWorkspace((state) => state.openingFile)
|
|
219
|
+
const git = useWorkspace((state) => state.git)
|
|
220
|
+
const gitLoading = useWorkspace((state) => state.gitLoading)
|
|
221
|
+
const setActiveFile = useWorkspace((state) => state.setActiveFile)
|
|
222
|
+
const saveFile = useWorkspace((state) => state.saveFile)
|
|
223
|
+
const setContent = useWorkspace((state) => state.setContent)
|
|
224
|
+
const createEntry = useWorkspace((state) => state.createEntry)
|
|
225
|
+
const renameEntry = useWorkspace((state) => state.renameEntry)
|
|
226
|
+
const deleteEntry = useWorkspace((state) => state.deleteEntry)
|
|
227
|
+
const forgetWorkspace = useWorkspace((state) => state.forgetWorkspace)
|
|
228
|
+
const loadGit = useWorkspace((state) => state.loadGit)
|
|
229
|
+
const refresh = useWorkspace((state) => state.refresh)
|
|
230
|
+
|
|
231
|
+
const [query, setQuery] = useState('')
|
|
232
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
|
233
|
+
const [tab, setTab] = useState<'files' | 'preview' | 'git'>('files')
|
|
234
|
+
const [choosing, setChoosing] = useState(!info.available)
|
|
235
|
+
const [editor, setEditor] = useState<{ mode: 'create-file' | 'create-dir' | 'rename'; value: string } | null>(
|
|
236
|
+
null,
|
|
237
|
+
)
|
|
238
|
+
const [copied, setCopied] = useState(false)
|
|
239
|
+
const editorRef = useRef<HTMLInputElement>(null)
|
|
240
|
+
|
|
241
|
+
const tree = useMemo(() => buildTree(entries), [entries])
|
|
242
|
+
const filtered = useMemo(() => {
|
|
243
|
+
const clean = query.trim().toLowerCase()
|
|
244
|
+
if (!clean) return null
|
|
245
|
+
return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
|
|
246
|
+
}, [entries, query])
|
|
247
|
+
const lineCount = content ? content.split('\n').length : 0
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (editor) editorRef.current?.focus()
|
|
251
|
+
}, [editor])
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
setChoosing(!info.available)
|
|
255
|
+
}, [info.available])
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const onKey = (event: KeyboardEvent) => {
|
|
259
|
+
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return
|
|
260
|
+
if (!useUi.getState().workspaceOpen || !useWorkspace.getState().activeFile) return
|
|
261
|
+
event.preventDefault()
|
|
262
|
+
void useWorkspace.getState().saveFile()
|
|
263
|
+
}
|
|
264
|
+
window.addEventListener('keydown', onKey)
|
|
265
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
266
|
+
}, [])
|
|
267
|
+
|
|
268
|
+
if (!open) return null
|
|
269
|
+
|
|
270
|
+
const submitEditor = async () => {
|
|
271
|
+
if (!editor) return
|
|
272
|
+
const value = editor.value.trim().replace(/^\/+/, '')
|
|
273
|
+
if (!value) return setEditor(null)
|
|
274
|
+
try {
|
|
275
|
+
if (editor.mode === 'rename') {
|
|
276
|
+
if (!activeFile) return
|
|
277
|
+
await renameEntry(activeFile, value)
|
|
278
|
+
} else {
|
|
279
|
+
await createEntry(value, editor.mode === 'create-dir' ? 'dir' : 'file')
|
|
280
|
+
}
|
|
281
|
+
setEditor(null)
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
notify(cause instanceof Error ? cause.message : 'That change failed.', 'error')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const removeEntry = async (path: string) => {
|
|
288
|
+
const confirmed = await askConfirm({
|
|
289
|
+
title: 'Delete this path?',
|
|
290
|
+
body: `${path} will be deleted from disk. This cannot be undone.`,
|
|
291
|
+
confirmLabel: 'Delete',
|
|
292
|
+
tone: 'danger',
|
|
293
|
+
})
|
|
294
|
+
if (!confirmed) return
|
|
295
|
+
try {
|
|
296
|
+
await deleteEntry(path)
|
|
297
|
+
} catch (cause) {
|
|
298
|
+
notify(cause instanceof Error ? cause.message : 'Could not delete that path.', 'error')
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const copyFile = async () => {
|
|
303
|
+
try {
|
|
304
|
+
await navigator.clipboard.writeText(content)
|
|
305
|
+
setCopied(true)
|
|
306
|
+
window.setTimeout(() => setCopied(false), 1500)
|
|
307
|
+
} catch {
|
|
308
|
+
notify('Clipboard access was blocked.', 'error')
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const renderNode = (node: TreeNode, depth: number): ReactNode => {
|
|
313
|
+
const path = node.entry.path
|
|
314
|
+
if (node.entry.type === 'dir') {
|
|
315
|
+
const closed = collapsed.has(path)
|
|
316
|
+
return (
|
|
317
|
+
<div key={path}>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() =>
|
|
321
|
+
setCollapsed((current) => {
|
|
322
|
+
const next = new Set(current)
|
|
323
|
+
if (next.has(path)) next.delete(path)
|
|
324
|
+
else next.add(path)
|
|
325
|
+
return next
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
className="group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[11.5px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
|
|
329
|
+
style={{ paddingLeft: 8 + depth * 13 }}
|
|
330
|
+
title={path}
|
|
331
|
+
>
|
|
332
|
+
<ChevronRight size={10} className={cn('shrink-0 text-faint transition-transform', !closed && 'rotate-90')} />
|
|
333
|
+
{closed ? (
|
|
334
|
+
<Folder size={12} className="shrink-0 text-faint" />
|
|
335
|
+
) : (
|
|
336
|
+
<FolderOpen size={12} className="shrink-0 text-faint" />
|
|
337
|
+
)}
|
|
338
|
+
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
339
|
+
<span
|
|
340
|
+
role="button"
|
|
341
|
+
tabIndex={0}
|
|
342
|
+
onClick={(event) => {
|
|
343
|
+
event.stopPropagation()
|
|
344
|
+
void removeEntry(path)
|
|
345
|
+
}}
|
|
346
|
+
onKeyDown={(event) => {
|
|
347
|
+
if (event.key === 'Enter') void removeEntry(path)
|
|
348
|
+
}}
|
|
349
|
+
className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
|
|
350
|
+
aria-label={`Delete ${path}`}
|
|
351
|
+
>
|
|
352
|
+
<Trash2 size={10} />
|
|
353
|
+
</span>
|
|
354
|
+
</button>
|
|
355
|
+
{!closed && node.children.map((child) => renderNode(child, depth + 1))}
|
|
356
|
+
</div>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
<button
|
|
361
|
+
key={path}
|
|
362
|
+
type="button"
|
|
363
|
+
onClick={() => setActiveFile(path)}
|
|
364
|
+
className={cn(
|
|
365
|
+
'flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
|
|
366
|
+
activeFile === path ? 'bg-accent-soft font-medium text-accent' : 'text-muted hover:bg-raised hover:text-ink',
|
|
367
|
+
)}
|
|
368
|
+
style={{ paddingLeft: 8 + depth * 13 + 10 }}
|
|
369
|
+
title={path}
|
|
370
|
+
>
|
|
371
|
+
<FileText size={11} className={cn('shrink-0', activeFile === path ? 'text-accent' : 'text-faint')} />
|
|
372
|
+
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
373
|
+
</button>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<>
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
aria-label="Close workspace"
|
|
382
|
+
onClick={() => setOpen(false)}
|
|
383
|
+
className="fixed inset-0 z-40 cursor-default bg-ink/20 backdrop-blur-[1px] xl:hidden"
|
|
384
|
+
/>
|
|
385
|
+
<aside className={cn(
|
|
386
|
+
'fixed inset-y-0 right-0 z-50 flex h-dvh w-[min(96vw,440px)] flex-col border-l border-line surface-lift shadow-float transition-[width] duration-200 xl:static xl:z-auto xl:shadow-none',
|
|
387
|
+
tab === 'preview' && info.available && !choosing ? 'sm:w-[min(96vw,620px)] xl:w-[560px]' : 'xl:w-[390px]',
|
|
388
|
+
)}
|
|
389
|
+
>
|
|
390
|
+
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-3">
|
|
391
|
+
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent">
|
|
392
|
+
<FolderOpen size={14} />
|
|
393
|
+
</span>
|
|
394
|
+
<div className="min-w-0 flex-1">
|
|
395
|
+
<p className="truncate text-[12px] font-semibold tracking-[-0.01em] text-ink">
|
|
396
|
+
{info.available ? info.name : 'Workspace'}
|
|
397
|
+
</p>
|
|
398
|
+
<p className="truncate text-[9px] text-faint" title={root}>
|
|
399
|
+
{info.available ? root : 'No folder open'}
|
|
400
|
+
</p>
|
|
401
|
+
</div>
|
|
402
|
+
{info.available && (
|
|
403
|
+
<>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
className="icon-button h-7 w-7"
|
|
407
|
+
onClick={() => setChoosing(true)}
|
|
408
|
+
title="Change folder"
|
|
409
|
+
aria-label="Change folder"
|
|
410
|
+
>
|
|
411
|
+
<Folder size={14} />
|
|
412
|
+
</button>
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
className="icon-button h-7 w-7"
|
|
416
|
+
onClick={() => void refresh().catch(() => undefined)}
|
|
417
|
+
title="Refresh tree"
|
|
418
|
+
aria-label="Refresh tree"
|
|
419
|
+
>
|
|
420
|
+
<RefreshCw size={13} className={cn(loadingTree && 'animate-spin')} />
|
|
421
|
+
</button>
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
className="icon-button h-7 w-7"
|
|
427
|
+
onClick={() => setOpen(false)}
|
|
428
|
+
title="Close workspace"
|
|
429
|
+
aria-label="Close workspace"
|
|
430
|
+
>
|
|
431
|
+
<X size={15} />
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{choosing || !info.available ? (
|
|
436
|
+
<FolderPicker onDone={() => setChoosing(false)} />
|
|
437
|
+
) : (
|
|
438
|
+
<>
|
|
439
|
+
<div className="flex shrink-0 items-center gap-1 border-b border-line px-2 py-1.5">
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
onClick={() => setTab('files')}
|
|
443
|
+
className={cn(
|
|
444
|
+
'h-7 rounded-md px-2.5 text-[11px] font-medium',
|
|
445
|
+
tab === 'files' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
|
|
446
|
+
)}
|
|
447
|
+
>
|
|
448
|
+
Files
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={() => setTab('preview')}
|
|
453
|
+
className={cn(
|
|
454
|
+
'h-7 rounded-md px-2.5 text-[11px] font-medium',
|
|
455
|
+
tab === 'preview' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
|
|
456
|
+
)}
|
|
457
|
+
>
|
|
458
|
+
Preview
|
|
459
|
+
</button>
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
onClick={() => setTab('git')}
|
|
463
|
+
className={cn(
|
|
464
|
+
'h-7 rounded-md px-2.5 text-[11px] font-medium',
|
|
465
|
+
tab === 'git' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
|
|
466
|
+
)}
|
|
467
|
+
>
|
|
468
|
+
Git {git.entries.length > 0 && <span className="text-warning">· {git.entries.length}</span>}
|
|
469
|
+
</button>
|
|
470
|
+
<div className="ml-auto flex items-center gap-0.5">
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
className="icon-button h-7 w-7"
|
|
474
|
+
onClick={() => setEditor({ mode: 'create-file', value: '' })}
|
|
475
|
+
title="New file"
|
|
476
|
+
aria-label="New file"
|
|
477
|
+
>
|
|
478
|
+
<FilePlus2 size={13} />
|
|
479
|
+
</button>
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
className="icon-button h-7 w-7"
|
|
483
|
+
onClick={() => setEditor({ mode: 'create-dir', value: '' })}
|
|
484
|
+
title="New folder"
|
|
485
|
+
aria-label="New folder"
|
|
486
|
+
>
|
|
487
|
+
<FolderPlus size={13} />
|
|
488
|
+
</button>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
{tab === 'files' ? (
|
|
493
|
+
<>
|
|
494
|
+
<div className="shrink-0 border-b border-line px-2.5 py-2">
|
|
495
|
+
<div className="relative">
|
|
496
|
+
<Search size={11} className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-faint" />
|
|
497
|
+
<input
|
|
498
|
+
value={query}
|
|
499
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
500
|
+
placeholder="Find a file"
|
|
501
|
+
className="h-7 w-full rounded-md border border-transparent bg-raised/60 pl-7 pr-2 text-[10.5px] text-ink outline-none transition-colors placeholder:text-faint focus:border-line focus:bg-panel"
|
|
502
|
+
aria-label="Find a file"
|
|
503
|
+
/>
|
|
504
|
+
</div>
|
|
505
|
+
{editor && (
|
|
506
|
+
<div className="mt-1.5 flex items-center gap-1">
|
|
507
|
+
<input
|
|
508
|
+
ref={editorRef}
|
|
509
|
+
value={editor.value}
|
|
510
|
+
onChange={(event) => setEditor({ ...editor, value: event.target.value })}
|
|
511
|
+
onKeyDown={(event) => {
|
|
512
|
+
if (event.key === 'Enter') void submitEditor()
|
|
513
|
+
if (event.key === 'Escape') setEditor(null)
|
|
514
|
+
}}
|
|
515
|
+
placeholder={editor.mode === 'rename' ? activeFile ?? 'new/path' : 'folder/file.ts'}
|
|
516
|
+
className="h-7 min-w-0 flex-1 rounded-md border border-accent/40 bg-panel px-2 font-mono text-[10.5px] outline-none"
|
|
517
|
+
/>
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={() => void submitEditor()}
|
|
521
|
+
className="flex h-7 items-center rounded-md bg-ink px-2 text-[10px] font-medium text-panel"
|
|
522
|
+
>
|
|
523
|
+
<Plus size={10} />
|
|
524
|
+
</button>
|
|
525
|
+
<button type="button" onClick={() => setEditor(null)} className="icon-button h-7 w-7">
|
|
526
|
+
<X size={12} />
|
|
527
|
+
</button>
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<div className="thin-scroll max-h-[32dvh] min-h-[92px] shrink-0 overflow-y-auto px-1.5 py-1.5 xl:max-h-[38%]">
|
|
533
|
+
{treeError && <p className="px-2 py-2 text-[10.5px] text-danger">{treeError}</p>}
|
|
534
|
+
{loadingTree && entries.length === 0 && (
|
|
535
|
+
<p className="flex items-center gap-1.5 px-2 py-3 text-[10.5px] text-faint">
|
|
536
|
+
<Spinner size={11} /> Reading folder…
|
|
537
|
+
</p>
|
|
538
|
+
)}
|
|
539
|
+
{filtered
|
|
540
|
+
? filtered.map((entry) => (
|
|
541
|
+
<button
|
|
542
|
+
key={entry.path}
|
|
543
|
+
type="button"
|
|
544
|
+
onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
|
|
545
|
+
className={cn(
|
|
546
|
+
'flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left font-mono text-[10.5px]',
|
|
547
|
+
activeFile === entry.path
|
|
548
|
+
? 'bg-accent-soft font-medium text-accent'
|
|
549
|
+
: 'text-muted hover:bg-raised hover:text-ink',
|
|
550
|
+
)}
|
|
551
|
+
>
|
|
552
|
+
{entry.type === 'dir' ? <Folder size={11} /> : <FileText size={11} />}
|
|
553
|
+
<span className="truncate">{entry.path}</span>
|
|
554
|
+
</button>
|
|
555
|
+
))
|
|
556
|
+
: tree.map((node) => renderNode(node, 0))}
|
|
557
|
+
{!loadingTree && !treeError && entries.length === 0 && (
|
|
558
|
+
<p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
<div className="flex min-h-0 flex-1 flex-col border-t border-line">
|
|
563
|
+
{activeFile && file ? (
|
|
564
|
+
<>
|
|
565
|
+
<div className="flex shrink-0 items-center gap-1.5 border-b border-line px-3 py-2">
|
|
566
|
+
<div className="min-w-0 flex-1">
|
|
567
|
+
<p className="truncate font-mono text-[10.5px] font-medium text-ink">{activeFile}</p>
|
|
568
|
+
<p className="mt-0.5 text-[9px] text-faint">
|
|
569
|
+
{extension(activeFile)} · {lineCount} lines · {formatBytes(file.size)}
|
|
570
|
+
{dirty ? ' · unsaved' : ' · saved'}
|
|
571
|
+
</p>
|
|
572
|
+
</div>
|
|
573
|
+
{openingFile && <Spinner size={11} />}
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
className="icon-button h-7 w-7"
|
|
577
|
+
onClick={() => setEditor({ mode: 'rename', value: activeFile })}
|
|
578
|
+
title="Rename"
|
|
579
|
+
aria-label="Rename"
|
|
580
|
+
>
|
|
581
|
+
<Pencil size={12} />
|
|
582
|
+
</button>
|
|
583
|
+
<button type="button" className="icon-button h-7 w-7" onClick={copyFile} title="Copy" aria-label="Copy">
|
|
584
|
+
{copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
|
|
585
|
+
</button>
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
className="icon-button h-7 w-7 hover:text-danger"
|
|
589
|
+
onClick={() => void removeEntry(activeFile)}
|
|
590
|
+
title="Delete"
|
|
591
|
+
aria-label="Delete"
|
|
592
|
+
>
|
|
593
|
+
<Trash2 size={12} />
|
|
594
|
+
</button>
|
|
595
|
+
<button
|
|
596
|
+
type="button"
|
|
597
|
+
onClick={() => void saveFile()}
|
|
598
|
+
disabled={!dirty || saving}
|
|
599
|
+
className="inline-flex h-7 items-center gap-1 rounded-md bg-ink px-2 text-[10px] font-medium text-panel disabled:opacity-35"
|
|
600
|
+
>
|
|
601
|
+
{saving ? <Spinner size={10} /> : <Save size={10} />}
|
|
602
|
+
Save
|
|
603
|
+
</button>
|
|
604
|
+
</div>
|
|
605
|
+
{file.binary ? (
|
|
606
|
+
<div className="flex flex-1 items-center justify-center px-6 text-center text-[11px] text-faint">
|
|
607
|
+
This file is binary and cannot be edited here.
|
|
608
|
+
</div>
|
|
609
|
+
) : (
|
|
610
|
+
<textarea
|
|
611
|
+
value={content}
|
|
612
|
+
onChange={(event) => setContent(event.target.value)}
|
|
613
|
+
spellCheck={false}
|
|
614
|
+
wrap="off"
|
|
615
|
+
aria-label={`Edit ${activeFile}`}
|
|
616
|
+
className="thin-scroll min-h-0 flex-1 resize-none overflow-auto bg-panel p-3.5 font-mono text-[11.5px] leading-[1.75] text-ink outline-none"
|
|
617
|
+
/>
|
|
618
|
+
)}
|
|
619
|
+
<div className="flex h-6 shrink-0 items-center justify-between border-t border-line px-3 text-[8.5px] text-faint">
|
|
620
|
+
<span className="flex items-center gap-1">
|
|
621
|
+
<span className={cn('h-1 w-1 rounded-full', dirty ? 'bg-warning' : 'bg-success')} />
|
|
622
|
+
{dirty ? 'Unsaved changes' : 'Saved to disk'}
|
|
623
|
+
</span>
|
|
624
|
+
<span>⌘S to save · UTF-8</span>
|
|
625
|
+
</div>
|
|
626
|
+
</>
|
|
627
|
+
) : (
|
|
628
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
|
629
|
+
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-raised text-faint">
|
|
630
|
+
<FileText size={16} />
|
|
631
|
+
</span>
|
|
632
|
+
<p className="text-[11.5px] font-medium text-ink">No file selected</p>
|
|
633
|
+
<p className="max-w-[230px] text-[10px] leading-relaxed text-faint">
|
|
634
|
+
Pick a file from the tree. The agent reads and writes these same files.
|
|
635
|
+
</p>
|
|
636
|
+
</div>
|
|
637
|
+
)}
|
|
638
|
+
</div>
|
|
639
|
+
</>
|
|
640
|
+
) : tab === 'preview' ? (
|
|
641
|
+
<PreviewPane />
|
|
642
|
+
) : (
|
|
643
|
+
<div className="thin-scroll min-h-0 flex-1 overflow-y-auto">
|
|
644
|
+
<div className="flex items-center gap-2 border-b border-line px-3 py-3">
|
|
645
|
+
<GitBranch size={14} className="text-faint" />
|
|
646
|
+
<div className="min-w-0 flex-1">
|
|
647
|
+
<p className="truncate text-[11.5px] font-medium text-ink">
|
|
648
|
+
{git.available ? git.branch || 'detached HEAD' : 'Not a git repository'}
|
|
649
|
+
</p>
|
|
650
|
+
<p className="text-[9.5px] text-faint">
|
|
651
|
+
{git.available ? `${git.entries.length} changed path${git.entries.length === 1 ? '' : 's'}` : git.error}
|
|
652
|
+
</p>
|
|
653
|
+
</div>
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
className="icon-button h-7 w-7"
|
|
657
|
+
onClick={() => void loadGit()}
|
|
658
|
+
aria-label="Refresh git"
|
|
659
|
+
>
|
|
660
|
+
<RefreshCw size={12} className={cn(gitLoading && 'animate-spin')} />
|
|
661
|
+
</button>
|
|
662
|
+
</div>
|
|
663
|
+
{git.entries.length === 0 ? (
|
|
664
|
+
<p className="px-3 py-5 text-[10.5px] text-faint">The working tree is clean.</p>
|
|
665
|
+
) : (
|
|
666
|
+
git.entries.map((entry) => (
|
|
667
|
+
<button
|
|
668
|
+
key={`${entry.status}-${entry.path}`}
|
|
669
|
+
type="button"
|
|
670
|
+
onClick={() => setActiveFile(entry.path)}
|
|
671
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
|
|
672
|
+
title={entry.path}
|
|
673
|
+
>
|
|
674
|
+
<span className="w-6 shrink-0 text-warning">{entry.status}</span>
|
|
675
|
+
<span className="truncate text-muted">{entry.path}</span>
|
|
676
|
+
</button>
|
|
677
|
+
))
|
|
678
|
+
)}
|
|
679
|
+
<div className="border-t border-line p-3">
|
|
680
|
+
<button
|
|
681
|
+
type="button"
|
|
682
|
+
onClick={async () => {
|
|
683
|
+
const confirmed = await askConfirm({
|
|
684
|
+
title: 'Forget this workspace?',
|
|
685
|
+
body: 'The folder stays on disk. OpenSol stops showing it and closes the current file.',
|
|
686
|
+
confirmLabel: 'Forget workspace',
|
|
687
|
+
})
|
|
688
|
+
if (confirmed) await forgetWorkspace()
|
|
689
|
+
}}
|
|
690
|
+
className="text-[10.5px] font-medium text-faint hover:text-danger"
|
|
691
|
+
>
|
|
692
|
+
Forget this workspace
|
|
693
|
+
</button>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
)}
|
|
697
|
+
</>
|
|
698
|
+
)}
|
|
699
|
+
|
|
700
|
+
{info.available && !choosing && (
|
|
701
|
+
<div className="flex h-8 shrink-0 items-center gap-1.5 border-t border-line px-3 text-[9px] text-faint">
|
|
702
|
+
<LoaderCircle size={10} className={cn(loadingTree && 'animate-spin')} />
|
|
703
|
+
Real folder · daemon-scoped access
|
|
704
|
+
<span className="ml-auto">{entries.length} entries</span>
|
|
705
|
+
</div>
|
|
706
|
+
)}
|
|
707
|
+
</aside>
|
|
708
|
+
</>
|
|
709
|
+
)
|
|
710
|
+
}
|