@lincyaw/workbench 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +34 -2
- package/package.json +63 -3
- package/src/api.ts +126 -0
- package/src/components.tsx +174 -0
- package/src/editor.ts +223 -0
- package/src/index.ts +46 -0
- package/src/merge.ts +30 -0
- package/src/messages.ts +233 -0
- package/src/panels/Changes.tsx +128 -0
- package/src/panels/DocumentView.tsx +50 -0
- package/src/panels/Files.tsx +73 -0
- package/src/panels/Home.tsx +224 -0
- package/src/panels/Preview.tsx +68 -0
- package/src/panels/Problems.tsx +42 -0
- package/src/panels/Source.tsx +118 -0
- package/src/panels/Status.tsx +26 -0
- package/src/places.ts +34 -0
- package/src/preview.ts +59 -0
- package/src/react.tsx +59 -0
- package/src/service.ts +82 -0
- package/src/store.ts +599 -0
- package/src/styles.css +629 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Group, Panel, Separator, usePanelRef } from 'react-resizable-panels'
|
|
2
|
+
import { useLayoutEffect } from 'react'
|
|
3
|
+
import { PmButton, PmIconButton, PmNotice, PmSegmented } from '../components.tsx'
|
|
4
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
5
|
+
import { ProblemsPanel } from './Problems.tsx'
|
|
6
|
+
import { PreviewPanel } from './Preview.tsx'
|
|
7
|
+
import { SourcePanel } from './Source.tsx'
|
|
8
|
+
|
|
9
|
+
export function DocumentView({ only }: { only?: 'source' | 'preview' }) {
|
|
10
|
+
const t = useMessages()
|
|
11
|
+
const workbench = useWorkbenchStore()
|
|
12
|
+
const layout = useWorkbench(state => state.layout)
|
|
13
|
+
const focus = useWorkbench(state => state.focus)
|
|
14
|
+
const pending = useWorkbench(state => state.focusPending)
|
|
15
|
+
const failure = useWorkbench(state => state.focusFailure)
|
|
16
|
+
const home = useWorkbench(state => state.document === '' && state.documents?.single === false)
|
|
17
|
+
const mode = home ? 'source' : only ?? layout
|
|
18
|
+
const single = mode === 'split' ? undefined : mode
|
|
19
|
+
const selected = focus[0]
|
|
20
|
+
const label = selected?.quote ?? selected?.section ?? selected?.source?.file
|
|
21
|
+
const source = usePanelRef()
|
|
22
|
+
const preview = usePanelRef()
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
if (mode === 'preview') source.current?.collapse()
|
|
25
|
+
else source.current?.expand()
|
|
26
|
+
if (mode === 'source') preview.current?.collapse()
|
|
27
|
+
else preview.current?.expand()
|
|
28
|
+
}, [mode, source, preview])
|
|
29
|
+
return (
|
|
30
|
+
<div className="pmw-document">
|
|
31
|
+
{!only && !home && <div className="pmw-document__bar">
|
|
32
|
+
<PmSegmented value={layout} label={t.documentLayout} options={[{ value: 'preview', label: t.preview }, { value: 'split', label: t.split }, { value: 'source', label: t.source }]} onChange={value => workbench.setLayout(value)} />
|
|
33
|
+
</div>}
|
|
34
|
+
{selected && <div className="pmw-focus" role="status" aria-label={t.focus} data-pending={pending || undefined}>
|
|
35
|
+
<span className="pmw-focus__label">{pending ? t.focusSaving : t.focus}</span>
|
|
36
|
+
<span className="pmw-focus__quote" title={label}>{label}</span>
|
|
37
|
+
<PmIconButton icon="close" label={t.clearFocus} small onClick={() => workbench.setFocus([])} />
|
|
38
|
+
</div>}
|
|
39
|
+
{failure && <PmNotice tone="danger"><span>{t.focusFailed(failure)}</span><PmButton small onClick={() => workbench.setFocus(focus)}>{t.retryFocus}</PmButton></PmNotice>}
|
|
40
|
+
<Group className="pmw-doc" data-only={single} disabled={Boolean(single)}>
|
|
41
|
+
<Panel id="source" panelRef={source} collapsible collapsedSize={0} minSize="20%" className="pmw-doc__source" hidden={single === 'preview'}>
|
|
42
|
+
<SourcePanel />
|
|
43
|
+
<ProblemsPanel />
|
|
44
|
+
</Panel>
|
|
45
|
+
<Separator className="pmw-separator" aria-label={t.resize} hidden={Boolean(single)} />
|
|
46
|
+
<Panel id="preview" panelRef={preview} collapsible collapsedSize={0} minSize="20%" className="pmw-doc__preview" hidden={single === 'source'}><PreviewPanel /></Panel>
|
|
47
|
+
</Group>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Tree } from 'react-arborist'
|
|
2
|
+
import type { NodeRendererProps } from 'react-arborist'
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import type { SourceFile } from '../api.ts'
|
|
5
|
+
import { PmIcon, PmIconButton, PmNotice } from '../components.tsx'
|
|
6
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
7
|
+
|
|
8
|
+
interface FileNode {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
file?: SourceFile
|
|
12
|
+
children?: FileNode[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fileTree(files: SourceFile[], document: string): FileNode[] {
|
|
16
|
+
const root: FileNode = { id: '', name: '', children: [] }
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
const parts = (document && file.path.startsWith(`${document}/`) ? file.path.slice(document.length + 1) : file.path).split('/')
|
|
19
|
+
let parent = root
|
|
20
|
+
for (const [i, name] of parts.entries()) {
|
|
21
|
+
const leaf = i === parts.length - 1
|
|
22
|
+
const id = leaf ? file.path : `directory:${parts.slice(0, i + 1).join('/')}`
|
|
23
|
+
let child = parent.children!.find(node => node.id === id)
|
|
24
|
+
if (!child) {
|
|
25
|
+
child = { id, name, ...leaf ? { file } : { children: [] } }
|
|
26
|
+
parent.children!.push(child)
|
|
27
|
+
}
|
|
28
|
+
parent = child
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return root.children!
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function FileRow({ node, style }: NodeRendererProps<FileNode>) {
|
|
35
|
+
return (
|
|
36
|
+
<button type="button" tabIndex={-1} style={style} className="pmw-tree__file" data-path={node.data.file?.path} data-active={node.isSelected || undefined} disabled={node.data.file?.binary} onClick={() => node.isInternal ? node.toggle() : node.activate()}>
|
|
37
|
+
<PmIcon name={node.isInternal ? 'folder' : 'document'} size={15} />{node.data.name}
|
|
38
|
+
</button>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function FilesPanel({ onOpen }: { onOpen?(path: string): void }) {
|
|
43
|
+
const t = useMessages()
|
|
44
|
+
const workbench = useWorkbenchStore()
|
|
45
|
+
const document = useWorkbench(state => state.document)
|
|
46
|
+
const files = useWorkbench(state => state.files)
|
|
47
|
+
const active = useWorkbench(state => state.active)
|
|
48
|
+
const documents = useWorkbench(state => state.documents)
|
|
49
|
+
const failure = useWorkbench(state => state.openFailure)
|
|
50
|
+
const data = useMemo(() => fileTree(files?.files ?? [], files?.home ? '' : document ?? ''), [files, document])
|
|
51
|
+
if (document === undefined) return null
|
|
52
|
+
const title = files?.home ? documents?.title ?? t.workspace : documents?.documents.find(doc => doc.dir === document)?.title ?? (document || documents?.title || t.workspace)
|
|
53
|
+
return (
|
|
54
|
+
<nav className="pmw-files" aria-label={t.filesOf(title)}>
|
|
55
|
+
<div className="pmw-files__head">
|
|
56
|
+
<PmIconButton icon="arrow-left" label={t.backToDocuments} small onClick={() => workbench.closeDocument()} />
|
|
57
|
+
<h2 className="pmw-files__title">{title}</h2>
|
|
58
|
+
</div>
|
|
59
|
+
{failure && <PmNotice tone="danger">{t.openFailed(failure)}</PmNotice>}
|
|
60
|
+
{files && files.files.length === 0 && <p className="pmw-muted">{t.noFiles}</p>}
|
|
61
|
+
{files && (
|
|
62
|
+
<Tree<FileNode> className="pmw-tree" data={data} selection={active} width="100%" height={Math.min(480, Math.max(96, (files.files.length + data.length) * 32))} rowHeight={32} indent={16} openByDefault disableDrag disableDrop disableEdit disableMultiSelection disableSelect={node => Boolean(node.file?.binary)} aria-label={t.files} onActivate={node => {
|
|
63
|
+
if (!node.data.file || node.data.file.binary) return
|
|
64
|
+
void workbench.openFile(node.data.file.path)
|
|
65
|
+
onOpen?.(node.data.file.path)
|
|
66
|
+
}}>
|
|
67
|
+
{FileRow}
|
|
68
|
+
</Tree>
|
|
69
|
+
)}
|
|
70
|
+
{files?.truncated && <p className="pmw-muted">{t.truncated}</p>}
|
|
71
|
+
</nav>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Command } from 'cmdk'
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import type { DocumentOperation, WorkspaceDocument } from '../api.ts'
|
|
4
|
+
import { PmBadge, PmButton, PmCard, PmDialog, PmEmpty, PmField, PmIcon, PmMenu, PmNotice } from '../components.tsx'
|
|
5
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
6
|
+
|
|
7
|
+
type Editing =
|
|
8
|
+
| { op: 'create' }
|
|
9
|
+
| { op: 'rename' | 'copy' | 'delete', doc: WorkspaceDocument }
|
|
10
|
+
|
|
11
|
+
function freeDir(dir: string, taken: string[]): string {
|
|
12
|
+
for (let n = 2; ; n++) {
|
|
13
|
+
const candidate = `${dir}-${n}`
|
|
14
|
+
if (!taken.includes(candidate)) return candidate
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HomePanel() {
|
|
19
|
+
const t = useMessages()
|
|
20
|
+
const workbench = useWorkbenchStore()
|
|
21
|
+
const answer = useWorkbench(state => state.documents)
|
|
22
|
+
const failure = useWorkbench(state => state.failure)
|
|
23
|
+
const noRoutes = useWorkbench(state => state.noRoutes)
|
|
24
|
+
const [editing, setEditing] = useState<Editing>()
|
|
25
|
+
const [done, setDone] = useState<string>()
|
|
26
|
+
const [error, setError] = useState<string>()
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void workbench.loadDocuments()
|
|
29
|
+
}, [workbench])
|
|
30
|
+
const documents = answer?.documents ?? []
|
|
31
|
+
const operable = Boolean(answer?.git) && !answer?.single
|
|
32
|
+
|
|
33
|
+
const run = async (operation: DocumentOperation, said: (dir: string) => string) => {
|
|
34
|
+
setError(undefined)
|
|
35
|
+
try {
|
|
36
|
+
const result = await workbench.operate(operation)
|
|
37
|
+
setDone(said(result.dir ?? operation.dir))
|
|
38
|
+
setEditing(undefined)
|
|
39
|
+
} catch (failed) {
|
|
40
|
+
setError(failed instanceof Error ? failed.message : String(failed))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section className="pmw-home" aria-labelledby="pmw-home-title">
|
|
46
|
+
<header className="pmw-home__bar">
|
|
47
|
+
<h1 id="pmw-home-title" className="pmw-home__title">{answer?.title ?? t.workspace}</h1>
|
|
48
|
+
<Search documents={documents} onCreate={() => setEditing({ op: 'create' })} />
|
|
49
|
+
{operable && <PmButton variant="primary" icon="plus" onClick={() => setEditing({ op: 'create' })}>{t.newDocument}</PmButton>}
|
|
50
|
+
</header>
|
|
51
|
+
{failure && <PmNotice tone="danger">{noRoutes ? t.noRoutes : failure}</PmNotice>}
|
|
52
|
+
{done && <PmNotice tone="ok">{done}</PmNotice>}
|
|
53
|
+
{answer && !answer.git && !answer.single && <PmNotice tone="warn">{t.notGit}</PmNotice>}
|
|
54
|
+
<h2 className="pmw-home__heading">{t.documents}</h2>
|
|
55
|
+
{answer && documents.length === 0
|
|
56
|
+
? <PmEmpty icon="document" title={t.noDocuments} />
|
|
57
|
+
: (
|
|
58
|
+
<ul className="pmw-home__cards" aria-label={t.documents}>
|
|
59
|
+
{documents.map(doc => (
|
|
60
|
+
<li key={doc.dir} className="pmw-home__card" data-document={doc.dir}>
|
|
61
|
+
<PmCard variant="interactive">
|
|
62
|
+
<button type="button" className="pmw-home__open" onClick={() => void workbench.openDocument(doc.dir)}>
|
|
63
|
+
<span className="pmw-home__doc-title">{doc.title}</span>
|
|
64
|
+
<span className="pmw-home__doc-dir">{doc.dir || '.'}</span>
|
|
65
|
+
</button>
|
|
66
|
+
<div className="pmw-home__meta">
|
|
67
|
+
{doc.lang && <PmBadge tone="outline">{doc.lang}</PmBadge>}
|
|
68
|
+
{operable && (
|
|
69
|
+
<PmMenu
|
|
70
|
+
label={t.more(doc.title)}
|
|
71
|
+
items={[{ id: 'rename', label: t.rename, icon: 'edit' }, { id: 'copy', label: t.copy, icon: 'copy' }, { id: 'delete', label: t.remove, icon: 'trash', danger: true }]}
|
|
72
|
+
onSelect={id => setEditing({ op: id as 'rename' | 'copy' | 'delete', doc })}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</PmCard>
|
|
77
|
+
</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)}
|
|
81
|
+
<McpCard />
|
|
82
|
+
{editing && (
|
|
83
|
+
<OperationDialog
|
|
84
|
+
editing={editing}
|
|
85
|
+
templates={answer?.templates ?? []}
|
|
86
|
+
taken={documents.map(doc => doc.dir)}
|
|
87
|
+
error={error}
|
|
88
|
+
onClose={() => {
|
|
89
|
+
setEditing(undefined)
|
|
90
|
+
setError(undefined)
|
|
91
|
+
}}
|
|
92
|
+
onRun={(operation) => {
|
|
93
|
+
const said = { create: t.created, rename: t.renamed, copy: t.copied, delete: t.removed }[operation.op]
|
|
94
|
+
void run(operation, said)
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</section>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function OperationDialog({ editing, templates, taken, error, onClose, onRun }: { editing: Editing, templates: string[], taken: string[], error?: string, onClose(): void, onRun(operation: DocumentOperation): void }) {
|
|
103
|
+
const t = useMessages()
|
|
104
|
+
const [dir, setDir] = useState(editing.op === 'create' ? '' : editing.op === 'copy' ? freeDir(editing.doc.dir, taken) : editing.op === 'rename' ? editing.doc.dir : '')
|
|
105
|
+
const [title, setTitle] = useState('')
|
|
106
|
+
const [template, setTemplate] = useState(templates.includes('default') ? 'default' : templates[0] ?? '')
|
|
107
|
+
const label = editing.op === 'create' ? t.newDocument : `${{ rename: t.rename, copy: t.copy, delete: t.remove }[editing.op]} · ${editing.doc.title}`
|
|
108
|
+
const submit = () => {
|
|
109
|
+
if (editing.op === 'create') onRun({ op: 'create', dir: dir.trim(), ...title.trim() ? { title: title.trim() } : {}, ...template ? { template } : {} })
|
|
110
|
+
else if (editing.op === 'delete') onRun({ op: 'delete', dir: editing.doc.dir })
|
|
111
|
+
else onRun({ op: editing.op, dir: editing.doc.dir, to: dir.trim() })
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<PmDialog open label={label} closeLabel={t.close} name="pmw-operation" onClose={onClose}>
|
|
115
|
+
<form
|
|
116
|
+
className="pmw-operation"
|
|
117
|
+
onSubmit={(event) => {
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
submit()
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{editing.op === 'delete'
|
|
123
|
+
? <p>{t.confirmRemove(editing.doc.title)}</p>
|
|
124
|
+
: (
|
|
125
|
+
<PmField label={editing.op === 'create' ? t.dir : editing.op === 'rename' ? t.renameTo : t.copyTo}>
|
|
126
|
+
<input className="pm-ui-input" name="dir" value={dir} required onChange={event => setDir(event.target.value)} />
|
|
127
|
+
</PmField>
|
|
128
|
+
)}
|
|
129
|
+
{editing.op === 'create' && (
|
|
130
|
+
<>
|
|
131
|
+
<PmField label={t.title}>
|
|
132
|
+
<input className="pm-ui-input" name="title" value={title} onChange={event => setTitle(event.target.value)} />
|
|
133
|
+
</PmField>
|
|
134
|
+
{templates.length > 1 && (
|
|
135
|
+
<PmField label={t.template}>
|
|
136
|
+
<select className="pm-ui-select" name="template" value={template} onChange={event => setTemplate(event.target.value)}>
|
|
137
|
+
{templates.map(name => <option key={name} value={name}>{name}</option>)}
|
|
138
|
+
</select>
|
|
139
|
+
</PmField>
|
|
140
|
+
)}
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
{error && <PmNotice tone="danger">{error}</PmNotice>}
|
|
144
|
+
<div className="pmw-operation__actions">
|
|
145
|
+
<PmButton onClick={onClose}>{t.cancel}</PmButton>
|
|
146
|
+
<PmButton type="submit" variant={editing.op === 'delete' ? 'danger' : 'primary'}>
|
|
147
|
+
{editing.op === 'create' ? t.create : { rename: t.rename, copy: t.copy, delete: t.remove }[editing.op]}
|
|
148
|
+
</PmButton>
|
|
149
|
+
</div>
|
|
150
|
+
</form>
|
|
151
|
+
</PmDialog>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Search({ documents, onCreate }: { documents: WorkspaceDocument[], onCreate(): void }) {
|
|
156
|
+
const t = useMessages()
|
|
157
|
+
const workbench = useWorkbenchStore()
|
|
158
|
+
const [query, setQuery] = useState('')
|
|
159
|
+
const input = useRef<HTMLInputElement>(null)
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const onKey = (event: KeyboardEvent) => {
|
|
162
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
input.current?.focus()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
window.addEventListener('keydown', onKey)
|
|
168
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
169
|
+
}, [])
|
|
170
|
+
return (
|
|
171
|
+
<Command className="pmw-search" label={t.search}>
|
|
172
|
+
<label className="pm-ui-search">
|
|
173
|
+
<PmIcon name="search" size={15} strokeWidth={1.8} />
|
|
174
|
+
<Command.Input ref={input} value={query} aria-label={t.search} placeholder={`${t.search}(${t.searchHint})`} onValueChange={setQuery} onKeyDown={event => { if (event.key === 'Escape') setQuery('') }} />
|
|
175
|
+
</label>
|
|
176
|
+
{query.trim() && (
|
|
177
|
+
<Command.List className="pmw-search__results" aria-label={t.search}>
|
|
178
|
+
<Command.Empty className="pmw-muted">{t.noMatch}</Command.Empty>
|
|
179
|
+
<Command.Group heading={t.documents}>
|
|
180
|
+
{documents.map(doc => (
|
|
181
|
+
<Command.Item key={doc.dir} value={`${doc.title} ${doc.dir}`} className="pmw-search__item" onSelect={() => { setQuery(''); void workbench.openDocument(doc.dir) }}>
|
|
182
|
+
<PmIcon name="document" size={15} />{doc.title}<span className="pmw-muted">{doc.dir}</span>
|
|
183
|
+
</Command.Item>
|
|
184
|
+
))}
|
|
185
|
+
</Command.Group>
|
|
186
|
+
<Command.Group heading={t.commands}>
|
|
187
|
+
<Command.Item value={t.newDocument} className="pmw-search__item" onSelect={() => { setQuery(''); onCreate() }}><PmIcon name="plus" size={15} />{t.newDocument}</Command.Item>
|
|
188
|
+
</Command.Group>
|
|
189
|
+
</Command.List>
|
|
190
|
+
)}
|
|
191
|
+
</Command>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function McpCard() {
|
|
196
|
+
const t = useMessages()
|
|
197
|
+
const workbench = useWorkbenchStore()
|
|
198
|
+
const [address, setAddress] = useState<string>()
|
|
199
|
+
const [copied, setCopied] = useState(false)
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
workbench.client.workbench().then(answer => setAddress(answer.mcp), () => {})
|
|
202
|
+
}, [workbench])
|
|
203
|
+
if (!address) return null
|
|
204
|
+
return (
|
|
205
|
+
<PmCard className="pmw-home__mcp">
|
|
206
|
+
<h2 className="pmw-home__heading">{t.mcp}</h2>
|
|
207
|
+
<div className="pmw-home__address">
|
|
208
|
+
<code>{address}</code>
|
|
209
|
+
<PmButton
|
|
210
|
+
small
|
|
211
|
+
icon={copied ? 'check' : 'copy'}
|
|
212
|
+
onClick={() => {
|
|
213
|
+
void navigator.clipboard?.writeText(address).catch(() => {})
|
|
214
|
+
setCopied(true)
|
|
215
|
+
setTimeout(() => setCopied(false), 2000)
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{copied ? t.copiedAddress : t.copyAddress}
|
|
219
|
+
</PmButton>
|
|
220
|
+
</div>
|
|
221
|
+
<p className="pmw-muted">{t.mcpHint}</p>
|
|
222
|
+
</PmCard>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { connectPreview } from '../preview.ts'
|
|
3
|
+
import { PmButton, PmIconButton, PmNotice, PmSegmented } from '../components.tsx'
|
|
4
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
5
|
+
import type { PreviewView } from '../store.ts'
|
|
6
|
+
|
|
7
|
+
const READY_MS = 45_000
|
|
8
|
+
const RELOADS = 3
|
|
9
|
+
|
|
10
|
+
export function PreviewPanel() {
|
|
11
|
+
const t = useMessages()
|
|
12
|
+
const workbench = useWorkbenchStore()
|
|
13
|
+
const document = useWorkbench(state => state.document)
|
|
14
|
+
const documents = useWorkbench(state => state.documents)
|
|
15
|
+
const preview = useWorkbench(state => state.preview)
|
|
16
|
+
const failure = useWorkbench(state => state.previewFailure)
|
|
17
|
+
const view = useWorkbench(state => state.view)
|
|
18
|
+
const picking = useWorkbench(state => state.picking)
|
|
19
|
+
const frame = useRef<HTMLIFrameElement>(null)
|
|
20
|
+
const [loads, setLoads] = useState(0)
|
|
21
|
+
const [ready, setReady] = useState(false)
|
|
22
|
+
const [mismatch, setMismatch] = useState(false)
|
|
23
|
+
const address = view === 'slides' ? preview?.slides : preview?.web
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!address || !frame.current) return
|
|
27
|
+
setReady(false)
|
|
28
|
+
const connection = connectPreview(() => frame.current, address, {
|
|
29
|
+
report(report) {
|
|
30
|
+
if (report.type === 'ready') setReady(true)
|
|
31
|
+
workbench.previewReport(report)
|
|
32
|
+
},
|
|
33
|
+
mismatch: () => setMismatch(true),
|
|
34
|
+
})
|
|
35
|
+
frame.current.src = connection.src
|
|
36
|
+
const detach = workbench.attachPreview(connection)
|
|
37
|
+
let reloads = 0
|
|
38
|
+
const timer = setInterval(() => {
|
|
39
|
+
if (connection.ready || reloads >= RELOADS || !frame.current) return
|
|
40
|
+
reloads++
|
|
41
|
+
frame.current.src = connection.src
|
|
42
|
+
}, READY_MS)
|
|
43
|
+
return () => {
|
|
44
|
+
clearInterval(timer)
|
|
45
|
+
detach()
|
|
46
|
+
connection.dispose()
|
|
47
|
+
}
|
|
48
|
+
}, [address, workbench, loads])
|
|
49
|
+
|
|
50
|
+
if (document === undefined) return null
|
|
51
|
+
const title = documents?.documents.find(doc => doc.dir === document)?.title ?? document
|
|
52
|
+
const views: { value: PreviewView, label: string }[] = [{ value: 'web', label: t.views.web }, ...preview?.slides ? [{ value: 'slides' as const, label: t.views.slides }] : []]
|
|
53
|
+
return (
|
|
54
|
+
<section className="pmw-preview" aria-label={t.preview} data-ready={ready || undefined}>
|
|
55
|
+
<div className="pmw-preview__bar">
|
|
56
|
+
{views.length > 1 && <PmSegmented value={view} options={views} label={t.view} onChange={value => workbench.setView(value)} />}
|
|
57
|
+
<span className="pmw-spacer" />
|
|
58
|
+
<PmButton small icon="edit" variant={picking ? 'accent' : 'ghost'} aria-pressed={picking} title={t.pickHint} onClick={() => workbench.setPicking(!picking)}>{t.pick}</PmButton>
|
|
59
|
+
<PmIconButton icon="redo" label={t.reloadPreview} small onClick={() => (preview ? setLoads(n => n + 1) : workbench.retryPreview())} />
|
|
60
|
+
</div>
|
|
61
|
+
{mismatch && <PmNotice tone="warn">{t.versionMismatch}</PmNotice>}
|
|
62
|
+
{failure && <PmNotice tone="danger">{t.previewFailed(failure)}</PmNotice>}
|
|
63
|
+
{!preview && !failure && <p className="pmw-muted pmw-preview__loading">{t.previewLoading}</p>}
|
|
64
|
+
{view === 'slides' && preview && !preview.slides && <p className="pmw-muted">{t.noSlides}</p>}
|
|
65
|
+
<iframe ref={frame} className="pmw-preview__frame" title={t.previewTitle(title)} hidden={!address} />
|
|
66
|
+
</section>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { PmIcon } from '../components.tsx'
|
|
2
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
3
|
+
|
|
4
|
+
export function ProblemsPanel() {
|
|
5
|
+
const t = useMessages()
|
|
6
|
+
const workbench = useWorkbenchStore()
|
|
7
|
+
const ir = useWorkbench(state => state.ir)
|
|
8
|
+
const issues = useWorkbench(state => state.issues)
|
|
9
|
+
const findings = ir?.diagnostics ?? []
|
|
10
|
+
const count = findings.length + issues.length
|
|
11
|
+
return (
|
|
12
|
+
<section className="pmw-problems" aria-label={t.problems}>
|
|
13
|
+
<h2 className="pmw-problems__head">
|
|
14
|
+
{t.problems}
|
|
15
|
+
<span className="pm-ui-badge" data-count={count}>{count}</span>
|
|
16
|
+
</h2>
|
|
17
|
+
{ir && count === 0 && <p className="pmw-muted pmw-problems__none">{t.noProblems}</p>}
|
|
18
|
+
<ul className="pmw-problems__list">
|
|
19
|
+
{findings.map((finding, i) => (
|
|
20
|
+
<li key={`f${i}`}>
|
|
21
|
+
<button type="button" className="pmw-problem" data-severity={finding.severity} disabled={!finding.source?.file} onClick={() => void workbench.revealProblem(finding)}>
|
|
22
|
+
<PmIcon name={finding.severity === 'error' ? 'error' : finding.severity === 'warning' ? 'warning' : 'info'} size={15} />
|
|
23
|
+
<span className="pmw-problem__severity">{t.severity[finding.severity] ?? finding.severity}</span>
|
|
24
|
+
<span className="pmw-problem__where">{finding.source ? `${finding.source.file}${finding.source.line ? `:${finding.source.line}` : ''}` : ''}</span>
|
|
25
|
+
<span className="pmw-problem__message">{finding.message}{finding.hint ? ` ${finding.hint}` : ''}</span>
|
|
26
|
+
</button>
|
|
27
|
+
</li>
|
|
28
|
+
))}
|
|
29
|
+
{issues.map((issue, i) => (
|
|
30
|
+
<li key={`p${i}`}>
|
|
31
|
+
<div className="pmw-problem" data-severity={issue.level}>
|
|
32
|
+
<PmIcon name={issue.level === 'error' ? 'error' : 'warning'} size={15} />
|
|
33
|
+
<span className="pmw-problem__severity">{t.severity[issue.level]}</span>
|
|
34
|
+
<span className="pmw-problem__where">{t.page}{issue.plugin ? ` · ${issue.plugin}` : ''}</span>
|
|
35
|
+
<span className="pmw-problem__message">{issue.message}</span>
|
|
36
|
+
</div>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
</section>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
import type { KeyboardEvent } from 'react'
|
|
4
|
+
import { createEditor } from '../editor.ts'
|
|
5
|
+
import type { Problem, SourceEditor } from '../editor.ts'
|
|
6
|
+
import { PmButton, PmIconButton, PmNotice } from '../components.tsx'
|
|
7
|
+
import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
|
|
8
|
+
import type { OpenFile } from '../store.ts'
|
|
9
|
+
|
|
10
|
+
const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1)
|
|
11
|
+
|
|
12
|
+
export function SourcePanel() {
|
|
13
|
+
const t = useMessages()
|
|
14
|
+
const workbench = useWorkbenchStore()
|
|
15
|
+
const open = useWorkbench(state => state.open)
|
|
16
|
+
const active = useWorkbench(state => state.active)
|
|
17
|
+
const file = open.find(f => f.path === active)
|
|
18
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
19
|
+
if (event.defaultPrevented || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
void workbench.save()
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<Tabs.Root asChild value={active ?? ''} onValueChange={path => workbench.showFile(path)}>
|
|
25
|
+
<section className="pmw-source" aria-label={t.source} onKeyDown={onKeyDown}>
|
|
26
|
+
<Tabs.List className="pmw-source__tabs" aria-label={t.source}>
|
|
27
|
+
{open.map(f => (
|
|
28
|
+
<span key={f.path} className="pmw-source__tab" data-active={f.path === active || undefined}>
|
|
29
|
+
<Tabs.Trigger value={f.path} title={f.path}>
|
|
30
|
+
{nameOf(f.path)}
|
|
31
|
+
{f.dirty && <span className="pmw-source__dot" aria-label={t.unsaved} role="img" />}
|
|
32
|
+
</Tabs.Trigger>
|
|
33
|
+
<PmIconButton icon="close" label={t.closeFile(nameOf(f.path))} small onClick={() => workbench.closeFile(f.path)} />
|
|
34
|
+
</span>
|
|
35
|
+
))}
|
|
36
|
+
<span className="pmw-spacer" />
|
|
37
|
+
{file && <PmButton small icon="check" variant={file.dirty ? 'primary' : 'default'} onClick={() => void workbench.save(file.path)}>{t.save}</PmButton>}
|
|
38
|
+
</Tabs.List>
|
|
39
|
+
{file?.notice && <FileNoticeBox file={file} />}
|
|
40
|
+
<Tabs.Content value={active ?? ''} forceMount className="pmw-source__body"><EditorPane /></Tabs.Content>
|
|
41
|
+
{!file && <p className="pmw-source__empty pmw-muted">{t.noFile}</p>}
|
|
42
|
+
</section>
|
|
43
|
+
</Tabs.Root>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function FileNoticeBox({ file }: { file: OpenFile }) {
|
|
48
|
+
const t = useMessages()
|
|
49
|
+
const workbench = useWorkbenchStore()
|
|
50
|
+
const notice = file.notice!
|
|
51
|
+
switch (notice.kind) {
|
|
52
|
+
case 'changed':
|
|
53
|
+
return (
|
|
54
|
+
<PmNotice tone="warn">
|
|
55
|
+
<span>{t.changedOnDisk(nameOf(file.path))}</span>
|
|
56
|
+
<span className="pmw-notice__actions">
|
|
57
|
+
<PmButton small onClick={() => workbench.merge(file.path, { mine: t.mergeMine, theirs: t.mergeTheirs })}>{t.merge}</PmButton>
|
|
58
|
+
<PmButton small onClick={() => workbench.reload(file.path)}>{t.reload}</PmButton>
|
|
59
|
+
</span>
|
|
60
|
+
</PmNotice>
|
|
61
|
+
)
|
|
62
|
+
case 'merged': return <PmNotice tone="accent">{t.merged(notice.conflicts)}</PmNotice>
|
|
63
|
+
case 'locked': return <PmNotice tone="warn">{t.locked}</PmNotice>
|
|
64
|
+
case 'failed': return <PmNotice tone="danger">{t.saveFailed(notice.message)}</PmNotice>
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function EditorPane() {
|
|
69
|
+
const t = useMessages()
|
|
70
|
+
const workbench = useWorkbenchStore()
|
|
71
|
+
const parent = useRef<HTMLDivElement>(null)
|
|
72
|
+
const editor = useRef<SourceEditor>(undefined)
|
|
73
|
+
const active = useWorkbench(state => state.active)
|
|
74
|
+
const reveal = useWorkbench(state => state.reveal)
|
|
75
|
+
const ir = useWorkbench(state => state.ir)
|
|
76
|
+
const opened = useWorkbench(state => state.open.length)
|
|
77
|
+
const label = useRef(t.editor(''))
|
|
78
|
+
label.current = t.editor(active ?? '')
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const made = createEditor(parent.current!, {
|
|
82
|
+
label: label.current,
|
|
83
|
+
save: () => void workbench.save(),
|
|
84
|
+
changed: (path, text) => workbench.edited(path, text),
|
|
85
|
+
moved: line => workbench.moved(line),
|
|
86
|
+
selected: selection => workbench.selectSource(selection),
|
|
87
|
+
})
|
|
88
|
+
editor.current = made
|
|
89
|
+
const unbind = workbench.bindEditor({ text: path => made.text(path), replace: (path, text, fresh) => made.replace(path, text, fresh), focus: () => made.focus() })
|
|
90
|
+
return () => {
|
|
91
|
+
unbind()
|
|
92
|
+
made.destroy()
|
|
93
|
+
editor.current = undefined
|
|
94
|
+
}
|
|
95
|
+
}, [workbench])
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const made = editor.current
|
|
99
|
+
if (!made) return
|
|
100
|
+
for (const path of made.paths()) if (!workbench.state.open.some(file => file.path === path)) made.forget(path)
|
|
101
|
+
if (!active) return
|
|
102
|
+
made.show(active, workbench.textOf(active) ?? '')
|
|
103
|
+
}, [active, workbench, opened])
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (reveal && reveal.path === active) editor.current?.reveal(reveal.line, reveal.endLine, reveal.mark)
|
|
107
|
+
}, [reveal, active])
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!active || !editor.current) return
|
|
111
|
+
const problems: Problem[] = (ir?.diagnostics ?? []).filter(finding => finding.source?.file === active && finding.source.line).map(finding => ({ line: finding.source!.line!, severity: finding.severity === 'error' ? 'error' : finding.severity === 'warning' ? 'warning' : 'info', message: finding.message }))
|
|
112
|
+
editor.current.problems(active, problems)
|
|
113
|
+
}, [ir, active])
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="pmw-source__editor" ref={parent} hidden={!active} data-path={active} />
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PmIcon } from '../components.tsx'
|
|
2
|
+
import { useMessages, useWorkbench } from '../react.tsx'
|
|
3
|
+
|
|
4
|
+
export function StatusItems() {
|
|
5
|
+
const t = useMessages()
|
|
6
|
+
const documents = useWorkbench(state => state.documents)
|
|
7
|
+
const document = useWorkbench(state => state.document)
|
|
8
|
+
const active = useWorkbench(state => state.active)
|
|
9
|
+
const line = useWorkbench(state => state.line)
|
|
10
|
+
const lang = documents?.documents.find(doc => doc.dir === document)?.lang
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
{documents && (
|
|
14
|
+
<span className="pmw-status__item" data-status="git">
|
|
15
|
+
<PmIcon name="branch" size={13} />
|
|
16
|
+
{documents.git ? (documents.branch ? t.branch(documents.branch) : 'HEAD') : t.noGit}
|
|
17
|
+
{documents.git && documents.uncommitted ? ` · ${t.uncommitted(documents.uncommitted)}` : ''}
|
|
18
|
+
</span>
|
|
19
|
+
)}
|
|
20
|
+
<span className="pmw-spacer" />
|
|
21
|
+
{document !== undefined && lang && <span className="pmw-status__item" data-status="lang">{t.language(lang)}</span>}
|
|
22
|
+
{active && <span className="pmw-status__item" data-status="encoding">{t.encoding}</span>}
|
|
23
|
+
{active && <span className="pmw-status__item" data-status="line">{t.line(line)}</span>}
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
}
|
package/src/places.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { irBlocks } from '@lincyaw/kernel'
|
|
2
|
+
import type { IRDocument, SourcePos } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export interface Place {
|
|
5
|
+
section: string
|
|
6
|
+
block?: string
|
|
7
|
+
source: SourcePos
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function placeAt(doc: IRDocument, file: string, line: number): Place | undefined {
|
|
11
|
+
let found: Place | undefined
|
|
12
|
+
let span = Number.POSITIVE_INFINITY
|
|
13
|
+
for (const section of doc.sections) {
|
|
14
|
+
const places: Place[] = [{ section: section.id, source: section.source }, ...[...irBlocks(section.blocks)].map(block => ({ section: section.id, block: block.id, source: block.source }))]
|
|
15
|
+
for (const place of places) {
|
|
16
|
+
const { source } = place
|
|
17
|
+
const end = source.endLine ?? source.line
|
|
18
|
+
if (source.file !== file || line < source.line || line > end) continue
|
|
19
|
+
if (end - source.line < span || (end - source.line === span && place.block)) {
|
|
20
|
+
found = place
|
|
21
|
+
span = end - source.line
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return found
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function placeOf(doc: IRDocument, section: string, block?: string): Place | undefined {
|
|
29
|
+
const found = doc.sections.find(s => s.id === section)
|
|
30
|
+
if (!found) return undefined
|
|
31
|
+
if (!block) return { section, source: found.source }
|
|
32
|
+
const inner = [...irBlocks(found.blocks)].find(b => b.id === block)
|
|
33
|
+
return inner ? { section, block, source: inner.source } : { section, source: found.source }
|
|
34
|
+
}
|