@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.
@@ -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
+ }