@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/editor.ts ADDED
@@ -0,0 +1,223 @@
1
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
2
+ import { markdown } from '@codemirror/lang-markdown'
3
+ import { yaml, yamlFrontmatter } from '@codemirror/lang-yaml'
4
+ import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
5
+ import { lintGutter, setDiagnostics } from '@codemirror/lint'
6
+ import type { Diagnostic } from '@codemirror/lint'
7
+ import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
8
+ import { EditorSelection, EditorState, StateEffect, StateField } from '@codemirror/state'
9
+ import type { Extension } from '@codemirror/state'
10
+ import { Decoration, drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, MatchDecorator, ViewPlugin } from '@codemirror/view'
11
+ import type { DecorationSet, ViewUpdate } from '@codemirror/view'
12
+ import { tags } from '@lezer/highlight'
13
+
14
+ export interface EditorOptions {
15
+ label: string
16
+ save(): void
17
+ changed(path: string, text: string): void
18
+ moved(line: number): void
19
+ selected?(selection: { startLine: number, endLine: number, text: string }): void
20
+ }
21
+
22
+ export interface Problem {
23
+ line: number
24
+ severity: 'error' | 'warning' | 'info'
25
+ message: string
26
+ }
27
+
28
+ export interface SourceEditor {
29
+ show(path: string, text: string): void
30
+ text(path: string): string | undefined
31
+ replace(path: string, text: string, fresh: boolean): void
32
+ reveal(line: number, endLine?: number, mark?: boolean): void
33
+ problems(path: string, problems: Problem[]): void
34
+ paths(): string[]
35
+ forget(path: string): void
36
+ focus(): void
37
+ destroy(): void
38
+ }
39
+
40
+ const MYST = new MatchDecorator({
41
+ regexp: /^(?:`{3,}|:{3,})\{[\w.:-]+\}.*$|^:[\w-]+:(?=\s|$)|^\([^)\s]+\)=\s*$|\{[\w.:-]+\}`[^`]*`/g,
42
+ decoration: match => Decoration.mark({ class: match[0].startsWith('(') ? 'pm-cm-myst-label' : 'pm-cm-myst' }),
43
+ })
44
+ const mystMarks = ViewPlugin.fromClass(class {
45
+ decorations: DecorationSet
46
+ constructor(view: EditorView) {
47
+ this.decorations = MYST.createDeco(view)
48
+ }
49
+
50
+ update(update: ViewUpdate) {
51
+ this.decorations = MYST.updateDeco(update, this.decorations)
52
+ }
53
+ }, { decorations: plugin => plugin.decorations })
54
+
55
+ const HIGHLIGHT = HighlightStyle.define([
56
+ { tag: tags.heading, color: 'var(--pm-color-accent)', fontWeight: '600' },
57
+ { tag: [tags.processingInstruction, tags.meta, tags.contentSeparator, tags.comment, tags.punctuation], color: 'var(--pm-color-muted)' },
58
+ { tag: tags.emphasis, fontStyle: 'italic' },
59
+ { tag: tags.strong, fontWeight: '600' },
60
+ { tag: [tags.link, tags.url], color: 'var(--pm-color-accent)' },
61
+ { tag: tags.monospace, color: 'var(--pm-color-syntax-string)' },
62
+ { tag: [tags.propertyName, tags.definition(tags.propertyName), tags.attributeName, tags.labelName], color: 'var(--pm-color-syntax-key)' },
63
+ { tag: [tags.string, tags.special(tags.string)], color: 'var(--pm-color-syntax-string)' },
64
+ { tag: [tags.keyword, tags.bool, tags.null, tags.number, tags.atom], color: 'var(--pm-color-syntax-keyword)' },
65
+ ])
66
+
67
+ const THEME = EditorView.theme({
68
+ '&': { height: '100%', backgroundColor: 'var(--pm-color-code)', color: 'var(--pm-color-ink)', fontSize: '0.84375rem' },
69
+ '&.cm-focused': { outline: 'none' },
70
+ '.cm-scroller': { fontFamily: 'var(--pm-font-mono)', lineHeight: '1.75' },
71
+ '.cm-content': { caretColor: 'var(--pm-color-ink)', padding: '14px 0' },
72
+ '.cm-line': { padding: '0 14px 0 6px' },
73
+ '.cm-gutters': { backgroundColor: 'var(--pm-color-code)', color: 'var(--pm-color-muted)', border: 'none' },
74
+ '.cm-lineNumbers .cm-gutterElement': { padding: '0 4px 0 12px', minWidth: '2.5em' },
75
+ '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--pm-color-ink)' },
76
+ '.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--pm-color-selected) 55%, transparent)' },
77
+ '&.cm-focused .cm-cursor': { borderLeftColor: 'var(--pm-color-ink)' },
78
+ '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { backgroundColor: 'var(--pm-color-accent-soft)' },
79
+ '.cm-line.pm-cm-picked': { backgroundColor: 'var(--pm-color-selected)', boxShadow: 'inset 2px 0 0 var(--pm-color-accent)' },
80
+ '.pm-cm-myst': { color: 'var(--pm-color-syntax-key)' },
81
+ '.pm-cm-myst-label': { color: 'var(--pm-color-muted)' },
82
+ '.cm-lintRange-error, .cm-lintRange-warning, .cm-lintRange-info': { backgroundImage: 'none', textDecoration: 'underline wavy', textUnderlineOffset: '3px' },
83
+ '.cm-lintRange-error': { textDecorationColor: 'var(--pm-color-danger)' },
84
+ '.cm-lintRange-warning': { textDecorationColor: 'var(--pm-color-warn)' },
85
+ '.cm-lintRange-info': { textDecorationColor: 'var(--pm-color-accent)' },
86
+ '.cm-tooltip': { border: '1px solid var(--pm-color-line)', backgroundColor: 'var(--pm-color-surface)', color: 'var(--pm-color-ink)', borderRadius: 'var(--pm-radius-small)' },
87
+ '.cm-panels': { backgroundColor: 'var(--pm-color-sunk)', color: 'var(--pm-color-ink)' },
88
+ '.cm-searchMatch': { backgroundColor: 'var(--pm-color-warn-soft)' },
89
+ '.cm-matchingBracket, &.cm-focused .cm-matchingBracket': { backgroundColor: 'var(--pm-color-accent-soft)' },
90
+ })
91
+
92
+ const pick = StateEffect.define<{ from: number, to: number } | null>()
93
+ const picked = StateField.define<DecorationSet>({
94
+ create: () => Decoration.none,
95
+ update(marks, transaction) {
96
+ marks = marks.map(transaction.changes)
97
+ for (const effect of transaction.effects) {
98
+ if (!effect.is(pick)) continue
99
+ if (!effect.value) return Decoration.none
100
+ const { doc } = transaction.state
101
+ const lines = []
102
+ for (let n = effect.value.from; n <= effect.value.to && n <= doc.lines; n++) lines.push(Decoration.line({ class: 'pm-cm-picked' }).range(doc.line(n).from))
103
+ return Decoration.set(lines)
104
+ }
105
+ return marks
106
+ },
107
+ provide: field => EditorView.decorations.from(field),
108
+ })
109
+
110
+ function language(path: string): Extension {
111
+ if (/\.(?:md|markdown)$/i.test(path)) return [yamlFrontmatter({ content: markdown() }), mystMarks]
112
+ if (/\.ya?ml$/i.test(path)) return yaml()
113
+ return []
114
+ }
115
+
116
+ export function createEditor(parent: HTMLElement, options: EditorOptions): SourceEditor {
117
+ const states = new Map<string, EditorState>()
118
+ const problems = new Map<string, Problem[]>()
119
+ let current: string | undefined
120
+ const listener = EditorView.updateListener.of((update) => {
121
+ if (!current) return
122
+ if (update.docChanged) options.changed(current, update.state.doc.toString())
123
+ if (update.docChanged || update.selectionSet) options.moved(update.state.doc.lineAt(update.state.selection.main.head).number)
124
+ const selection = update.state.selection.main
125
+ if (update.view.hasFocus && update.selectionSet && !selection.empty) {
126
+ options.selected?.({
127
+ startLine: update.state.doc.lineAt(selection.from).number,
128
+ endLine: update.state.doc.lineAt(Math.max(selection.from, selection.to - 1)).number,
129
+ text: update.state.sliceDoc(selection.from, selection.to),
130
+ })
131
+ }
132
+ })
133
+ const extensions = (path: string): Extension => [
134
+ lineNumbers(),
135
+ highlightActiveLineGutter(),
136
+ highlightActiveLine(),
137
+ history(),
138
+ drawSelection(),
139
+ indentOnInput(),
140
+ bracketMatching(),
141
+ highlightSelectionMatches(),
142
+ lintGutter(),
143
+ picked,
144
+ EditorView.lineWrapping,
145
+ syntaxHighlighting(HIGHLIGHT),
146
+ THEME,
147
+ language(path),
148
+ keymap.of([
149
+ { key: 'Mod-s', preventDefault: true, run: () => {
150
+ options.save()
151
+ return true
152
+ } },
153
+ ...defaultKeymap,
154
+ ...historyKeymap,
155
+ ...searchKeymap,
156
+ ]),
157
+ EditorView.contentAttributes.of({ 'aria-label': options.label }),
158
+ listener,
159
+ ]
160
+ const view = new EditorView({ parent, state: EditorState.create({ doc: '', extensions: [THEME, EditorState.readOnly.of(true)] }) })
161
+
162
+ const diagnose = () => {
163
+ if (!current) return
164
+ const { doc } = view.state
165
+ const diagnostics: Diagnostic[] = (problems.get(current) ?? []).filter(problem => problem.line >= 1 && problem.line <= doc.lines).map((problem) => {
166
+ const line = doc.line(problem.line)
167
+ return { from: line.from, to: line.to, severity: problem.severity, message: problem.message }
168
+ })
169
+ view.dispatch(setDiagnostics(view.state, diagnostics))
170
+ }
171
+
172
+ return {
173
+ show(path, text) {
174
+ if (current === path) return
175
+ if (current) states.set(current, view.state)
176
+ current = path
177
+ view.setState(states.get(path) ?? EditorState.create({ doc: text, extensions: extensions(path) }))
178
+ diagnose()
179
+ options.moved(view.state.doc.lineAt(view.state.selection.main.head).number)
180
+ },
181
+ text(path) {
182
+ return (path === current ? view.state : states.get(path))?.doc.toString()
183
+ },
184
+ replace(path, text, fresh) {
185
+ if (path !== current) {
186
+ states.set(path, EditorState.create({ doc: text, extensions: extensions(path) }))
187
+ return
188
+ }
189
+ if (fresh) {
190
+ const head = Math.min(view.state.selection.main.head, text.length)
191
+ view.setState(EditorState.create({ doc: text, selection: EditorSelection.cursor(head), extensions: extensions(path) }))
192
+ diagnose()
193
+ return
194
+ }
195
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
196
+ },
197
+ reveal(line, endLine, mark) {
198
+ const { doc } = view.state
199
+ const first = Math.min(Math.max(line, 1), doc.lines)
200
+ const last = Math.min(Math.max(endLine ?? first, first), doc.lines)
201
+ const position = doc.line(first).from
202
+ view.dispatch({
203
+ selection: { anchor: position },
204
+ effects: [EditorView.scrollIntoView(position, { y: 'center' }), pick.of(mark ? { from: first, to: last } : null)],
205
+ })
206
+ },
207
+ problems(path, list) {
208
+ problems.set(path, list)
209
+ if (path === current) diagnose()
210
+ },
211
+ paths: () => [...new Set([...states.keys(), ...current ? [current] : []])],
212
+ forget(path) {
213
+ states.delete(path)
214
+ problems.delete(path)
215
+ if (path === current) {
216
+ current = undefined
217
+ view.setState(EditorState.create({ doc: '', extensions: [THEME, EditorState.readOnly.of(true)] }))
218
+ }
219
+ },
220
+ focus: () => view.focus(),
221
+ destroy: () => view.destroy(),
222
+ }
223
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { IconName } from '@lincyaw/design'
2
+ import type { ComponentType } from 'react'
3
+ import type { WorkbenchMessages } from './messages.ts'
4
+ import { ChangesPanel } from './panels/Changes.tsx'
5
+ import { DocumentView } from './panels/DocumentView.tsx'
6
+ import { FilesPanel } from './panels/Files.tsx'
7
+ import { HomePanel } from './panels/Home.tsx'
8
+ import { PreviewPanel } from './panels/Preview.tsx'
9
+ import { ProblemsPanel } from './panels/Problems.tsx'
10
+ import { SourcePanel } from './panels/Source.tsx'
11
+
12
+ export type * from './api.ts'
13
+ export * from './components.tsx'
14
+ export { createEditor } from './editor.ts'
15
+ export type { EditorOptions, Problem, SourceEditor } from './editor.ts'
16
+ export { mergeTexts } from './merge.ts'
17
+ export type { MergeLabels, Merged } from './merge.ts'
18
+ export { MESSAGES } from './messages.ts'
19
+ export type { WorkbenchMessages } from './messages.ts'
20
+ export { placeAt, placeOf } from './places.ts'
21
+ export type { Place } from './places.ts'
22
+ export { connectPreview } from './preview.ts'
23
+ export type { PreviewConnection, PreviewHandlers } from './preview.ts'
24
+ export { useMessages, useWorkbench, useWorkbenchStore, WorkbenchProvider } from './react.tsx'
25
+ export { ServiceError, workspaceClient } from './service.ts'
26
+ export type { WorkspaceClient } from './service.ts'
27
+ export { localeOf, Workbench } from './store.ts'
28
+ export type { DocumentLayout, EditorBridge, FileNotice, Locale, OpenFile, PageIssue, PreviewSink, PreviewView, Reveal, WorkbenchState } from './store.ts'
29
+ export { StatusItems } from './panels/Status.tsx'
30
+ export { ChangesPanel, DocumentView, FilesPanel, HomePanel, PreviewPanel, ProblemsPanel, SourcePanel }
31
+
32
+ export interface PanelDescriptor {
33
+ id: string
34
+ icon: IconName
35
+ title(t: WorkbenchMessages): string
36
+ Component: ComponentType
37
+ }
38
+
39
+ export const PANELS = {
40
+ documents: { id: 'documents', icon: 'document', title: t => t.documents, Component: HomePanel },
41
+ files: { id: 'files', icon: 'folder', title: t => t.files, Component: FilesPanel },
42
+ source: { id: 'source', icon: 'edit', title: t => t.source, Component: SourcePanel },
43
+ problems: { id: 'problems', icon: 'warning', title: t => t.problems, Component: ProblemsPanel },
44
+ preview: { id: 'preview', icon: 'slides', title: t => t.preview, Component: PreviewPanel },
45
+ changes: { id: 'changes', icon: 'diff', title: t => t.changes, Component: ChangesPanel },
46
+ } satisfies Record<string, PanelDescriptor>
package/src/merge.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { diff3Merge } from 'node-diff3'
2
+
3
+ export interface Merged {
4
+ text: string
5
+ conflicts: number
6
+ }
7
+
8
+ export interface MergeLabels {
9
+ mine: string
10
+ theirs: string
11
+ }
12
+
13
+ export function mergeTexts(mine: string, base: string, theirs: string, labels: MergeLabels): Merged {
14
+ const lines: string[] = []
15
+ let conflicts = 0
16
+ for (const region of diff3Merge(mine.split('\n'), base.split('\n'), theirs.split('\n'), { excludeFalseConflicts: true })) {
17
+ if (region.ok) {
18
+ lines.push(...region.ok)
19
+ continue
20
+ }
21
+ const { a, o, b } = region.conflict!
22
+ if (o.length === 0) {
23
+ lines.push(...b, ...a)
24
+ continue
25
+ }
26
+ conflicts++
27
+ lines.push(`<<<<<<< ${labels.mine}`, ...a, '=======', ...b, `>>>>>>> ${labels.theirs}`)
28
+ }
29
+ return { text: lines.join('\n'), conflicts }
30
+ }
@@ -0,0 +1,233 @@
1
+ import type { Locale } from './store.ts'
2
+
3
+ const zh = {
4
+ documents: '文档',
5
+ workspace: '工作区',
6
+ launcher: '启动台',
7
+ search: '搜索文档与命令',
8
+ resize: '拖动调整宽度',
9
+ searchHint: 'Ctrl K',
10
+ noMatch: '没有找到',
11
+ commands: '命令',
12
+ newDocument: '新建文档',
13
+ manage: '管理文档',
14
+ noDocuments: '工作区中还没有文档。',
15
+ notGit: '工作区不是 git 仓库:在其中运行 git init,才能新建、改名、复制与删除文档。',
16
+ single: '这个仓库只有一份文档。',
17
+ noRoutes: '工作台读不到工作区:Presmium 的插件包 @lincyaw/dsh 在“插件”页中被关掉了。从活动栏打开“插件”,把它重新打开,然后重新载入页面。',
18
+ open: '打开',
19
+ more: (title: string) => `${title} 的操作`,
20
+ rename: '改名',
21
+ copy: '复制',
22
+ remove: '删除',
23
+ dir: '目录',
24
+ title: '标题',
25
+ template: '模板',
26
+ create: '新建',
27
+ renameTo: '新的目录',
28
+ copyTo: '副本的目录',
29
+ confirmRemove: (title: string) => `删除文档“${title}”?删除也是一个提交,可以在修改历史中撤销。`,
30
+ cancel: '取消',
31
+ close: '关闭',
32
+ created: (dir: string) => `已新建 ${dir}`,
33
+ renamed: (dir: string) => `已改名为 ${dir}`,
34
+ copied: (dir: string) => `已复制为 ${dir}`,
35
+ removed: (dir: string) => `已删除 ${dir}`,
36
+ sections: (n: number) => `${n} 节`,
37
+ mcp: '连接外部 agent(MCP)',
38
+ mcpHint: 'Claude Code、Codex 等经 MCP 使用与这里相同的工具;令牌在服务的状态文件中。',
39
+ copyAddress: '复制',
40
+ copiedAddress: '已复制',
41
+ files: '文件',
42
+ filesOf: (title: string) => `${title} 的文件`,
43
+ noFiles: '没有文件。',
44
+ truncated: '文件太多,只列出了一部分。',
45
+ backToDocuments: '回到文档',
46
+ source: '源码',
47
+ editor: (path: string) => `源码:${path}`,
48
+ noFile: '在文件树中选一个文件打开。',
49
+ openFailed: (reason: string) => `打不开:${reason}`,
50
+ save: '保存',
51
+ unsaved: '未保存',
52
+ saveChanges: '保存未完成的修改?',
53
+ unsavedFiles: '以下文件有未保存的修改。',
54
+ discardChanges: '不保存',
55
+ saveBeforeLeavingFailed: '修改未能全部保存,文件仍保留在编辑器中。取消后可查看保存错误或处理冲突。',
56
+ closeFile: (name: string) => `关闭 ${name}`,
57
+ changedOnDisk: (name: string) => `${name} 在打开之后被别处改过,编辑器中也有改动。`,
58
+ merge: '合并',
59
+ reload: '重新载入',
60
+ merged: (n: number) => (n === 0 ? '已合并,保存即写入。' : `已合并,有 ${n} 处冲突标在文中,解决后保存。`),
61
+ mergeMine: '编辑器中的',
62
+ mergeTheirs: '磁盘上的',
63
+ locked: '文档正被 agent 的修改占用,审阅结束后再保存。',
64
+ saveFailed: (reason: string) => `没能保存:${reason}`,
65
+ problems: '问题',
66
+ noProblems: '没有问题。',
67
+ severity: { error: '错误', warning: '警告', info: '提示' } as Record<string, string>,
68
+ page: '页面',
69
+ preview: '预览',
70
+ documentLayout: '文档显示方式',
71
+ split: '并排',
72
+ focus: '对话关注内容',
73
+ focusSaving: '正在同步关注内容…',
74
+ clearFocus: '清除关注内容',
75
+ retryFocus: '重新同步',
76
+ focusFailed: (reason: string) => `关注内容未同步到对话:${reason}`,
77
+ views: { web: '文档', slides: '幻灯片' },
78
+ view: '预览的形态',
79
+ pick: '点选',
80
+ pickHint: '点选内容供对话参考;并排时同时定位源码',
81
+ reloadPreview: '重新载入预览',
82
+ previewTitle: (title: string) => `${title} 的预览`,
83
+ previewFailed: (reason: string) => `预览不可用:${reason}`,
84
+ previewLoading: '预览载入中…',
85
+ noSlides: '这份文稿没有幻灯片形态。',
86
+ versionMismatch: '预览的版本与工作台不符,请重新启动。',
87
+ branch: (name: string) => `分支 ${name}`,
88
+ noGit: '不是 git 仓库',
89
+ uncommitted: (n: number) => `${n} 处未提交`,
90
+ line: (n: number) => `第 ${n} 行`,
91
+ encoding: 'UTF-8',
92
+ language: (lang: string) => `语言 ${lang}`,
93
+ changes: '修改',
94
+ review: 'AI 的修改',
95
+ reviewRunning: 'agent 还在修改……',
96
+ reviewWaiting: (n: number) => `${n} 个文件等待审阅,接受之后成为一个提交。`,
97
+ rejectFile: (path: string) => `撤回 ${path}`,
98
+ checkpoints: '检查点',
99
+ restore: '回到这里',
100
+ accept: '接受',
101
+ rejectAll: '全部撤回',
102
+ optionalMessage: '提交说明(可选)',
103
+ uncommittedFiles: '未提交的修改',
104
+ noUncommitted: '没有未提交的修改。',
105
+ commitMessage: '提交说明',
106
+ commit: '提交',
107
+ discard: '放弃',
108
+ confirmDiscard: '放弃全部未提交的修改?文件回到上次提交时的样子,不能撤销。',
109
+ history: '修改历史',
110
+ noHistory: '还没有提交。',
111
+ revert: '撤销',
112
+ reverted: '已撤销',
113
+ origins: { agent: 'AI', editor: '编辑器', document: '文档' } as Record<string, string>,
114
+ changesFailed: (reason: string) => `没能完成:${reason}`,
115
+ }
116
+
117
+ export type WorkbenchMessages = typeof zh
118
+
119
+ const en: WorkbenchMessages = {
120
+ documents: 'Documents',
121
+ workspace: 'Workspace',
122
+ launcher: 'Launcher',
123
+ search: 'Search documents and commands',
124
+ resize: 'Resize panels',
125
+ searchHint: 'Ctrl K',
126
+ noMatch: 'Nothing found',
127
+ commands: 'Commands',
128
+ newDocument: 'New document',
129
+ manage: 'Manage documents',
130
+ noDocuments: 'The workspace has no documents yet.',
131
+ notGit: 'The workspace is not a git repository: run git init in it to create, rename, copy and delete documents.',
132
+ single: 'This repository is one document.',
133
+ noRoutes: 'The workbench cannot reach the workspace: Presmium\'s bundle @lincyaw/dsh was turned off in the Plugins page. Open Plugins in the activity bar, turn it on again, then reload the page.',
134
+ open: 'Open',
135
+ more: (title: string) => `Actions of ${title}`,
136
+ rename: 'Rename',
137
+ copy: 'Copy',
138
+ remove: 'Delete',
139
+ dir: 'Directory',
140
+ title: 'Title',
141
+ template: 'Template',
142
+ create: 'Create',
143
+ renameTo: 'New directory',
144
+ copyTo: 'Directory of the copy',
145
+ confirmRemove: (title: string) => `Delete the document “${title}”? The deletion is a commit too, which the history can undo.`,
146
+ cancel: 'Cancel',
147
+ close: 'Close',
148
+ created: (dir: string) => `Created ${dir}`,
149
+ renamed: (dir: string) => `Renamed to ${dir}`,
150
+ copied: (dir: string) => `Copied to ${dir}`,
151
+ removed: (dir: string) => `Deleted ${dir}`,
152
+ sections: (n: number) => `${n} sections`,
153
+ mcp: 'Connect an outside agent (MCP)',
154
+ mcpHint: 'Claude Code, Codex and others use the same tools as here over MCP; the token is in the service\'s state file.',
155
+ copyAddress: 'Copy',
156
+ copiedAddress: 'Copied',
157
+ files: 'Files',
158
+ filesOf: (title: string) => `Files of ${title}`,
159
+ noFiles: 'No files.',
160
+ truncated: 'Too many files: the list stops here.',
161
+ backToDocuments: 'Back to the documents',
162
+ source: 'Source',
163
+ editor: (path: string) => `Source: ${path}`,
164
+ noFile: 'Pick a file in the tree to open it.',
165
+ openFailed: (reason: string) => `Cannot open: ${reason}`,
166
+ save: 'Save',
167
+ unsaved: 'Unsaved',
168
+ saveChanges: 'Save your changes?',
169
+ unsavedFiles: 'These files have unsaved changes.',
170
+ discardChanges: 'Don’t save',
171
+ saveBeforeLeavingFailed: 'Some changes could not be saved. Your files remain open. Cancel to inspect the save error or resolve conflicts.',
172
+ closeFile: (name: string) => `Close ${name}`,
173
+ changedOnDisk: (name: string) => `${name} changed elsewhere since it was opened, and it has changes in the editor.`,
174
+ merge: 'Merge',
175
+ reload: 'Reload',
176
+ merged: (n: number) => (n === 0 ? 'Merged: saving writes it.' : `Merged with ${n} conflict${n === 1 ? '' : 's'} marked in the text: resolve, then save.`),
177
+ mergeMine: 'in the editor',
178
+ mergeTheirs: 'on disk',
179
+ locked: 'An agent\'s change holds the document: save once its review is over.',
180
+ saveFailed: (reason: string) => `Not saved: ${reason}`,
181
+ problems: 'Problems',
182
+ noProblems: 'No problems.',
183
+ severity: { error: 'Error', warning: 'Warning', info: 'Note' },
184
+ page: 'Page',
185
+ preview: 'Preview',
186
+ documentLayout: 'Document layout',
187
+ split: 'Side by side',
188
+ focus: 'Context for the conversation',
189
+ focusSaving: 'Syncing conversation context…',
190
+ clearFocus: 'Clear conversation context',
191
+ retryFocus: 'Retry sync',
192
+ focusFailed: (reason: string) => `Conversation context was not synced: ${reason}`,
193
+ views: { web: 'Document', slides: 'Slides' },
194
+ view: 'Form of the preview',
195
+ pick: 'Pick',
196
+ pickHint: 'Pick conversation context; side by side also locates its source',
197
+ reloadPreview: 'Reload the preview',
198
+ previewTitle: (title: string) => `Preview of ${title}`,
199
+ previewFailed: (reason: string) => `No preview: ${reason}`,
200
+ previewLoading: 'Loading the preview…',
201
+ noSlides: 'This document has no slides.',
202
+ versionMismatch: 'The preview speaks another version than the workbench: restart it.',
203
+ branch: (name: string) => `Branch ${name}`,
204
+ noGit: 'Not a git repository',
205
+ uncommitted: (n: number) => `${n} not committed`,
206
+ line: (n: number) => `Line ${n}`,
207
+ encoding: 'UTF-8',
208
+ language: (lang: string) => `Language ${lang}`,
209
+ changes: 'Changes',
210
+ review: 'The AI\'s change',
211
+ reviewRunning: 'The agent is still changing files…',
212
+ reviewWaiting: (n: number) => `${n} file${n === 1 ? '' : 's'} waiting for review; accepted, the change is one commit.`,
213
+ rejectFile: (path: string) => `Take back ${path}`,
214
+ checkpoints: 'Checkpoints',
215
+ restore: 'Back to here',
216
+ accept: 'Accept',
217
+ rejectAll: 'Take back all',
218
+ optionalMessage: 'Commit message (optional)',
219
+ uncommittedFiles: 'Not committed',
220
+ noUncommitted: 'No changes to commit.',
221
+ commitMessage: 'Commit message',
222
+ commit: 'Commit',
223
+ discard: 'Discard',
224
+ confirmDiscard: 'Discard every change not committed? The files go back to the last commit; this cannot be undone.',
225
+ history: 'History',
226
+ noHistory: 'No commits yet.',
227
+ revert: 'Undo',
228
+ reverted: 'Undone',
229
+ origins: { agent: 'AI', editor: 'Editor', document: 'Document' },
230
+ changesFailed: (reason: string) => `Not done: ${reason}`,
231
+ }
232
+
233
+ export const MESSAGES: Record<Locale, WorkbenchMessages> = { 'zh-CN': zh, 'en': en }
@@ -0,0 +1,128 @@
1
+ import { useState } from 'react'
2
+ import type { FormEvent } from 'react'
3
+ import { PmBadge, PmButton, PmDialog, PmIconButton, PmNotice } from '../components.tsx'
4
+ import { useMessages, useWorkbench, useWorkbenchStore } from '../react.tsx'
5
+
6
+ function Counts({ added, removed }: { added?: number, removed?: number }) {
7
+ return (
8
+ <span className="pmw-changes__counts">
9
+ {added !== undefined && <span data-sign="added">+{added}</span>}
10
+ {removed !== undefined && <span data-sign="removed">−{removed}</span>}
11
+ </span>
12
+ )
13
+ }
14
+
15
+ export function ChangesPanel() {
16
+ const t = useMessages()
17
+ const workbench = useWorkbenchStore()
18
+ const locale = useWorkbench(state => state.locale)
19
+ const review = useWorkbench(state => state.review)
20
+ const changed = useWorkbench(state => state.changed)
21
+ const history = useWorkbench(state => state.history)
22
+ const failure = useWorkbench(state => state.changesFailure)
23
+ const dir = useWorkbench(state => state.document)
24
+ const short = (path: string) => (dir && path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path)
25
+ const [reviewMessage, setReviewMessage] = useState('')
26
+ const [commitMessage, setCommitMessage] = useState('')
27
+ const [discarding, setDiscarding] = useState(false)
28
+ const pending = new Set(review?.changes.map(change => change.path))
29
+ const own = changed?.filter(file => !pending.has(file.path))
30
+ const submit = (work: () => Promise<boolean>, done: () => void) => async (event: FormEvent) => {
31
+ event.preventDefault()
32
+ if (await work()) done()
33
+ }
34
+ return (
35
+ <section className="pmw-changes" aria-label={t.changes}>
36
+ {failure && <PmNotice tone="danger">{t.changesFailed(failure)}</PmNotice>}
37
+ {review && (
38
+ <section className="pmw-changes__group" aria-label={t.review} data-group="review">
39
+ <h2 className="pmw-changes__head">{t.review}<PmBadge tone="accent">{review.changes.length}</PmBadge></h2>
40
+ <p className="pmw-muted">{review.running ? t.reviewRunning : t.reviewWaiting(review.changes.length)}</p>
41
+ <ul className="pmw-changes__list">
42
+ {review.changes.map(change => (
43
+ <li key={change.path} className="pmw-changes__file">
44
+ <details>
45
+ <summary><span className="pmw-changes__path" data-kind={change.kind} title={change.path}>{short(change.path)}</span><Counts added={change.added} removed={change.removed} /></summary>
46
+ {change.diff && <pre className="pmw-changes__diff">{change.diff}</pre>}
47
+ </details>
48
+ <PmIconButton icon="undo" small label={t.rejectFile(short(change.path))} disabled={review.running} onClick={() => void workbench.rejectReview([change.path])} />
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ {review.steps.length > 1 && (
53
+ <details className="pmw-changes__steps">
54
+ <summary>{t.checkpoints}</summary>
55
+ <ol>
56
+ {review.steps.map(step => (
57
+ <li key={step.id}>
58
+ <span>{step.label}</span>
59
+ <PmButton small disabled={review.running} onClick={() => void workbench.restoreReview(step.id)}>{t.restore}</PmButton>
60
+ </li>
61
+ ))}
62
+ </ol>
63
+ </details>
64
+ )}
65
+ <form className="pmw-changes__form" onSubmit={submit(() => workbench.acceptReview(reviewMessage.trim() || undefined), () => setReviewMessage(''))}>
66
+ <input className="pm-ui-input" aria-label={t.optionalMessage} placeholder={t.optionalMessage} value={reviewMessage} onChange={event => setReviewMessage(event.target.value)} />
67
+ <PmButton type="submit" variant="primary" icon="check" disabled={review.running}>{t.accept}</PmButton>
68
+ <PmButton variant="danger" disabled={review.running} onClick={() => void workbench.rejectReview()}>{t.rejectAll}</PmButton>
69
+ </form>
70
+ </section>
71
+ )}
72
+ <section className="pmw-changes__group" aria-label={t.uncommittedFiles} data-group="uncommitted">
73
+ <h2 className="pmw-changes__head">{t.uncommittedFiles}{own?.length ? <PmBadge>{own.length}</PmBadge> : null}</h2>
74
+ {own?.length === 0 && <p className="pmw-muted">{t.noUncommitted}</p>}
75
+ {own && own.length > 0 && (
76
+ <>
77
+ <ul className="pmw-changes__list">
78
+ {own.map(file => (
79
+ <li key={file.path} className="pmw-changes__file">
80
+ <span className="pmw-changes__path" data-kind={file.kind} title={file.path}>{short(file.path)}</span>
81
+ <Counts added={file.added} removed={file.removed} />
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ <form className="pmw-changes__form" onSubmit={submit(() => workbench.commitChanges(commitMessage.trim()), () => setCommitMessage(''))}>
86
+ <input className="pm-ui-input" required aria-label={t.commitMessage} placeholder={t.commitMessage} value={commitMessage} onChange={event => setCommitMessage(event.target.value)} />
87
+ <PmButton type="submit" variant="primary" disabled={Boolean(review)}>{t.commit}</PmButton>
88
+ <PmButton disabled={Boolean(review)} onClick={() => setDiscarding(true)}>{t.discard}</PmButton>
89
+ </form>
90
+ </>
91
+ )}
92
+ </section>
93
+ <section className="pmw-changes__group" aria-label={t.history} data-group="history">
94
+ <h2 className="pmw-changes__head">{t.history}</h2>
95
+ {history?.length === 0 && <p className="pmw-muted">{t.noHistory}</p>}
96
+ <ol className="pmw-changes__list">
97
+ {history?.map(revision => (
98
+ <li key={revision.id} className="pmw-changes__revision" data-revision={revision.id}>
99
+ <span className="pmw-changes__subject">{revision.message.split('\n')[0]}</span>
100
+ <span className="pmw-muted pmw-changes__meta">
101
+ <PmBadge>{t.origins[revision.origin.kind] ?? revision.origin.kind}</PmBadge>
102
+ {revision.author.replace(/\s*<[^>]*>$/, '')} · {new Date(revision.date).toLocaleString(locale, { dateStyle: 'short', timeStyle: 'short' })}
103
+ </span>
104
+ {revision.revertedBy
105
+ ? <span className="pmw-muted">{t.reverted}</span>
106
+ : <PmButton small icon="undo" onClick={() => void workbench.revert(revision.id)}>{t.revert}</PmButton>}
107
+ </li>
108
+ ))}
109
+ </ol>
110
+ </section>
111
+ <PmDialog open={discarding} label={t.discard} closeLabel={t.close} onClose={() => setDiscarding(false)}>
112
+ <p>{t.confirmDiscard}</p>
113
+ <div className="pmw-operation__actions">
114
+ <PmButton onClick={() => setDiscarding(false)}>{t.cancel}</PmButton>
115
+ <PmButton
116
+ variant="danger"
117
+ onClick={() => {
118
+ setDiscarding(false)
119
+ void workbench.discardChanges()
120
+ }}
121
+ >
122
+ {t.discard}
123
+ </PmButton>
124
+ </div>
125
+ </PmDialog>
126
+ </section>
127
+ )
128
+ }