dsh-plugin-notepad 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/lib/client.js ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Notepad browser half.
3
+ *
4
+ * This is a hand-written DSH client bundle: a classic script (not a module, no
5
+ * build step) that only registers a factory. The module system runs the factory
6
+ * on first use, claims the stylesheet it inserts synchronously, and treats the
7
+ * returned `{ apply, inject }` as a Cordis client plugin.
8
+ *
9
+ * The panel is a pair of registrations coupled by one string: an icon in
10
+ * `sidebar.panellist` (id `notepad`) and the center page in the keyed `main`
11
+ * slot (key `notepad`). All data lives behind the host half's own HTTP route
12
+ * under `api/notepad` (document-relative on purpose, matching the shipped
13
+ * client-hmr half).
14
+ *
15
+ * @see module:dsh-plugin-notepad
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-plugin-notepad',
19
+ factory: (require) => {
20
+ var module = { exports: {} }
21
+ var exports = module.exports
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
23
+
24
+ const React = require('react')
25
+ const h = React.createElement
26
+
27
+ /** Panel key shared by the sidebar icon and the center page. */
28
+ const PANEL_ID = 'notepad'
29
+ /** Host route prefix, document-relative so a non-root mount still works. */
30
+ const API = 'api/notepad'
31
+ /** Marker header the host API requires; a cross-site request cannot set it. */
32
+ const CLIENT_HEADER = 'x-notepad-client'
33
+
34
+ const CSS = `
35
+ .notepadPage{box-sizing:border-box;height:100%;overflow:auto;padding:18px 22px 28px;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
36
+ .notepadHead{display:flex;flex-direction:column;gap:10px}
37
+ .notepadTitleRow{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
38
+ .notepadTitle{font-size:15px;font-weight:600}
39
+ .notepadMeta{color:var(--dsw-alias-label-caption,#8b949e);font-size:11px;line-height:16px}
40
+ .notepadToolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
41
+ .notepadTabs{display:inline-flex;border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.35));border-radius:8px;overflow:hidden}
42
+ .notepadTab{border:0;background:0 0;color:var(--dsw-alias-label-secondary,#57606a);font:inherit;padding:5px 12px;cursor:pointer}
43
+ .notepadTab[data-active=true]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));color:var(--dsw-alias-label-primary,#1f2328);font-weight:500}
44
+ .notepadInput,.notepadTextarea,.notepadSelect{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.35));border-radius:8px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.05));color:inherit;font:inherit;padding:6px 9px;outline:none;min-width:0}
45
+ .notepadInput:focus,.notepadTextarea:focus{border-color:var(--dsw-alias-border-l1,rgba(127,127,127,.6))}
46
+ .notepadTextarea{resize:vertical;min-height:60px;width:100%}
47
+ .notepadGrow{flex:1}
48
+ .notepadButton{border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.35));border-radius:8px;background:0 0;color:inherit;font:inherit;padding:5px 12px;cursor:pointer;white-space:nowrap}
49
+ .notepadButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
50
+ .notepadButton:disabled{opacity:.5;cursor:default}
51
+ .notepadButton[data-danger=true]:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,rgba(207,34,46,.12))}
52
+ .notepadComposer{display:flex;flex-direction:column;gap:8px;border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.35));border-radius:10px;padding:10px}
53
+ .notepadList{display:flex;flex-direction:column;gap:8px}
54
+ .notepadNote{border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.35));border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px}
55
+ .notepadNoteText{white-space:pre-wrap;word-break:break-word}
56
+ .notepadNoteFoot{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
57
+ .notepadTag{border-radius:999px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.14));padding:1px 8px;font-size:11px;color:var(--dsw-alias-label-secondary,#57606a)}
58
+ .notepadActions{display:flex;gap:6px;margin-left:auto}
59
+ .notepadState{color:var(--dsw-alias-label-caption,#8b949e);font-size:12px}
60
+ .notepadError{color:var(--dsw-alias-state-error-primary,#cf222e);font-size:12px;white-space:pre-wrap}
61
+ .notepadPath{color:var(--dsw-alias-label-caption,#8b949e);font-size:11px;word-break:break-all}
62
+ `
63
+
64
+ // Styles must be inserted synchronously while the factory runs: the module
65
+ // system claims any untagged <style> present when it returns, and removes it
66
+ // again on hot reload or disable.
67
+ const CSS_TAG = 'dsh-plugin-notepad/client.css'
68
+ if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_TAG)}]`) === null) {
69
+ const style = document.createElement('style')
70
+ style.dataset.plugin = 'dsh-plugin-notepad'
71
+ style.dataset.pluginCss = CSS_TAG
72
+ style.textContent = CSS
73
+ document.head.appendChild(style)
74
+ }
75
+
76
+ /**
77
+ * Call one host API route.
78
+ * @param {string} method - HTTP method.
79
+ * @param {string} path - route path below the API prefix.
80
+ * @param {object} [body] - JSON request body.
81
+ * @returns {Promise<object>} the parsed response payload.
82
+ */
83
+ async function call(method, path, body) {
84
+ const response = await fetch(`${API}${path}`, {
85
+ method,
86
+ headers: { 'content-type': 'application/json', [CLIENT_HEADER]: '1' },
87
+ body: body === undefined ? undefined : JSON.stringify(body),
88
+ })
89
+ let payload
90
+ try {
91
+ payload = await response.json()
92
+ } catch {
93
+ payload = {}
94
+ }
95
+ if (!response.ok) throw new Error(payload?.error ?? `${method} ${path} failed with ${response.status}`)
96
+ return payload
97
+ }
98
+
99
+ /** Merge one level's fresh payload into the panel state. */
100
+ function mergeLevel(state, payload) {
101
+ return { ...state, levels: { ...state.levels, [payload.scope]: payload } }
102
+ }
103
+
104
+ /** One note count for a level, tolerating a disabled level. */
105
+ function totalOf(state, scope) {
106
+ return state?.levels?.[scope]?.enabled === true ? state.levels[scope].total : 0
107
+ }
108
+
109
+ /**
110
+ * The project dropdown's entries: every registered workspace, plus the project
111
+ * the panel is actually addressing when it is not registered. A `<select>`
112
+ * whose `value` matches no option silently displays the first option instead,
113
+ * which would name a different project than the one being edited.
114
+ * @param {Array<{ path: string, title?: string }>} projects - registered workspaces.
115
+ * @param {string | null} current - the project this panel is addressing.
116
+ * @returns {Array<{ path: string, title: string, followed: boolean }>} dropdown entries.
117
+ */
118
+ function projectOptions(projects, current) {
119
+ const entries = projects.map((entry) => ({ path: entry.path, title: entry.title || entry.path, followed: false }))
120
+ if (current !== null && current !== undefined && current !== '' && !entries.some((entry) => entry.path === current)) {
121
+ const name = current.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || current
122
+ entries.unshift({ path: current, title: `${name}(当前会话)`, followed: true })
123
+ }
124
+ return entries
125
+ }
126
+
127
+ /** The sidebar panel icon (receives `{ size, active }`). */
128
+ function NotepadIcon({ size, active }) {
129
+ return h('svg', {
130
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
131
+ strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
132
+ 'aria-hidden': 'true', style: { opacity: active ? 1 : 0.75 },
133
+ },
134
+ h('rect', { x: 4.5, y: 3, width: 15, height: 18, rx: 2.5 }),
135
+ h('path', { d: 'M8 8.5h8M8 12h8M8 15.5h4.5' }))
136
+ }
137
+
138
+ /**
139
+ * The session the page last showed, reported by the session-scoped bridge
140
+ * below. Root-scoped panels receive no session id (neither `PanelInfo` nor
141
+ * the sessions service exposes a "current" selection), so the bridge is how
142
+ * the center page learns which project to open. `cwd` is the session's own
143
+ * working directory — the same value the model tools resolve against.
144
+ */
145
+ const activeSession = { id: null, cwd: null }
146
+
147
+ /** Stand-in selector hook for a host that supplies no `useSessions`. */
148
+ const useNoSessions = (selector) => selector({ byId: {} })
149
+
150
+ /**
151
+ * A zero-size registration in a session-scoped header slot whose only job is
152
+ * to remember the current session and its working directory. It keeps the
153
+ * last value after it unmounts (selecting the notepad panel hides the
154
+ * conversation), which is exactly the session the user was just looking at.
155
+ */
156
+ function NotepadSessionBridge(props) {
157
+ const sessionId = props?.sessionId ?? null
158
+ const useSessions = typeof props?.useSessions === 'function' ? props.useSessions : useNoSessions
159
+ const cwd = useSessions((state) => (sessionId === null ? null : state?.byId?.[sessionId]?.cwd ?? null))
160
+ React.useEffect(() => {
161
+ activeSession.id = sessionId
162
+ activeSession.cwd = cwd ?? null
163
+ }, [sessionId, cwd])
164
+ return h('span', { style: { display: 'none' }, 'aria-hidden': 'true' })
165
+ }
166
+
167
+ /** The `/note` command and model tools write into the same files this page edits. */
168
+ function NotepadPage() {
169
+ const [state, setState] = React.useState(null)
170
+ const [scope, setScope] = React.useState('project')
171
+ const [manual, setManual] = React.useState(null)
172
+ const [query, setQuery] = React.useState('')
173
+ const [draft, setDraft] = React.useState('')
174
+ const [draftTags, setDraftTags] = React.useState('')
175
+ const [editing, setEditing] = React.useState(null)
176
+ const [busy, setBusy] = React.useState(false)
177
+ const [error, setError] = React.useState(null)
178
+ /** Last state payload, readable from callbacks without re-creating them. */
179
+ const stateFromRef = React.useRef(null)
180
+
181
+ /**
182
+ * The project this page addresses: the user's manual pick, else the
183
+ * session's own cwd, else whatever the last state reported, else the most
184
+ * recently updated registered workspace. Only `project` goes on the wire:
185
+ * the route resolves it exactly like a tool call does.
186
+ */
187
+ const targetOf = React.useCallback((override) => {
188
+ const chosen = override === undefined ? manual : override
189
+ if (chosen !== null && chosen !== undefined && chosen !== '') return chosen
190
+ if (activeSession.cwd !== null && activeSession.cwd !== '') return activeSession.cwd
191
+ const known = stateFromRef.current
192
+ if (known?.current?.path) return known.current.path
193
+ return known?.projects?.[0]?.path ?? null
194
+ }, [manual])
195
+
196
+ const load = React.useCallback(async (override) => {
197
+ setBusy(true)
198
+ try {
199
+ const target = targetOf(override)
200
+ const hint = activeSession.id === null ? {} : { session: activeSession.id }
201
+ const params = new URLSearchParams({ limit: '0', ...hint, ...(target === null ? {} : { project: target }) })
202
+ const next = await call('GET', `/state?${params.toString()}`)
203
+ stateFromRef.current = next
204
+ setState(next)
205
+ setScope((current) => (next.levels[current]?.enabled === true ? current : next.defaultScope))
206
+ setError(null)
207
+ } catch (failure) {
208
+ setError(failure.message)
209
+ } finally {
210
+ setBusy(false)
211
+ }
212
+ }, [targetOf])
213
+
214
+ React.useEffect(() => { void load() }, [load])
215
+
216
+ // Follow the session even while the page stays open: a session switch (or
217
+ // the first report from the bridge) reloads against the new project.
218
+ const observedSession = React.useRef(null)
219
+ React.useEffect(() => {
220
+ const timer = setInterval(() => {
221
+ if (manual !== null) return
222
+ if (activeSession.id === observedSession.current) return
223
+ observedSession.current = activeSession.id
224
+ void load()
225
+ }, 1500)
226
+ return () => clearInterval(timer)
227
+ }, [load, manual])
228
+
229
+ const projects = state?.projects ?? []
230
+ const options = projectOptions(projects, state?.current?.path ?? null)
231
+ // The panel always sends an explicit `project`, so the host reports
232
+ // `manual`; whether the page is actually following the session is the
233
+ // client's own state.
234
+ const mode = manual !== null
235
+ ? '手动选择'
236
+ : activeSession.cwd !== null && activeSession.cwd !== ''
237
+ ? '跟随当前会话'
238
+ : state?.current?.source === 'default' ? '默认(最近使用的项目)' : '跟随当前会话'
239
+
240
+ const mutate = async (method, path, body) => {
241
+ setBusy(true)
242
+ try {
243
+ const target = targetOf()
244
+ const payload = await call(method, path, { ...(target === null ? {} : { project: target }), ...body })
245
+ setState((current) => (current === null ? current : mergeLevel(current, payload)))
246
+ setError(null)
247
+ } catch (failure) {
248
+ setError(failure.message)
249
+ } finally {
250
+ setBusy(false)
251
+ }
252
+ }
253
+
254
+ const level = state?.levels?.[scope] ?? null
255
+ const notes = (level?.notes ?? []).filter((note) => {
256
+ if (query.trim() === '') return true
257
+ const needle = query.trim().toLowerCase()
258
+ return note.content.toLowerCase().includes(needle)
259
+ || note.id.toLowerCase().includes(needle)
260
+ || (note.tags ?? []).some((tag) => tag.toLowerCase().includes(needle))
261
+ })
262
+
263
+ const addNote = async () => {
264
+ if (draft.trim() === '') return
265
+ await mutate('POST', '/notes', {
266
+ scope,
267
+ content: draft,
268
+ tags: draftTags.split(',').map((tag) => tag.trim()).filter((tag) => tag !== ''),
269
+ })
270
+ setDraft('')
271
+ setDraftTags('')
272
+ }
273
+
274
+ const saveEdit = async () => {
275
+ if (editing === null) return
276
+ await mutate('PUT', '/notes', {
277
+ scope,
278
+ id: editing.id,
279
+ content: editing.content,
280
+ tags: editing.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag !== ''),
281
+ })
282
+ setEditing(null)
283
+ }
284
+
285
+ const children = []
286
+
287
+ children.push(h('div', { className: 'notepadHead', key: 'head' },
288
+ h('div', { className: 'notepadTitleRow' },
289
+ h('span', { className: 'notepadTitle' }, '速记 Notepad'),
290
+ state === null
291
+ ? null
292
+ : h('span', { className: 'notepadMeta' }, `全局 ${totalOf(state, 'global')} 条 · 项目 ${totalOf(state, 'project')} 条`)),
293
+ h('div', { className: 'notepadToolbar' },
294
+ h('div', { className: 'notepadTabs' },
295
+ ['project', 'global'].map((candidate) => {
296
+ const enabled = state?.levels?.[candidate]?.enabled === true
297
+ return h('button', {
298
+ key: candidate,
299
+ type: 'button',
300
+ className: 'notepadTab',
301
+ 'data-active': String(scope === candidate),
302
+ disabled: !enabled,
303
+ title: enabled ? undefined : '该层级在当前配置下被禁用',
304
+ onClick: () => { setScope(candidate); setEditing(null) },
305
+ }, candidate === 'project' ? '项目' : '全局')
306
+ })),
307
+ options.length > 0 && scope === 'project'
308
+ ? h('select', {
309
+ className: 'notepadSelect',
310
+ value: manual ?? state?.current?.path ?? '',
311
+ title: state?.projectRoot === undefined ? undefined : `项目目录:${state.projectRoot}`,
312
+ onChange: (event) => {
313
+ const chosen = event.target.value
314
+ // Choosing the session's own project means "follow it" again.
315
+ setManual(activeSession.cwd !== null && chosen === activeSession.cwd ? null : chosen)
316
+ setEditing(null)
317
+ },
318
+ }, options.map((entry) => h('option', { key: entry.path, value: entry.path, title: entry.path }, entry.title)))
319
+ : null,
320
+ scope === 'project' && options.length === 0
321
+ ? h('input', {
322
+ className: 'notepadInput notepadGrow',
323
+ placeholder: '项目目录(绝对路径)',
324
+ value: manual ?? '',
325
+ onChange: (event) => { setManual(event.target.value); setEditing(null) },
326
+ })
327
+ : null,
328
+ scope === 'project' && manual !== null
329
+ ? h('button', {
330
+ className: 'notepadButton',
331
+ type: 'button',
332
+ title: '回到跟随当前会话的项目',
333
+ onClick: () => { setManual(null); setEditing(null) },
334
+ }, '跟随会话')
335
+ : null,
336
+ h('input', {
337
+ className: 'notepadInput',
338
+ placeholder: '搜索…',
339
+ value: query,
340
+ onChange: (event) => setQuery(event.target.value),
341
+ }),
342
+ h('button', {
343
+ className: 'notepadButton',
344
+ type: 'button',
345
+ disabled: busy,
346
+ onClick: () => void load(),
347
+ }, busy ? '…' : '刷新'),
348
+ h('button', {
349
+ className: 'notepadButton',
350
+ type: 'button',
351
+ 'data-danger': 'true',
352
+ disabled: busy || level?.enabled !== true || totalOf(state, scope) === 0,
353
+ onClick: () => {
354
+ if (typeof window !== 'undefined' && !window.confirm(`确定要清空${scope === 'project' ? '项目' : '全局'}层的全部笔记吗?`)) return
355
+ void mutate('POST', '/clear', { scope })
356
+ },
357
+ }, '清空')),
358
+ state === null
359
+ ? null
360
+ : h('div', { className: 'notepadMeta' },
361
+ scope === 'project'
362
+ ? `${mode}:${state.projectRoot}`
363
+ : '全局:所有项目共用')))
364
+
365
+ children.push(h('div', { className: 'notepadComposer', key: 'composer' },
366
+ h('textarea', {
367
+ className: 'notepadTextarea',
368
+ placeholder: '记一条…(Ctrl+Enter 或点击「添加」)',
369
+ value: draft,
370
+ onChange: (event) => setDraft(event.target.value),
371
+ onKeyDown: (event) => {
372
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) void addNote()
373
+ },
374
+ }),
375
+ h('div', { className: 'notepadToolbar' },
376
+ h('input', {
377
+ className: 'notepadInput notepadGrow',
378
+ placeholder: '标签,用逗号分隔(可选)',
379
+ value: draftTags,
380
+ onChange: (event) => setDraftTags(event.target.value),
381
+ }),
382
+ h('button', {
383
+ className: 'notepadButton',
384
+ type: 'button',
385
+ disabled: busy || draft.trim() === '' || level?.enabled !== true,
386
+ onClick: () => void addNote(),
387
+ }, `添加到${scope === 'project' ? '项目' : '全局'}`))))
388
+
389
+ if (error !== null) children.push(h('div', { className: 'notepadError', key: 'error' }, error))
390
+ if (level !== null && level.enabled !== true) {
391
+ children.push(h('div', { className: 'notepadState', key: 'disabled' }, '该层级在当前插件配置下被禁用(config.level)。'))
392
+ } else if (notes.length === 0) {
393
+ children.push(h('div', { className: 'notepadState', key: 'empty' }, query.trim() === '' ? '还没有笔记。' : '没有匹配的笔记。'))
394
+ }
395
+
396
+ children.push(h('div', { className: 'notepadList', key: 'list' },
397
+ notes.map((note) => {
398
+ const isEditing = editing !== null && editing.id === note.id
399
+ const rowActions = isEditing
400
+ ? [
401
+ h('button', { key: 'save', className: 'notepadButton', type: 'button', disabled: busy, onClick: () => void saveEdit() }, '保存'),
402
+ h('button', { key: 'cancel', className: 'notepadButton', type: 'button', onClick: () => setEditing(null) }, '取消'),
403
+ ]
404
+ : [
405
+ h('button', {
406
+ key: 'edit',
407
+ className: 'notepadButton',
408
+ type: 'button',
409
+ onClick: () => setEditing({
410
+ id: note.id,
411
+ content: note.content,
412
+ tags: (note.tags ?? []).join(', '),
413
+ }),
414
+ }, '编辑'),
415
+ h('button', {
416
+ key: 'delete',
417
+ className: 'notepadButton',
418
+ type: 'button',
419
+ 'data-danger': 'true',
420
+ disabled: busy,
421
+ onClick: () => void mutate('DELETE', '/notes', { scope, id: note.id }),
422
+ }, '删除'),
423
+ ]
424
+ return h('div', { className: 'notepadNote', key: `${note.scope}:${note.id}` },
425
+ isEditing
426
+ ? h('textarea', {
427
+ className: 'notepadTextarea',
428
+ value: editing.content,
429
+ autoFocus: true,
430
+ onChange: (event) => setEditing({ ...editing, content: event.target.value }),
431
+ onKeyDown: (event) => {
432
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) void saveEdit()
433
+ if (event.key === 'Escape') setEditing(null)
434
+ },
435
+ })
436
+ : h('div', { className: 'notepadNoteText' }, note.content),
437
+ isEditing
438
+ ? h('input', {
439
+ className: 'notepadInput',
440
+ placeholder: '标签,用逗号分隔',
441
+ value: editing.tags,
442
+ onChange: (event) => setEditing({ ...editing, tags: event.target.value }),
443
+ })
444
+ : null,
445
+ h('div', { className: 'notepadNoteFoot' },
446
+ h('span', { className: 'notepadMeta' }, `${note.id} · ${new Date(note.at).toLocaleString()}${note.editedAt ? '(已编辑)' : ''}`),
447
+ ...(note.tags ?? []).map((tag) => h('span', { className: 'notepadTag', key: tag }, tag)),
448
+ h('span', { className: 'notepadActions' }, rowActions)))
449
+ })))
450
+
451
+ if (level?.file !== undefined) {
452
+ children.push(h('div', { className: 'notepadPath', key: 'path' }, `文件:${level.file}`))
453
+ }
454
+
455
+ return h('div', { className: 'notepadPage' }, ...children)
456
+ }
457
+
458
+ /** Client services this plugin waits for. */
459
+ const inject = ['slots']
460
+
461
+ /**
462
+ * Register the session bridge, the sidebar icon, and the center page.
463
+ * @param {object} ctx - client plugin context.
464
+ */
465
+ function apply(ctx) {
466
+ // The `main` slot is keyed by the active sidebar panel id, so both
467
+ // registrations must use the same PANEL_ID.
468
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, NotepadPage))
469
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
470
+ name: 'sidebar.panellist',
471
+ id: PANEL_ID,
472
+ order: 40,
473
+ label: '速记',
474
+ }, NotepadIcon))
475
+ // Session-scoped: renders nothing, reports the current session to the
476
+ // root-scoped page above so it opens that session's project.
477
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
478
+ name: 'conversation.session.header.utilities',
479
+ id: 'notepad-session',
480
+ order: 900,
481
+ }, NotepadSessionBridge))
482
+ }
483
+
484
+ exports.apply = apply
485
+ exports.inject = inject
486
+ exports.NotepadPage = NotepadPage
487
+ exports.NotepadIcon = NotepadIcon
488
+ exports.NotepadSessionBridge = NotepadSessionBridge
489
+ // Exported for tests.
490
+ exports.projectOptions = projectOptions
491
+ // The bridge's report, exported so the follow behavior is testable.
492
+ exports.activeSession = activeSession
493
+ return module.exports
494
+ },
495
+ })