@lincyaw/workbench 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/preview.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { isPreviewEnvelope, PREVIEW_PARAM, PREVIEW_PROTOCOL, previewEnvelope, readPreviewReport } from '@lincyaw/kernel'
2
+ import type { PreviewCommand, PreviewReport } from '@lincyaw/kernel'
3
+
4
+ export interface PreviewHandlers {
5
+ report(report: PreviewReport): void
6
+ mismatch?(version: number): void
7
+ }
8
+
9
+ export interface PreviewConnection {
10
+ readonly src: string
11
+ readonly nonce: string
12
+ readonly ready: boolean
13
+ send(command: PreviewCommand): void
14
+ dispose(): void
15
+ }
16
+
17
+ function nonce(): string {
18
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
19
+ }
20
+
21
+ export function connectPreview(frame: () => HTMLIFrameElement | null, address: string, handlers: PreviewHandlers): PreviewConnection {
22
+ const url = new URL(address)
23
+ const own = nonce()
24
+ url.searchParams.set(PREVIEW_PARAM, own)
25
+ const origin = url.origin
26
+ const latest = new Map<string, PreviewCommand>()
27
+ let ready = false
28
+ const post = (command: PreviewCommand) => frame()?.contentWindow?.postMessage(previewEnvelope(own, command), origin)
29
+ const listener = (event: MessageEvent) => {
30
+ const window_ = frame()?.contentWindow
31
+ if (!window_ || event.source !== window_ || event.origin !== origin) return
32
+ if (isPreviewEnvelope(event.data, own) && event.data.version !== PREVIEW_PROTOCOL) {
33
+ handlers.mismatch?.(event.data.version)
34
+ return
35
+ }
36
+ const report = readPreviewReport(event.data, own)
37
+ if (!report) return
38
+ if (report.type === 'ready') {
39
+ ready = true
40
+ for (const command of latest.values()) post(command)
41
+ }
42
+ handlers.report(report)
43
+ }
44
+ window.addEventListener('message', listener)
45
+ return {
46
+ src: url.href,
47
+ nonce: own,
48
+ get ready() {
49
+ return ready
50
+ },
51
+ send(command) {
52
+ latest.set(command.type, command)
53
+ if (ready) post(command)
54
+ },
55
+ dispose() {
56
+ window.removeEventListener('message', listener)
57
+ },
58
+ }
59
+ }
package/src/react.tsx ADDED
@@ -0,0 +1,59 @@
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 ADDED
@@ -0,0 +1,82 @@
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
+ }