fs browser: inline editor with Put/Get actions
This commit is contained in:
parent
34443a6cb7
commit
2d378ace4e
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,10 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^9.1.6",
|
||||
"mermaid": "^11.14.0",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"preact": "^10.25.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,87 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { marked } from 'marked'
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import plantumlEncoder from 'plantuml-encoder'
|
||||
import type { IdxEntry } from '../lib/api'
|
||||
import { write9p, browse9p } from '../lib/api'
|
||||
import type { ChatMessage } from '../lib/chat'
|
||||
import { shortId, truncate } from '../lib/chat'
|
||||
import { PromptBar } from './PromptBar'
|
||||
|
||||
marked.use({ gfm: true, breaks: false })
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'dark' })
|
||||
|
||||
function diagramWrap(label: string, rendered: string, source: string): string {
|
||||
return `<div class="diagram-wrap">` +
|
||||
`<button class="diagram-toggle" type="button" data-label="${label}">${label}</button>` +
|
||||
rendered +
|
||||
`<pre class="diagram-source">${escapeHtml(source)}</pre>` +
|
||||
`</div>`
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
code(code: string, lang: string | undefined): string {
|
||||
const l = (lang ?? '').toLowerCase()
|
||||
if (l === 'mermaid') {
|
||||
return diagramWrap('mermaid', `<div class="diagram-mermaid">${escapeHtml(code)}</div>`, code)
|
||||
}
|
||||
if (l === 'math' || l === 'latex' || l === 'tex') {
|
||||
let rendered: string
|
||||
try {
|
||||
rendered = `<div class="diagram-math">${katex.renderToString(code, { displayMode: true, throwOnError: false })}</div>`
|
||||
} catch {
|
||||
rendered = `<pre class="diagram-error">${escapeHtml(code)}</pre>`
|
||||
}
|
||||
return diagramWrap('math', rendered, code)
|
||||
}
|
||||
if (l === 'plantuml') {
|
||||
const encoded = plantumlEncoder.encode(code)
|
||||
return diagramWrap('plantuml',
|
||||
`<div class="diagram-plantuml"><img src="https://www.plantuml.com/plantuml/svg/${encoded}" alt="PlantUML diagram" loading="lazy" /></div>`,
|
||||
code)
|
||||
}
|
||||
return `<pre><code class="language-${escapeHtml(lang ?? '')}">${escapeHtml(code)}</code></pre>`
|
||||
},
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
marked.use({ gfm: true, breaks: false, renderer })
|
||||
|
||||
// Display math: $$...$$ as a block or inline paragraph
|
||||
marked.use({
|
||||
extensions: [
|
||||
{
|
||||
name: 'displayMath',
|
||||
level: 'block',
|
||||
start(src: string) { return src.indexOf('$$') },
|
||||
tokenizer(src: string) {
|
||||
const m = src.match(/^\$\$([\s\S]+?)\$\$/)
|
||||
if (m) return { type: 'displayMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token: any) {
|
||||
const rendered = `<div class="diagram-math">${katex.renderToString(token.text, { displayMode: true, throwOnError: false })}</div>`
|
||||
return diagramWrap('math', rendered, token.text)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inlineMath',
|
||||
level: 'inline',
|
||||
start(src: string) { return src.indexOf('$') },
|
||||
tokenizer(src: string) {
|
||||
const m = src.match(/^\$([^$\n]+?)\$/)
|
||||
if (m) return { type: 'inlineMath', raw: m[0], text: m[1] }
|
||||
},
|
||||
renderer(token: any) {
|
||||
return katex.renderToString(token.text, { displayMode: false, throwOnError: false })
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
interface Props {
|
||||
session: IdxEntry | null
|
||||
|
|
@ -38,12 +114,45 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
}
|
||||
}, [messages.length, isRunning, atBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const nodes = Array.from(listRef.current.querySelectorAll<HTMLElement>('.diagram-mermaid:not([data-processed])'))
|
||||
if (nodes.length === 0) return
|
||||
mermaid.run({ nodes }).catch(() => {})
|
||||
}, [messages.length])
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80)
|
||||
}
|
||||
|
||||
const [modalHtml, setModalHtml] = useState<string | null>(null)
|
||||
|
||||
const handleDiagramClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// Toggle button: switch between rendered and source
|
||||
const btn = target.closest('.diagram-toggle') as HTMLElement | null
|
||||
if (btn) {
|
||||
const wrap = btn.closest('.diagram-wrap') as HTMLElement | null
|
||||
if (!wrap) return
|
||||
const isSource = wrap.classList.toggle('show-source')
|
||||
btn.textContent = isSource ? 'render' : btn.dataset.label ?? ''
|
||||
if (!isSource) {
|
||||
const node = wrap.querySelector<HTMLElement>('.diagram-mermaid:not([data-processed])')
|
||||
if (node) mermaid.run({ nodes: [node] }).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Click on rendered diagram content → open zoom modal
|
||||
const wrap = target.closest('.diagram-wrap') as HTMLElement | null
|
||||
if (!wrap || wrap.classList.contains('show-source')) return
|
||||
const rendered = wrap.querySelector<HTMLElement>('.diagram-mermaid, .diagram-plantuml, .diagram-math')
|
||||
if (rendered) setModalHtml(rendered.outerHTML)
|
||||
}
|
||||
|
||||
if (!session || !cfg) {
|
||||
return (
|
||||
<main class="main">
|
||||
|
|
@ -96,7 +205,9 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div class="message-list" ref={listRef} onScroll={handleScroll}>
|
||||
{modalHtml && <DiagramModal html={modalHtml} onClose={() => setModalHtml(null)} />}
|
||||
|
||||
<div class="message-list" ref={listRef} onScroll={handleScroll} onClick={handleDiagramClick as any}>
|
||||
{messages.length === 0 && !isRunning && (
|
||||
<div class="message-empty">/s/{sid}/chat</div>
|
||||
)}
|
||||
|
|
@ -134,6 +245,84 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
)
|
||||
}
|
||||
|
||||
function DiagramModal({ html, onClose }: { html: string; onClose: () => void }) {
|
||||
// Transform state kept in a ref so wheel handler never captures stale values
|
||||
const xf = useRef({ scale: 1, ox: 0, oy: 0 })
|
||||
const [, rerender] = useState(0)
|
||||
const bump = () => rerender(n => n + 1)
|
||||
|
||||
const dragging = useRef(false)
|
||||
const dragStart = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [onClose])
|
||||
|
||||
// Non-passive wheel for zoom-to-cursor
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current
|
||||
if (!el) return
|
||||
const handler = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const rect = el.getBoundingClientRect()
|
||||
// cursor position relative to canvas center (where content is naturally anchored)
|
||||
const dx = (e.clientX - rect.left) - rect.width / 2
|
||||
const dy = (e.clientY - rect.top) - rect.height / 2
|
||||
const { scale: s, ox, oy } = xf.current
|
||||
const ns = Math.max(0.05, Math.min(20, s * (e.deltaY < 0 ? 1.15 : 1 / 1.15)))
|
||||
const r = ns / s
|
||||
xf.current = { scale: ns, ox: dx - (dx - ox) * r, oy: dy - (dy - oy) * r }
|
||||
bump()
|
||||
}
|
||||
el.addEventListener('wheel', handler, { passive: false })
|
||||
return () => el.removeEventListener('wheel', handler)
|
||||
}, [])
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
dragging.current = true
|
||||
dragStart.current = { mx: e.clientX, my: e.clientY, ox: xf.current.ox, oy: xf.current.oy }
|
||||
e.preventDefault()
|
||||
}
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.current) return
|
||||
xf.current = { ...xf.current, ox: dragStart.current.ox + e.clientX - dragStart.current.mx, oy: dragStart.current.oy + e.clientY - dragStart.current.my }
|
||||
bump()
|
||||
}
|
||||
const onMouseUp = () => { dragging.current = false }
|
||||
|
||||
const { scale, ox, oy } = xf.current
|
||||
|
||||
return (
|
||||
<div class="diagram-modal-overlay" onClick={onClose}>
|
||||
<div class="diagram-modal-toolbar" onClick={e => e.stopPropagation()}>
|
||||
<button class="dmt-btn" onClick={() => { xf.current = { ...xf.current, scale: Math.min(20, xf.current.scale * 1.25) }; bump() }}>+</button>
|
||||
<button class="dmt-btn" onClick={() => { xf.current = { scale: 1, ox: 0, oy: 0 }; bump() }}>reset</button>
|
||||
<button class="dmt-btn" onClick={() => { xf.current = { ...xf.current, scale: Math.max(0.05, xf.current.scale / 1.25) }; bump() }}>−</button>
|
||||
<button class="dmt-btn dmt-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div
|
||||
class="diagram-modal-canvas"
|
||||
ref={canvasRef}
|
||||
onMouseDown={onMouseDown as any}
|
||||
onMouseMove={onMouseMove as any}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="diagram-modal-content"
|
||||
style={{ transform: `translate(${ox}px, ${oy}px) scale(${scale})` }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatToolContent(raw: string): { name: string; detail: string; body: string; output: string } {
|
||||
const m = raw.match(/^(\w+)\((\{[\s\S]*\})\)(.*)$/s)
|
||||
if (!m) return { name: '', detail: '', body: raw, output: '' }
|
||||
|
|
@ -201,21 +390,50 @@ function FsBrowser({ path, data, onNavigate, onSelectSession }: { path: string;
|
|||
const parent = path === '/mnt/' ? null : path.replace(/[^/]+\/?$/, '') || '/mnt/'
|
||||
const displayPath = path.startsWith('/mnt') ? path.slice(4) : path
|
||||
const isSessionDir = path === '/mnt/s/'
|
||||
const isDir = Array.isArray(data) && data.length > 0 && typeof data[0] === 'object'
|
||||
const apiPath = path.startsWith('/mnt/') ? '/' + path.slice(5) : path
|
||||
|
||||
const fileContent = data === null || isDir ? null
|
||||
: Array.isArray(data) ? data.join('\n')
|
||||
: typeof data === 'object' ? Object.entries(data).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
: String(data)
|
||||
|
||||
const [draft, setDraft] = useState(fileContent ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => { if (fileContent !== null) setDraft(fileContent) }, [fileContent])
|
||||
|
||||
// For /s/, filter to directories only and clicking selects the session
|
||||
const filteredData = isSessionDir && Array.isArray(data)
|
||||
? data.filter((e: any) => e.is_dir)
|
||||
: data
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
await write9p(apiPath, draft)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleReload = async () => {
|
||||
const fresh = await browse9p(apiPath)
|
||||
const text = fresh === null ? ''
|
||||
: Array.isArray(fresh) ? fresh.join('\n')
|
||||
: typeof fresh === 'object' ? Object.entries(fresh).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
: String(fresh)
|
||||
setDraft(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="fs-browser">
|
||||
<div class="fs-path">
|
||||
{parent && <span class="fs-path-link" onClick={() => onNavigate(parent)}>↑</span>}
|
||||
{parent && <span class="fs-path-link" onClick={() => onNavigate(parent)}>..</span>}
|
||||
{parent && ' '}{displayPath}
|
||||
{fileContent !== null && <span class="fs-path-link" onClick={handleSave}>{saving ? '…' : 'Put'}</span>}
|
||||
{fileContent !== null && <span class="fs-path-link" onClick={handleReload}>Get</span>}
|
||||
</div>
|
||||
<div class="fs-content">
|
||||
{filteredData === null ? (
|
||||
<div class="fs-empty">loading…</div>
|
||||
) : Array.isArray(filteredData) && filteredData.length > 0 && typeof filteredData[0] === 'object' ? (
|
||||
) : isDir ? (
|
||||
filteredData.map((entry: any) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
|
|
@ -228,12 +446,8 @@ function FsBrowser({ path, data, onNavigate, onSelectSession }: { path: string;
|
|||
{entry.size > 0 && <span class="fs-entry-size">{entry.size}</span>}
|
||||
</div>
|
||||
))
|
||||
) : Array.isArray(filteredData) ? (
|
||||
<pre class="fs-file">{filteredData.join('\n')}</pre>
|
||||
) : typeof filteredData === 'object' ? (
|
||||
<pre class="fs-file">{Object.entries(filteredData).map(([k, v]) => `${k}=${v}`).join('\n')}</pre>
|
||||
) : (
|
||||
<pre class="fs-file">{String(filteredData)}</pre>
|
||||
<textarea class="fs-editor" value={draft} onInput={(e) => setDraft((e.target as HTMLTextAreaElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ export async function browse9p(path: string): Promise<any> {
|
|||
return await r.json()
|
||||
}
|
||||
|
||||
export async function write9p(path: string, content: string): Promise<boolean> {
|
||||
const r = await fetch(path, { method: 'PUT', body: content })
|
||||
return r.ok
|
||||
}
|
||||
|
||||
export async function renameSession(id: string, newName: string): Promise<void> {
|
||||
await fetch(`/s/${id}`, {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
declare module 'plantuml-encoder' {
|
||||
export function encode(source: string): string
|
||||
export function decode(encoded: string): string
|
||||
export default { encode, decode }
|
||||
}
|
||||
150
src/style.css
150
src/style.css
|
|
@ -840,6 +840,7 @@ details[open] > .tool-header::before {
|
|||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.fs-path-link:hover { text-decoration: underline; }
|
||||
.fs-content {
|
||||
|
|
@ -877,6 +878,21 @@ details[open] > .tool-header::before {
|
|||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
.fs-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tree-row-link { cursor: pointer; }
|
||||
.tree-row-link:hover .tree-path { text-decoration: underline; }
|
||||
|
||||
|
|
@ -894,6 +910,140 @@ details[open] > .tool-header::before {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Diagrams ---- */
|
||||
.diagram-wrap {
|
||||
margin: 0.7em 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.diagram-toggle {
|
||||
align-self: flex-end;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.diagram-toggle:hover {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.diagram-source {
|
||||
display: none;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diagram-wrap.show-source .diagram-mermaid,
|
||||
.diagram-wrap.show-source .diagram-plantuml,
|
||||
.diagram-wrap.show-source .diagram-math { display: none; }
|
||||
.diagram-wrap.show-source .diagram-source { display: block; }
|
||||
|
||||
.diagram-mermaid,
|
||||
.diagram-plantuml,
|
||||
.diagram-math {
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diagram-mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.diagram-plantuml img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--r-md);
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.diagram-math .katex-display {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- Diagram zoom modal ---- */
|
||||
.diagram-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.diagram-modal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dmt-btn {
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
padding: 4px 11px;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.dmt-btn:hover { color: var(--text); border-color: var(--border-focus); }
|
||||
.dmt-close { margin-left: auto; }
|
||||
|
||||
.diagram-modal-canvas {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
.diagram-modal-canvas:active { cursor: grabbing; }
|
||||
|
||||
.diagram-modal-content {
|
||||
transform-origin: center center;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
.diagram-modal-content svg { display: block; max-width: 100%; height: auto; }
|
||||
.diagram-modal-content img { display: block; max-width: 100%; height: auto; background: #fff; padding: 8px; border-radius: var(--r-md); }
|
||||
|
||||
.diagram-error {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ---- Info messages ---- */
|
||||
.message.info { justify-content: flex-start; }
|
||||
.message.info .msg-bubble {
|
||||
|
|
|
|||
Reference in New Issue