@lincyaw/workbench 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +34 -2
- package/package.json +63 -3
- package/src/api.ts +126 -0
- package/src/components.tsx +174 -0
- package/src/editor.ts +223 -0
- package/src/index.ts +46 -0
- package/src/merge.ts +30 -0
- package/src/messages.ts +233 -0
- package/src/panels/Changes.tsx +128 -0
- package/src/panels/DocumentView.tsx +50 -0
- package/src/panels/Files.tsx +73 -0
- package/src/panels/Home.tsx +224 -0
- package/src/panels/Preview.tsx +68 -0
- package/src/panels/Problems.tsx +42 -0
- package/src/panels/Source.tsx +118 -0
- package/src/panels/Status.tsx +26 -0
- package/src/places.ts +34 -0
- package/src/preview.ts +59 -0
- package/src/react.tsx +59 -0
- package/src/service.ts +82 -0
- package/src/store.ts +599 -0
- package/src/styles.css +629 -0
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
|
+
}
|