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/LICENSE +21 -0
- package/README.md +296 -2
- package/cordis.patch.yml +9 -0
- package/lib/api.js +251 -0
- package/lib/client.js +495 -0
- package/lib/index.js +428 -0
- package/lib/store.js +508 -0
- package/package.json +52 -4
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
|
+
})
|