@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
package/src/store.ts DELETED
@@ -1,599 +0,0 @@
1
- import { stepKeys, trailStep } from '@lincyaw/kernel'
2
- import type { Finding, FocusReport, PreviewAnchor, PreviewCommand, PreviewReport } from '@lincyaw/kernel'
3
- import type { DocumentOperation, DocumentsAnswer, FilesAnswer, HistoryRevision, IrAnswer, IrEvent, OperationAnswer, PreviewAnswer, ReviewFollowed, ReviewView, SourceAnswer, UncommittedFile } from './api.ts'
4
- import { mergeTexts } from './merge.ts'
5
- import type { MergeLabels } from './merge.ts'
6
- import { placeAt, placeOf } from './places.ts'
7
- import { ServiceError, workspaceClient } from './service.ts'
8
- import type { WorkspaceClient } from './service.ts'
9
-
10
- export type Locale = 'zh-CN' | 'en'
11
-
12
- export type PreviewView = 'web' | 'slides'
13
- export type DocumentLayout = 'preview' | 'split' | 'source'
14
-
15
- export type FileNotice =
16
- | { kind: 'changed', version: string, content: string }
17
- | { kind: 'merged', conflicts: number }
18
- | { kind: 'locked', holder: string }
19
- | { kind: 'failed', message: string }
20
-
21
- export interface OpenFile {
22
- path: string
23
- base: string
24
- version: string
25
- dirty: boolean
26
- notice?: FileNotice
27
- }
28
-
29
- export interface Reveal {
30
- path: string
31
- line: number
32
- endLine?: number
33
- mark?: boolean
34
- seq: number
35
- }
36
-
37
- export interface PageIssue {
38
- level: 'error' | 'warning'
39
- plugin?: string
40
- message: string
41
- }
42
-
43
- export interface WorkbenchState {
44
- locale: Locale
45
- dark: boolean
46
- documents?: DocumentsAnswer
47
- failure?: string
48
- noRoutes?: boolean
49
- document?: string
50
- files?: FilesAnswer
51
- ir?: IrAnswer
52
- open: OpenFile[]
53
- active?: string
54
- openFailure?: string
55
- line: number
56
- reveal?: Reveal
57
- view: PreviewView
58
- preview?: PreviewAnswer
59
- previewFailure?: string
60
- shown?: string
61
- issues: PageIssue[]
62
- picking: boolean
63
- layout: DocumentLayout
64
- focus: FocusReport[]
65
- focusPending: boolean
66
- focusFailure?: string
67
- leaving?: string[]
68
- leaveSaving: boolean
69
- leaveFailed: boolean
70
- review?: ReviewView
71
- changed?: UncommittedFile[]
72
- history?: HistoryRevision[]
73
- changesFailure?: string
74
- }
75
-
76
- export interface EditorBridge {
77
- text(path: string): string | undefined
78
- replace(path: string, text: string, fresh: boolean): void
79
- focus?(): void
80
- }
81
-
82
- export interface PreviewSink {
83
- send(command: PreviewCommand): void
84
- }
85
-
86
- type Listener = () => void
87
-
88
- export function localeOf(tag: string | undefined): Locale {
89
- return tag?.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'
90
- }
91
-
92
- export class Workbench {
93
- readonly client: WorkspaceClient
94
- #state: WorkbenchState
95
- readonly #listeners = new Set<Listener>()
96
- #editor?: EditorBridge
97
- readonly #previews = new Set<PreviewSink>()
98
- readonly #saving = new Set<string>()
99
- #stopEvents?: () => void
100
- #stopReview?: () => void
101
- #changeReads = 0
102
- #revealed = 0
103
- #highlightTimer?: ReturnType<typeof setTimeout>
104
- #highlight: PreviewAnchor | null = null
105
- #focusVersion = 0
106
- #focusWriting: Promise<void> = Promise.resolve()
107
- #leave?: () => void | Promise<void>
108
-
109
- constructor(options: { client?: WorkspaceClient, locale?: Locale, dark?: boolean } = {}) {
110
- this.client = options.client ?? workspaceClient()
111
- this.#state = {
112
- locale: options.locale ?? localeOf(globalThis.navigator?.language),
113
- dark: options.dark ?? false,
114
- open: [],
115
- line: 1,
116
- view: 'web',
117
- issues: [],
118
- picking: false,
119
- layout: 'preview',
120
- focus: [],
121
- focusPending: false,
122
- leaveSaving: false,
123
- leaveFailed: false,
124
- }
125
- }
126
-
127
- get state(): WorkbenchState {
128
- return this.#state
129
- }
130
-
131
- subscribe = (listener: Listener): (() => void) => {
132
- this.#listeners.add(listener)
133
- return () => this.#listeners.delete(listener)
134
- }
135
-
136
- getState = (): WorkbenchState => this.#state
137
-
138
- #set(patch: Partial<WorkbenchState>): void {
139
- this.#state = { ...this.#state, ...patch }
140
- for (const listener of this.#listeners) listener()
141
- }
142
-
143
- #file(path: string | undefined): OpenFile | undefined {
144
- return this.#state.open.find(file => file.path === path)
145
- }
146
-
147
- #update(path: string, patch: Partial<OpenFile>): void {
148
- this.#set({ open: this.#state.open.map(file => (file.path === path ? { ...file, ...patch } : file)) })
149
- }
150
-
151
- setLocale(locale: Locale): void {
152
- if (locale !== this.#state.locale) this.#set({ locale })
153
- }
154
-
155
- setDark(dark: boolean): void {
156
- if (dark === this.#state.dark) return
157
- this.#set({ dark })
158
- this.#broadcast({ type: 'appearance', dark })
159
- }
160
-
161
- async loadDocuments(): Promise<void> {
162
- try {
163
- this.#set({ documents: await this.client.documents(), failure: undefined, noRoutes: false })
164
- } catch (error) {
165
- this.#set({ failure: message(error), noRoutes: error instanceof ServiceError && error.status === 404 })
166
- }
167
- }
168
-
169
- async operate(operation: DocumentOperation): Promise<OperationAnswer> {
170
- const answer = await this.client.operate(operation)
171
- if (operation.op !== 'create' && operation.op !== 'copy' && this.#state.document === operation.dir) this.closeDocument()
172
- await this.loadDocuments()
173
- return answer
174
- }
175
-
176
- async openDocument(dir: string): Promise<void> {
177
- if (this.#state.document === dir) return
178
- if (this.#deferLeave(this.#state.open, () => this.#openDocument(dir))) return
179
- await this.#openDocument(dir)
180
- }
181
-
182
- async #openDocument(dir: string): Promise<void> {
183
- this.#stop()
184
- this.#stopReview = this.client.review(dir, event => this.#reviewed(dir, event))
185
- this.#set({ document: dir, files: undefined, ir: undefined, open: [], active: undefined, line: 1, reveal: undefined, preview: undefined, previewFailure: undefined, shown: undefined, issues: [], focus: [], focusPending: false, focusFailure: undefined, ...NO_CHANGES })
186
- if (dir === '' && this.#state.documents?.single === false) {
187
- this.#stopEvents = undefined
188
- this.#set({ layout: 'source' })
189
- await Promise.all([this.#readFiles(dir), this.#readStatus(dir), this.#readChanges(dir)])
190
- return
191
- }
192
- this.#stopEvents = this.client.events(dir, event => void this.#compiled(dir, event), () => void this.#readIr(dir))
193
- await Promise.all([this.#readFiles(dir), this.#readIr(dir), this.#readPreview(dir), this.#readStatus(dir), this.#readFocus(dir), this.#readChanges(dir)])
194
- }
195
-
196
- closeDocument(): void {
197
- if (this.#deferLeave(this.#state.open, () => this.#closeDocument())) return
198
- this.#closeDocument()
199
- }
200
-
201
- #closeDocument(): void {
202
- this.#stop()
203
- this.#set({ document: undefined, files: undefined, ir: undefined, open: [], active: undefined, reveal: undefined, preview: undefined, shown: undefined, issues: [], focus: [], focusPending: false, focusFailure: undefined, ...NO_CHANGES })
204
- void this.loadDocuments()
205
- }
206
-
207
- async #readFiles(dir: string): Promise<void> {
208
- try {
209
- const files = await this.client.files('')
210
- if (this.#state.document === dir) this.#set({ files })
211
- } catch {
212
- }
213
- }
214
-
215
- async #readFocus(dir: string): Promise<void> {
216
- const version = this.#focusVersion
217
- try {
218
- const answer = await this.client.focus(dir)
219
- if (this.#state.document === dir && version === this.#focusVersion) this.#set({ focus: answer.focus ?? [] })
220
- } catch {
221
- }
222
- }
223
-
224
- async #readIr(dir: string): Promise<void> {
225
- try {
226
- const ir = await this.client.ir(dir)
227
- if (this.#state.document === dir) this.#set({ ir })
228
- } catch {
229
- }
230
- }
231
-
232
- async #readStatus(dir: string): Promise<void> {
233
- try {
234
- const documents = await this.client.documents(dir)
235
- if (this.#state.document === dir) this.#set({ documents: { ...documents, documents: this.#state.documents?.documents ?? documents.documents } })
236
- } catch {
237
- }
238
- }
239
-
240
- async #readChanges(dir: string): Promise<void> {
241
- const read = ++this.#changeReads
242
- try {
243
- const [{ files }, { revisions }] = await Promise.all([this.client.changes(dir), this.client.revisions(dir)])
244
- if (this.#state.document === dir && read === this.#changeReads) this.#set({ changed: files, history: revisions })
245
- } catch {
246
- }
247
- }
248
-
249
- #reviewed(dir: string, event: ReviewFollowed): void {
250
- if (this.#state.document !== dir) return
251
- this.#set({ review: event.review })
252
- if (event.ended) {
253
- void this.#readChanges(dir)
254
- void this.#readStatus(dir)
255
- }
256
- }
257
-
258
- async #act(work: (dir: string) => Promise<unknown>): Promise<boolean> {
259
- const dir = this.#state.document
260
- if (dir === undefined) return false
261
- try {
262
- await work(dir)
263
- this.#set({ changesFailure: undefined })
264
- return true
265
- } catch (error) {
266
- this.#set({ changesFailure: message(error) })
267
- return false
268
- } finally {
269
- void this.#readChanges(dir)
270
- void this.#readStatus(dir)
271
- }
272
- }
273
-
274
- acceptReview(message?: string): Promise<boolean> {
275
- return this.#act(dir => this.client.accept(dir, message ? { message } : {}))
276
- }
277
-
278
- rejectReview(paths?: string[]): Promise<boolean> {
279
- return this.#act(dir => this.client.reject(dir, paths))
280
- }
281
-
282
- restoreReview(step: string): Promise<boolean> {
283
- return this.#act(dir => this.client.restore(dir, step))
284
- }
285
-
286
- commitChanges(message: string): Promise<boolean> {
287
- return this.#act(dir => this.client.commit(dir, message))
288
- }
289
-
290
- discardChanges(): Promise<boolean> {
291
- return this.#act(dir => this.client.discard(dir))
292
- }
293
-
294
- revert(id: string): Promise<boolean> {
295
- return this.#act(async (dir) => {
296
- const answer = await this.client.revert(dir, id)
297
- if (!answer.ok) throw new Error(answer.conflicts.join(', '))
298
- })
299
- }
300
-
301
- async #readPreview(dir: string): Promise<void> {
302
- try {
303
- const preview = await this.client.preview(dir)
304
- if (this.#state.document === dir) this.#set({ preview, previewFailure: undefined })
305
- } catch (error) {
306
- if (this.#state.document === dir) this.#set({ previewFailure: message(error) })
307
- }
308
- }
309
-
310
- async #compiled(dir: string, event: IrEvent): Promise<void> {
311
- if (this.#state.document !== dir) return
312
- if (event.revision !== this.#state.ir?.revision) await this.#readIr(dir)
313
- const changed = this.#state.open.filter(file => event.files.includes(file.path)).map(file => file.path)
314
- if (changed.length > 0) await this.checkDisk(changed)
315
- if (event.files.length > 0) {
316
- void this.#readStatus(dir)
317
- void this.#readChanges(dir)
318
- }
319
- if (event.files.some(file => !this.#state.files?.files.some(listed => listed.path === file))) void this.#readFiles(dir)
320
- }
321
-
322
- bindEditor(editor: EditorBridge): () => void {
323
- this.#editor = editor
324
- return () => {
325
- if (this.#editor === editor) this.#editor = undefined
326
- }
327
- }
328
-
329
- textOf(path: string): string | undefined {
330
- const file = this.#file(path)
331
- return file && (this.#editor?.text(path) ?? file.base)
332
- }
333
-
334
- async openFile(path: string, at?: Omit<Reveal, 'path' | 'seq'>): Promise<boolean> {
335
- const document = this.#state.document
336
- if (document === undefined) return false
337
- if (!this.#file(path)) {
338
- let answer: SourceAnswer
339
- try {
340
- // Files belong to the opened workspace; the selected document owns only its preview and context.
341
- answer = await this.client.source('', path)
342
- } catch (error) {
343
- this.#set({ openFailure: message(error) })
344
- return false
345
- }
346
- if (document !== this.#state.document) return false
347
- if (!this.#file(path)) this.#set({ open: [...this.#state.open, { path, base: answer.content, version: answer.version, dirty: false }] })
348
- }
349
- this.#set({ active: path, openFailure: undefined, ...this.#state.layout === 'preview' ? { layout: 'split' as const } : {}, ...at ? { reveal: { path, ...at, seq: ++this.#revealed } } : {} })
350
- return true
351
- }
352
-
353
- async openSource(document: string, path: string, at?: Omit<Reveal, 'path' | 'seq'>): Promise<void> {
354
- const destination = document === '' ? this.#state.document ?? document : document
355
- const open = async () => {
356
- if (destination !== this.#state.document) await this.#openDocument(destination)
357
- if (destination !== this.#state.document) return
358
- if (this.#file(path)) await this.checkDisk([path])
359
- await this.openFile(path, at ?? { line: this.#state.active === path ? this.#state.line : 1 })
360
- this.#editor?.focus?.()
361
- }
362
- if (destination !== this.#state.document && this.#deferLeave(this.#state.open, open)) return
363
- await open()
364
- }
365
-
366
- closeFile(path: string): void {
367
- if (this.#deferLeave(this.#state.open.filter(file => file.path === path), () => this.#closeFile(path))) return
368
- this.#closeFile(path)
369
- }
370
-
371
- #closeFile(path: string): void {
372
- const index = this.#state.open.findIndex(file => file.path === path)
373
- if (index < 0) return
374
- const open = this.#state.open.filter(file => file.path !== path)
375
- this.#set({ open, ...this.#state.active === path ? { active: open[Math.min(index, open.length - 1)]?.path } : {} })
376
- }
377
-
378
- #deferLeave(files: OpenFile[], leave: () => void | Promise<void>): boolean {
379
- const dirty = files.filter(file => file.dirty).map(file => file.path)
380
- if (!dirty.length) return false
381
- this.#leave = leave
382
- this.#set({ leaving: dirty, leaveSaving: false, leaveFailed: false })
383
- return true
384
- }
385
-
386
- async resolveLeave(choice: 'save' | 'discard' | 'cancel'): Promise<void> {
387
- if (this.#state.leaveSaving) return
388
- const leave = this.#leave
389
- if (choice === 'save') {
390
- this.#set({ leaveSaving: true, leaveFailed: false })
391
- for (const path of this.#state.leaving ?? []) await this.save(path)
392
- if (this.#state.leaving?.some(path => this.#file(path)?.dirty)) {
393
- this.#set({ leaveSaving: false, leaveFailed: true })
394
- return
395
- }
396
- }
397
- this.#leave = undefined
398
- this.#set({ leaving: undefined, leaveSaving: false, leaveFailed: false })
399
- if (choice !== 'cancel') await leave?.()
400
- }
401
-
402
- showFile(path: string): void {
403
- if (this.#file(path)) this.#set({ active: path })
404
- }
405
-
406
- edited(path: string, text: string): void {
407
- const file = this.#file(path)
408
- if (file && file.dirty !== (text !== file.base)) this.#update(path, { dirty: text !== file.base })
409
- }
410
-
411
- async save(path = this.#state.active): Promise<void> {
412
- const file = this.#file(path)
413
- const document = this.#state.document
414
- if (!file || document === undefined || this.#saving.has(file.path)) return
415
- const text = this.textOf(file.path) ?? file.base
416
- this.#saving.add(file.path)
417
- try {
418
- const { version } = await this.client.save('', file.path, text, file.version)
419
- this.#update(file.path, { base: text, version, dirty: this.textOf(file.path) !== text, notice: undefined })
420
- void this.#readStatus(document)
421
- } catch (error) {
422
- const answer = error instanceof ServiceError ? error.answer : undefined
423
- if (answer?.locked) this.#update(file.path, { notice: { kind: 'locked', holder: answer.locked.holder } })
424
- else if (answer?.conflict) this.#update(file.path, { notice: { kind: 'changed', ...answer.conflict } })
425
- else this.#update(file.path, { notice: { kind: 'failed', message: message(error) } })
426
- } finally {
427
- this.#saving.delete(file.path)
428
- }
429
- }
430
-
431
- reload(path: string): void {
432
- const file = this.#file(path)
433
- if (file?.notice?.kind !== 'changed') return
434
- const { content, version } = file.notice
435
- this.#update(path, { base: content, version, dirty: false, notice: undefined })
436
- this.#editor?.replace(path, content, true)
437
- this.#editor?.focus?.()
438
- }
439
-
440
- merge(path: string, labels: MergeLabels): void {
441
- const file = this.#file(path)
442
- if (file?.notice?.kind !== 'changed') return
443
- const { content, version } = file.notice
444
- const merged = mergeTexts(this.textOf(path) ?? file.base, file.base, content, labels)
445
- this.#update(path, { base: content, version, notice: { kind: 'merged', conflicts: merged.conflicts }, dirty: merged.text !== content })
446
- this.#editor?.replace(path, merged.text, false)
447
- this.#editor?.focus?.()
448
- }
449
-
450
- async checkDisk(paths = this.#state.open.map(file => file.path)): Promise<void> {
451
- const document = this.#state.document
452
- if (document === undefined) return
453
- for (const path of paths) {
454
- const file = this.#file(path)
455
- if (!file || this.#saving.has(path)) continue
456
- const known = file.version
457
- let answer: SourceAnswer
458
- try {
459
- answer = await this.client.source('', path)
460
- } catch {
461
- continue
462
- }
463
- const now = this.#file(path)
464
- if (!now || answer.version === now.version || now.version !== known || this.#saving.has(path)) continue
465
- if (!now.dirty) {
466
- this.#update(path, { base: answer.content, version: answer.version, notice: undefined })
467
- this.#editor?.replace(path, answer.content, true)
468
- } else if (now.notice?.kind !== 'locked' && (now.notice?.kind !== 'changed' || now.notice.version !== answer.version)) {
469
- this.#update(path, { notice: { kind: 'changed', version: answer.version, content: answer.content } })
470
- }
471
- }
472
- }
473
-
474
- moved(line: number): void {
475
- if (line !== this.#state.line) this.#set({ line })
476
- clearTimeout(this.#highlightTimer)
477
- this.#highlightTimer = setTimeout(() => {
478
- const doc = this.#state.ir?.doc
479
- const path = this.#state.active
480
- if (!doc || !path) return
481
- const place = placeAt(doc, path, line)
482
- this.#highlight = place ? { section: place.section, ...place.block ? { block: place.block } : {} } : null
483
- this.#broadcast({ type: 'highlight', target: this.#highlight })
484
- }, 150)
485
- }
486
-
487
- async revealPlace(section: string, block?: string): Promise<void> {
488
- const doc = this.#state.ir?.doc
489
- const place = doc && placeOf(doc, section, block)
490
- if (!place) return
491
- await this.openFile(place.source.file, { line: place.source.line, ...place.source.endLine ? { endLine: place.source.endLine } : {}, mark: true })
492
- }
493
-
494
- async revealProblem(finding: Finding): Promise<void> {
495
- if (!finding.source?.file) return
496
- await this.openFile(finding.source.file, { line: finding.source.line ?? 1 })
497
- }
498
-
499
- setView(view: PreviewView): void {
500
- this.#set({ view })
501
- }
502
-
503
- setLayout(layout: DocumentLayout): void {
504
- this.#set({ layout })
505
- }
506
-
507
- selectSource(selection: { startLine: number, endLine: number, text: string }): void {
508
- const { active, document } = this.#state
509
- if (!active || document === undefined) return
510
- if (document === '' && this.#state.documents?.single === false) return
511
- const prefix = document ? `${document}/` : ''
512
- if (prefix && !active.startsWith(prefix)) return
513
- this.setFocus([{ id: 'selection', from: 'source', source: { file: active.slice(prefix.length), startLine: selection.startLine, endLine: selection.endLine }, quote: selection.text.slice(0, 2000) }])
514
- }
515
-
516
- setFocus(focus: FocusReport[]): void {
517
- const document = this.#state.document
518
- if (document === undefined) return
519
- const version = ++this.#focusVersion
520
- this.#set({ focus, focusPending: true, focusFailure: undefined })
521
- this.#broadcast({ type: 'pins', targets: focus.flatMap(item => item.section && item.block ? [{ id: item.id, section: item.section, block: item.block, label: '1' }] : []) })
522
- this.#focusWriting = this.#focusWriting.then(async () => {
523
- if (version !== this.#focusVersion) return
524
- try {
525
- const answer = await this.client.focus(document, focus)
526
- if (version === this.#focusVersion && document === this.#state.document) this.#set({ focus: answer.focus ?? [], focusPending: false })
527
- } catch (error) {
528
- if (version === this.#focusVersion && document === this.#state.document) this.#set({ focusPending: false, focusFailure: message(error) })
529
- }
530
- })
531
- }
532
-
533
- setPicking(picking: boolean): void {
534
- this.#set({ picking })
535
- this.#broadcast({ type: 'pick', on: picking })
536
- }
537
-
538
- attachPreview(sink: PreviewSink): () => void {
539
- this.#previews.add(sink)
540
- sink.send({ type: 'appearance', dark: this.#state.dark })
541
- sink.send({ type: 'pick', on: this.#state.picking })
542
- sink.send({ type: 'highlight', target: this.#highlight })
543
- return () => this.#previews.delete(sink)
544
- }
545
-
546
- previewReport(report: PreviewReport): void {
547
- switch (report.type) {
548
- case 'ready':
549
- this.#set({ shown: report.revision })
550
- break
551
- case 'updated':
552
- this.#set({ shown: report.revision })
553
- break
554
- case 'picked':
555
- {
556
- const section = this.#state.ir?.doc.sections.find(section => section.id === report.section)
557
- const step = section && report.trail ? trailStep(stepKeys(section), report.trail) : report.step
558
- this.setFocus([{ id: 'selection', from: this.#state.view, section: report.section, ...report.block ? { block: report.block } : {}, ...step === undefined ? {} : { step } }])
559
- if (this.#state.layout !== 'preview') void this.revealPlace(report.section, report.block)
560
- }
561
- break
562
- case 'selection':
563
- if (report.selection?.text.trim()) this.setFocus([{ id: 'selection', from: this.#state.view, section: report.selection.section, ...report.selection.block ? { block: report.selection.block } : {}, quote: report.selection.text.slice(0, 2000) }])
564
- break
565
- case 'report':
566
- if (!this.#state.issues.some(issue => issue.message === report.message && issue.plugin === report.plugin)) {
567
- this.#set({ issues: [...this.#state.issues, { level: report.level, ...report.plugin ? { plugin: report.plugin } : {}, message: report.message }] })
568
- }
569
- break
570
- default:
571
- break
572
- }
573
- }
574
-
575
- retryPreview(): void {
576
- if (this.#state.document !== undefined) void this.#readPreview(this.#state.document)
577
- }
578
-
579
- #broadcast(command: PreviewCommand): void {
580
- for (const preview of this.#previews) preview.send(command)
581
- }
582
-
583
- #stop(): void {
584
- this.#stopEvents?.()
585
- this.#stopReview?.()
586
- this.#stopEvents = this.#stopReview = undefined
587
- }
588
-
589
- dispose(): void {
590
- this.#stop()
591
- clearTimeout(this.#highlightTimer)
592
- }
593
- }
594
-
595
- const NO_CHANGES = { review: undefined, changed: undefined, history: undefined, changesFailure: undefined }
596
-
597
- function message(error: unknown): string {
598
- return error instanceof Error ? error.message : String(error)
599
- }