@lincyaw/workbench 0.1.0 → 0.2.0-rc.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.
Files changed (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -1,224 +0,0 @@
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
- }
@@ -1,68 +0,0 @@
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
- }
@@ -1,42 +0,0 @@
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
- }
@@ -1,118 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
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/react.tsx DELETED
@@ -1,59 +0,0 @@
1
- import { createContext, useContext, useEffect, useSyncExternalStore } from 'react'
2
- import type { ReactNode } from 'react'
3
- import { MESSAGES } from './messages.ts'
4
- import type { WorkbenchMessages } from './messages.ts'
5
- import type { Workbench, WorkbenchState } from './store.ts'
6
- import { PmButton, PmDialog, PmNotice } from './components.tsx'
7
-
8
- const WORKBENCH = createContext<Workbench | undefined>(undefined)
9
-
10
- export function WorkbenchProvider({ workbench, children, protectChanges = true }: { workbench: Workbench, children?: ReactNode, protectChanges?: boolean }) {
11
- return <WORKBENCH.Provider value={workbench}>{children}{protectChanges && <UnsavedChanges />}</WORKBENCH.Provider>
12
- }
13
-
14
- function UnsavedChanges() {
15
- const workbench = useWorkbenchStore()
16
- const files = useWorkbench(state => state.leaving)
17
- const saving = useWorkbench(state => state.leaveSaving)
18
- const failed = useWorkbench(state => state.leaveFailed)
19
- const t = useMessages()
20
- useEffect(() => {
21
- const beforeUnload = (event: BeforeUnloadEvent) => {
22
- if (!workbench.state.open.some(file => file.dirty)) return
23
- event.preventDefault()
24
- event.returnValue = ''
25
- }
26
- window.addEventListener('beforeunload', beforeUnload)
27
- const refreshFiles = () => { void workbench.checkDisk() }
28
- window.addEventListener('focus', refreshFiles)
29
- return () => {
30
- window.removeEventListener('beforeunload', beforeUnload)
31
- window.removeEventListener('focus', refreshFiles)
32
- }
33
- }, [workbench])
34
- return <PmDialog open={Boolean(files)} label={t.saveChanges} closeLabel={t.close} onClose={() => void workbench.resolveLeave('cancel')}>
35
- <p>{t.unsavedFiles}</p>
36
- <ul>{files?.map(path => <li key={path}>{path}</li>)}</ul>
37
- {failed && <PmNotice tone="danger">{t.saveBeforeLeavingFailed}</PmNotice>}
38
- <div className="pmw-notice__actions">
39
- <PmButton disabled={saving} onClick={() => void workbench.resolveLeave('cancel')}>{t.cancel}</PmButton>
40
- <PmButton disabled={saving} onClick={() => void workbench.resolveLeave('discard')}>{t.discardChanges}</PmButton>
41
- <PmButton disabled={saving} variant="primary" onClick={() => void workbench.resolveLeave('save')}>{t.save}</PmButton>
42
- </div>
43
- </PmDialog>
44
- }
45
-
46
- export function useWorkbenchStore(): Workbench {
47
- const workbench = useContext(WORKBENCH)
48
- if (!workbench) throw new Error('a workbench panel is drawn outside WorkbenchProvider')
49
- return workbench
50
- }
51
-
52
- export function useWorkbench<T>(select: (state: WorkbenchState) => T): T {
53
- const workbench = useWorkbenchStore()
54
- return useSyncExternalStore(workbench.subscribe, () => select(workbench.getState()))
55
- }
56
-
57
- export function useMessages(): WorkbenchMessages {
58
- return MESSAGES[useWorkbench(state => state.locale)]
59
- }
package/src/service.ts DELETED
@@ -1,82 +0,0 @@
1
- import type { FocusReport } from '@lincyaw/kernel'
2
- import type { DocumentOperation, DocumentsAnswer, ErrorAnswer, FilesAnswer, FocusAnswer, HistoryRevision, IrAnswer, IrEvent, OperationAnswer, PreviewAnswer, RevertAnswer, ReviewEnd, ReviewFollowed, ReviewView, SavedAnswer, SourceAnswer, UncommittedFile, WorkbenchAnswer } from './api.ts'
3
-
4
- export class ServiceError extends Error {
5
- readonly status: number
6
- readonly answer: ErrorAnswer
7
- constructor(status: number, answer: ErrorAnswer) {
8
- super(answer.error ?? answer.detail ?? answer.title ?? `HTTP ${status}`)
9
- this.status = status
10
- this.answer = answer
11
- }
12
- }
13
-
14
- export interface WorkspaceClient {
15
- documents(document?: string): Promise<DocumentsAnswer>
16
- operate(operation: DocumentOperation): Promise<OperationAnswer>
17
- files(document: string): Promise<FilesAnswer>
18
- source(document: string, path: string): Promise<SourceAnswer>
19
- save(document: string, path: string, content: string, base: string): Promise<SavedAnswer>
20
- ir(document: string): Promise<IrAnswer>
21
- preview(document: string): Promise<PreviewAnswer>
22
- workbench(): Promise<WorkbenchAnswer>
23
- focus(document: string, focus?: FocusReport[]): Promise<FocusAnswer>
24
- events(document: string, revision: (event: IrEvent) => void, failed?: () => void): () => void
25
- review(document: string, followed: (event: ReviewFollowed) => void): () => void
26
- accept(document: string, body: { reject?: string[], message?: string }): Promise<{ ended: ReviewEnd }>
27
- reject(document: string, paths?: string[]): Promise<ReviewFollowed>
28
- restore(document: string, step: string): Promise<{ review: ReviewView }>
29
- changes(document: string): Promise<{ files: UncommittedFile[] }>
30
- commit(document: string, message: string, paths?: string[]): Promise<unknown>
31
- discard(document: string, paths?: string[]): Promise<unknown>
32
- revisions(document: string): Promise<{ revisions: HistoryRevision[] }>
33
- revert(document: string, id: string): Promise<RevertAnswer>
34
- }
35
-
36
- export function workspaceClient(base = 'api/presmium/'): WorkspaceClient {
37
- const url = (path: string, document?: string, query: Record<string, string> = {}) => {
38
- const address = new URL(`${base}${path}`, globalThis.document.baseURI)
39
- if (document !== undefined) address.searchParams.set('document', document)
40
- for (const [name, value] of Object.entries(query)) address.searchParams.set(name, value)
41
- return address.href
42
- }
43
- const call = async <T>(address: string, init?: RequestInit): Promise<T> => {
44
- const response = await fetch(address, { credentials: 'same-origin', ...init })
45
- const data = await response.json().catch(() => ({})) as T & ErrorAnswer
46
- if (!response.ok) throw new ServiceError(response.status, data)
47
- return data
48
- }
49
- const post = <T>(address: string, body: unknown) => call<T>(address, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
50
- const follow = <T>(address: string, name: string, on: (data: T) => void, failed?: () => void) => {
51
- const source = new EventSource(address)
52
- source.addEventListener(name, (event) => {
53
- try {
54
- on(JSON.parse((event as MessageEvent<string>).data) as T)
55
- } catch {
56
- }
57
- })
58
- if (failed) source.addEventListener('error', failed)
59
- return () => source.close()
60
- }
61
- return {
62
- documents: document => call(url('documents', document)),
63
- operate: operation => post(url('documents'), operation),
64
- files: document => call(url('files', document)),
65
- source: (document, path) => call(url('source', document, { path })),
66
- save: (document, path, content, base) => post(url('source', document), { path, content, base }),
67
- ir: document => call(url('ir', document)),
68
- preview: document => call(url('preview', document)),
69
- workbench: () => call(url('workbench')),
70
- focus: (document, focus) => focus === undefined ? call(url('focus', document)) : post(url('focus'), { document, focus }),
71
- events: (document, revision, failed) => follow(url('ir/events', document), 'revision', revision, failed),
72
- review: (document, followed) => follow(url('review', document), 'review', followed),
73
- accept: (document, body) => post(url('review/accept', document), body),
74
- reject: (document, paths) => post(url('review/reject', document), paths ? { paths } : {}),
75
- restore: (document, step) => post(url('review/restore', document), { step }),
76
- changes: document => call(url('changes', document)),
77
- commit: (document, message, paths) => post(url('changes/commit', document), { message, ...paths ? { paths } : {} }),
78
- discard: (document, paths) => post(url('changes/discard', document), paths ? { paths } : {}),
79
- revisions: document => call(url('revisions', document, { limit: '50' })),
80
- revert: (document, id) => post(url('revisions/revert', document), { id }),
81
- }
82
- }