@fonlan/dsh-quick-commands 0.1.5 → 0.1.7
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 +182 -52
- package/lib/client.js.map +1 -1
- package/lib/index.js +48 -23
- package/lib/types/index.d.ts +4 -2
- package/lib/types/server/settings.d.ts +14 -4
- package/lib/types/shared/contract.d.ts +7 -0
- package/package.json +17 -16
- package/src/client/header.tsx +15 -12
- package/src/client/icons.tsx +106 -0
- package/src/client/index.tsx +15 -14
- package/src/client/locales.ts +7 -7
- package/src/client/quick-commands.css +1 -191
- package/src/client/settings-section.css +163 -0
- package/src/client/settings-section.tsx +192 -0
- package/src/index.ts +11 -4
- package/src/server/settings.ts +74 -29
- package/src/shared/contract.ts +10 -0
- package/src/client/settings-card.tsx +0 -209
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-commands settings page (设置 → 侧栏「快捷命令」).
|
|
3
|
+
*
|
|
4
|
+
* Registers into the "settings.section" list slot, so the page owns one entry
|
|
5
|
+
* in the settings sidebar and renders in the settings panel's content column.
|
|
6
|
+
* The header is static (title + one-line summary) over the always-visible
|
|
7
|
+
* body: the workspace / command-set editor and the popup anchor preference.
|
|
8
|
+
* Body edits instant-save per change; partial rows (empty name/command)
|
|
9
|
+
* persist while the user types.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
12
|
+
import { PlusIcon, TrashIcon } from './icons'
|
|
13
|
+
import { quickApi } from './api'
|
|
14
|
+
import { anchorOf, type QuickRosterEntry, type QuickCommandsSettings } from '../shared/contract'
|
|
15
|
+
import './settings-section.css'
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
t: (key: string) => string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RosterState {
|
|
22
|
+
loaded: boolean
|
|
23
|
+
entries: QuickRosterEntry[]
|
|
24
|
+
anchor: QuickCommandsSettings['popupAnchor']
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type CommandDraft = { name: string; command: string }
|
|
28
|
+
|
|
29
|
+
export function QuickCommandsSettingsSection({ t }: Props): JSX.Element {
|
|
30
|
+
const [state, setState] = useState<RosterState>({ loaded: false, entries: [], anchor: 'corner' })
|
|
31
|
+
const [error, setError] = useState<string | null>(null)
|
|
32
|
+
// Guard concurrent saves: the last write wins via a monotonically bumped token.
|
|
33
|
+
const saveToken = useRef(0)
|
|
34
|
+
|
|
35
|
+
const load = async (): Promise<void> => {
|
|
36
|
+
try {
|
|
37
|
+
const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
|
|
38
|
+
setState({ loaded: true, entries, anchor: anchorOf(settings.popupAnchor) })
|
|
39
|
+
setError(null)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
setState((s) => ({ ...s, loaded: true }))
|
|
42
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
void load()
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
/** Instant-save the workspace's whole command set; optimistic UI. */
|
|
51
|
+
const saveCommands = async (workspaceId: string, commands: CommandDraft[]): Promise<void> => {
|
|
52
|
+
const token = ++saveToken.current
|
|
53
|
+
try {
|
|
54
|
+
const next = await quickApi.settingsSetCommands(workspaceId, commands)
|
|
55
|
+
if (token !== saveToken.current) return
|
|
56
|
+
setState((s) => ({
|
|
57
|
+
...s,
|
|
58
|
+
anchor: next.popupAnchor,
|
|
59
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
60
|
+
}))
|
|
61
|
+
setError(null)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
64
|
+
// Re-sync to server truth on failure.
|
|
65
|
+
void load()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compute the next command list for a workspace, THEN save + update state. */
|
|
70
|
+
const mutateCommands = (workspaceId: string, next: (current: CommandDraft[]) => CommandDraft[]): void => {
|
|
71
|
+
const entry = state.entries.find((e) => e.workspaceId === workspaceId)
|
|
72
|
+
if (entry === undefined) return
|
|
73
|
+
const commands = next(entry.commands)
|
|
74
|
+
// Optimistic update first (row stays visible immediately), then persist.
|
|
75
|
+
setState((s) => ({
|
|
76
|
+
...s,
|
|
77
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
78
|
+
}))
|
|
79
|
+
void saveCommands(workspaceId, commands)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const patchEntry = (workspaceId: string, index: number, field: 'name' | 'command', value: string): void => {
|
|
83
|
+
mutateCommands(workspaceId, (current) =>
|
|
84
|
+
current.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const addCommand = (workspaceId: string): void => {
|
|
89
|
+
mutateCommands(workspaceId, (current) => [...current, { name: '', command: '' }])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const removeCommand = (workspaceId: string, index: number): void => {
|
|
93
|
+
mutateCommands(workspaceId, (current) => current.filter((_, i) => i !== index))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const setAnchor = async (anchor: QuickCommandsSettings['popupAnchor']): Promise<void> => {
|
|
97
|
+
setState((s) => ({ ...s, anchor }))
|
|
98
|
+
try {
|
|
99
|
+
const next = await quickApi.settingsSetAnchor(anchor)
|
|
100
|
+
setState((s) => ({ ...s, anchor: anchorOf(next.popupAnchor) }))
|
|
101
|
+
} catch (e) {
|
|
102
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
103
|
+
void load()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const title = t('cardTitle')
|
|
108
|
+
const description = t('sectionSub')
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="qc-page">
|
|
112
|
+
<header className="qc-page-head">
|
|
113
|
+
<h3 className="qc-page-title">{title}</h3>
|
|
114
|
+
<p className="qc-page-sub">{description}</p>
|
|
115
|
+
</header>
|
|
116
|
+
<div className="qc-settings-body">
|
|
117
|
+
<p className="qc-settings-intro">{t('cardIntro')}</p>
|
|
118
|
+
|
|
119
|
+
<div className="qc-settings-anchor">
|
|
120
|
+
<span className="qc-settings-anchor-label">{t('cardAnchor')}</span>
|
|
121
|
+
<label className="qc-settings-radio">
|
|
122
|
+
<input
|
|
123
|
+
type="radio"
|
|
124
|
+
name="qc-anchor"
|
|
125
|
+
checked={state.anchor === 'corner'}
|
|
126
|
+
onChange={() => void setAnchor('corner')}
|
|
127
|
+
/>
|
|
128
|
+
{t('cardAnchorCorner')}
|
|
129
|
+
</label>
|
|
130
|
+
<label className="qc-settings-radio">
|
|
131
|
+
<input
|
|
132
|
+
type="radio"
|
|
133
|
+
name="qc-anchor"
|
|
134
|
+
checked={state.anchor === 'button'}
|
|
135
|
+
onChange={() => void setAnchor('button')}
|
|
136
|
+
/>
|
|
137
|
+
{t('cardAnchorButton')}
|
|
138
|
+
</label>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{error !== null && <div className="qc-settings-error">{t('cardSaveError')}: {error}</div>}
|
|
142
|
+
|
|
143
|
+
{state.loaded && state.entries.length === 0 && (
|
|
144
|
+
<p className="qc-settings-empty">{t('cardNoWorkspaces')}</p>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{state.entries.map((entry) => (
|
|
148
|
+
<section key={entry.workspaceId} className="qc-settings-workspace">
|
|
149
|
+
<header className="qc-settings-workspace-head">
|
|
150
|
+
<span className="qc-settings-workspace-title">{entry.title || entry.path || entry.workspaceId}</span>
|
|
151
|
+
<span className="qc-settings-workspace-path">{entry.path}</span>
|
|
152
|
+
</header>
|
|
153
|
+
{entry.commands.length === 0 && (
|
|
154
|
+
<p className="qc-settings-no-commands">{t('cardNoCommands')}</p>
|
|
155
|
+
)}
|
|
156
|
+
{entry.commands.map((command, index) => (
|
|
157
|
+
<div key={`${entry.workspaceId}-${index}`} className="qc-settings-command">
|
|
158
|
+
<input
|
|
159
|
+
className="qc-settings-field qc-settings-field-name"
|
|
160
|
+
placeholder={t('cardName')}
|
|
161
|
+
value={command.name}
|
|
162
|
+
aria-label={t('cardName')}
|
|
163
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'name', e.target.value)}
|
|
164
|
+
/>
|
|
165
|
+
<input
|
|
166
|
+
className="qc-settings-field qc-settings-field-command"
|
|
167
|
+
placeholder={t('cardCommand')}
|
|
168
|
+
value={command.command}
|
|
169
|
+
aria-label={t('cardCommand')}
|
|
170
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'command', e.target.value)}
|
|
171
|
+
/>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className="qc-settings-icon-btn"
|
|
175
|
+
aria-label={t('cardRemove')}
|
|
176
|
+
title={t('cardRemove')}
|
|
177
|
+
onClick={() => removeCommand(entry.workspaceId, index)}
|
|
178
|
+
>
|
|
179
|
+
<TrashIcon size={14} />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
))}
|
|
183
|
+
<button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
|
|
184
|
+
<PlusIcon size={14} />
|
|
185
|
+
{t('cardAddCommand')}
|
|
186
|
+
</button>
|
|
187
|
+
</section>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,16 +15,23 @@
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { QuickCommandRunner, type RemoteBackend } from './server/runner.js'
|
|
17
17
|
import { readHostConfig } from './server/remote.js'
|
|
18
|
-
import { registerSettings } from './server/settings.js'
|
|
18
|
+
import { QuickCommandsSettingsSchema, registerSettings } from './server/settings.js'
|
|
19
19
|
import { registerApiRoutes } from './server/rpc.js'
|
|
20
|
+
import type { QuickCommandsSettings } from './shared/contract.js'
|
|
20
21
|
|
|
21
22
|
export const name = '@fonlan/dsh-quick-commands'
|
|
22
23
|
|
|
23
24
|
export const inject = ['settings']
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// The entry config IS this plugin's settings document (workspace command sets
|
|
27
|
+
// + popup preferences). Its fields are declared volatile, so the settings
|
|
28
|
+
// plane edits them without remounting the entry and `apply` receives live
|
|
29
|
+
// handles rather than snapshots — see server/settings.ts for the reads.
|
|
30
|
+
export const Config = QuickCommandsSettingsSchema
|
|
31
|
+
|
|
32
|
+
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
33
|
+
export function apply(ctx: Context, config: QuickCommandsSettings): void {
|
|
34
|
+
const settings = registerSettings(ctx, config)
|
|
28
35
|
const subprocess = ctx.get('subprocess')
|
|
29
36
|
if (subprocess === undefined) {
|
|
30
37
|
ctx.logger?.warn?.('@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run')
|
package/src/server/settings.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User-facing plugin settings (per-workspace command sets + popup anchor),
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* carried by this bundle's entry config: dsh >= 0.1.7 owns plugin settings as
|
|
4
|
+
* entry config (the plugin's `Config` schema) and persists edits through
|
|
5
|
+
* `settings.update`.
|
|
6
|
+
*
|
|
7
|
+
* All three fields are declared `volatile()` — that is what makes them
|
|
8
|
+
* writable by the settings plane — and schemastery hands a volatile field to
|
|
9
|
+
* `apply()` as a LIVE HANDLE (`{ get() }`), not as the value. Reading the raw
|
|
10
|
+
* field therefore yields the handle object, which silently destroys the whole
|
|
11
|
+
* document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
|
|
12
|
+
* `.default('corner')` union arrives as the handle rather than a literal.
|
|
13
|
+
* Every read goes through {@link readField} instead, which unwraps handles and
|
|
14
|
+
* still accepts plain values (hosts that resolve the config without the
|
|
15
|
+
* volatile wrapper).
|
|
6
16
|
*/
|
|
7
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
18
|
import z from '@deepseek-ai/schemastery'
|
|
9
19
|
import type { QuickCommandsSettings, QuickCommandEntry, QuickPopupSize, WorkspaceCommands } from '../shared/contract.js'
|
|
10
|
-
import type {} from '@deepseek-ai/dsh-settings'
|
|
11
20
|
|
|
12
21
|
export const QUICK_COMMANDS_NS = 'quick-commands'
|
|
13
22
|
|
|
@@ -28,27 +37,41 @@ const WorkspaceCommandsSchema = z.object({
|
|
|
28
37
|
})
|
|
29
38
|
|
|
30
39
|
export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
// Values are addressed by index (array edits), so the whole array is the
|
|
41
|
+
// volatile handle the settings plane swaps in place.
|
|
42
|
+
workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
|
|
43
|
+
popupAnchor: z.union([z.const('corner'), z.const('button')]).default('corner').volatile(),
|
|
33
44
|
// No default on purpose: a missing popupSize stays ABSENT in the resolved
|
|
34
45
|
// document, so the client can distinguish "never resized" (per-anchor
|
|
35
|
-
// default size) from a user-chosen size.
|
|
46
|
+
// default size) from a user-chosen size. Semantics match the runtime value:
|
|
47
|
+
// never resized resolves to `{}` (schemastery's implicit object default).
|
|
36
48
|
popupSize: z.object({
|
|
37
49
|
width: z.number().min(320).max(8192),
|
|
38
50
|
height: z.number().min(200).max(8192),
|
|
39
|
-
}),
|
|
51
|
+
}).volatile(),
|
|
40
52
|
})
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
/** Live handle schemastery wraps around one `volatile()` config field. */
|
|
55
|
+
interface VolatileField<T> {
|
|
56
|
+
get(): T
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Read one config field. A volatile field arrives as a handle (dsh >= 0.1.7)
|
|
61
|
+
* and is read through `.get()` on EVERY access, so edits made through the
|
|
62
|
+
* settings plane are visible immediately; hosts that hand the plain value keep
|
|
63
|
+
* working unchanged.
|
|
64
|
+
*/
|
|
65
|
+
function readField<T>(field: unknown, fallback: T): T {
|
|
66
|
+
if (field === undefined || field === null) return fallback
|
|
67
|
+
const handle = field as Partial<VolatileField<T>>
|
|
68
|
+
const value = typeof handle.get === 'function' ? handle.get() : (field as T)
|
|
69
|
+
return value === undefined || value === null ? fallback : value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Structural settings-service subset used for persistence. */
|
|
73
|
+
interface SettingsUpdateFace {
|
|
74
|
+
update(ns: string, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
/** Settings service face for the quick-commands engine. */
|
|
@@ -64,35 +87,57 @@ export interface QuickCommandsSettingsFace {
|
|
|
64
87
|
setPopupSize(size: QuickPopupSize): Promise<void>
|
|
65
88
|
}
|
|
66
89
|
|
|
67
|
-
export function registerSettings(ctx: Context): QuickCommandsSettingsFace {
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
export function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace {
|
|
91
|
+
const readWorkspaces = (): WorkspaceCommands[] => {
|
|
92
|
+
const value = readField<unknown>(config?.workspaces, [])
|
|
93
|
+
return Array.isArray(value) ? value as WorkspaceCommands[] : []
|
|
94
|
+
}
|
|
95
|
+
const readAnchor = (): QuickCommandsSettings['popupAnchor'] =>
|
|
96
|
+
(readField<unknown>(config?.popupAnchor, 'corner') === 'button' ? 'button' : 'corner')
|
|
97
|
+
const readPopupSize = (): QuickPopupSize | undefined => {
|
|
98
|
+
const value = readField<unknown>(config?.popupSize, undefined)
|
|
99
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
100
|
+
const { width, height } = value as { width?: unknown; height?: unknown }
|
|
101
|
+
// `{}` (never resized) and any partially written size read as absent.
|
|
102
|
+
if (typeof width !== 'number' || typeof height !== 'number') return undefined
|
|
103
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined
|
|
104
|
+
return { width: Math.round(width), height: Math.round(height) }
|
|
105
|
+
}
|
|
106
|
+
/** Current document, rebuilt from the live config fields on every read. */
|
|
107
|
+
const readDocument = (): QuickCommandsSettings => {
|
|
108
|
+
const size = readPopupSize()
|
|
109
|
+
return {
|
|
110
|
+
workspaces: readWorkspaces(),
|
|
111
|
+
popupAnchor: readAnchor(),
|
|
112
|
+
...(size === undefined ? {} : { popupSize: size }),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let service: SettingsUpdateFace | undefined
|
|
70
116
|
|
|
117
|
+
// Optional settings service: without one the commands still run from the
|
|
118
|
+
// entry config; they just cannot persist edits.
|
|
71
119
|
ctx.inject(['settings'], (sctx) => {
|
|
72
|
-
service = sctx
|
|
73
|
-
const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema) as {
|
|
74
|
-
get(): QuickCommandsSettings
|
|
75
|
-
watch(callback: (next: QuickCommandsSettings) => void): () => void
|
|
76
|
-
}
|
|
77
|
-
current = scope.get()
|
|
78
|
-
scope.watch((next) => { current = next })
|
|
120
|
+
service = (sctx as unknown as { settings?: SettingsUpdateFace }).settings
|
|
79
121
|
})
|
|
80
122
|
|
|
81
123
|
async function persist(patch: Record<string, unknown>): Promise<void> {
|
|
82
124
|
if (service === undefined) {
|
|
83
125
|
throw new Error('@fonlan/dsh-quick-commands: settings service is not available in this profile')
|
|
84
126
|
}
|
|
127
|
+
// The write lands in this entry's config: the settings plane either swaps
|
|
128
|
+
// the volatile handles in place or re-applies the entry — both paths are
|
|
129
|
+
// picked up by the next read, which is why no local mirror is kept here.
|
|
85
130
|
await service.update(QUICK_COMMANDS_NS, patch)
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
return {
|
|
89
|
-
get:
|
|
134
|
+
get: readDocument,
|
|
90
135
|
commandsOf: (workspaceId) => {
|
|
91
|
-
const set =
|
|
136
|
+
const set = readWorkspaces().find((w) => w.workspaceId === workspaceId)
|
|
92
137
|
return set === undefined ? [] : set.commands
|
|
93
138
|
},
|
|
94
139
|
setCommands: async (workspaceId, commands) => {
|
|
95
|
-
const next
|
|
140
|
+
const next = readWorkspaces().filter((w) => w.workspaceId !== workspaceId)
|
|
96
141
|
if (commands.length > 0) next.push({ workspaceId, commands })
|
|
97
142
|
await persist({ workspaces: next })
|
|
98
143
|
},
|
package/src/shared/contract.ts
CHANGED
|
@@ -38,6 +38,16 @@ export interface QuickCommandsSettings {
|
|
|
38
38
|
popupSize?: QuickPopupSize
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The document's anchor as a usable literal. Wire data is defensive by
|
|
43
|
+
* contract: an older host can still answer with schemastery's empty-object
|
|
44
|
+
* resolution (`{}`) for this field, which must never reach a CSS class name or
|
|
45
|
+
* a radio `checked` comparison.
|
|
46
|
+
*/
|
|
47
|
+
export function anchorOf(value: unknown): 'corner' | 'button' {
|
|
48
|
+
return value === 'button' ? 'button' : 'corner'
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/** Placeholder substitution values resolved for one execution. */
|
|
42
52
|
export interface QuickRunPlaceholders {
|
|
43
53
|
/** Workspace root path (also the execution cwd). */
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quick-commands settings card (设置 → 插件配置): an expandable plugin card —
|
|
3
|
-
* header (title + description + chevron) collapsed by default, body reveals
|
|
4
|
-
* the workspace × command-set editor and the popup anchor preference. Body
|
|
5
|
-
* edits instant-save per change; partial rows (empty name/command) persist
|
|
6
|
-
* while the user types.
|
|
7
|
-
*
|
|
8
|
-
* Mirrors the official PluginCard disclosure pattern (header button with
|
|
9
|
-
* aria-expanded, rotate-180 chevron, body separated by a top border).
|
|
10
|
-
*/
|
|
11
|
-
import { useEffect, useRef, useState } from 'react'
|
|
12
|
-
import {
|
|
13
|
-
IconChevronDownOutline14,
|
|
14
|
-
IconPlusOutline16,
|
|
15
|
-
IconTrashOutline16,
|
|
16
|
-
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
-
import { quickApi } from './api'
|
|
18
|
-
import type { QuickRosterEntry, QuickCommandsSettings } from '../shared/contract'
|
|
19
|
-
|
|
20
|
-
interface Props {
|
|
21
|
-
t: (key: string) => string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface RosterState {
|
|
25
|
-
loaded: boolean
|
|
26
|
-
entries: QuickRosterEntry[]
|
|
27
|
-
anchor: QuickCommandsSettings['popupAnchor']
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
type CommandDraft = { name: string; command: string }
|
|
31
|
-
|
|
32
|
-
export function QuickCommandsSettingsCard({ t }: Props): JSX.Element {
|
|
33
|
-
// Card-local disclosure state (a reading gesture, like the official card).
|
|
34
|
-
const [open, setOpen] = useState(false)
|
|
35
|
-
const [state, setState] = useState<RosterState>({ loaded: false, entries: [], anchor: 'corner' })
|
|
36
|
-
const [error, setError] = useState<string | null>(null)
|
|
37
|
-
// Guard concurrent saves: the last write wins via a monotonically bumped token.
|
|
38
|
-
const saveToken = useRef(0)
|
|
39
|
-
|
|
40
|
-
const load = async (): Promise<void> => {
|
|
41
|
-
try {
|
|
42
|
-
const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
|
|
43
|
-
setState({ loaded: true, entries, anchor: settings.popupAnchor })
|
|
44
|
-
setError(null)
|
|
45
|
-
} catch (e) {
|
|
46
|
-
setState((s) => ({ ...s, loaded: true }))
|
|
47
|
-
setError(e instanceof Error ? e.message : String(e))
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
void load()
|
|
53
|
-
}, [])
|
|
54
|
-
|
|
55
|
-
/** Instant-save the workspace's whole command set; optimistic UI. */
|
|
56
|
-
const saveCommands = async (workspaceId: string, commands: CommandDraft[]): Promise<void> => {
|
|
57
|
-
const token = ++saveToken.current
|
|
58
|
-
try {
|
|
59
|
-
const next = await quickApi.settingsSetCommands(workspaceId, commands)
|
|
60
|
-
if (token !== saveToken.current) return
|
|
61
|
-
setState((s) => ({
|
|
62
|
-
...s,
|
|
63
|
-
anchor: next.popupAnchor,
|
|
64
|
-
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
65
|
-
}))
|
|
66
|
-
setError(null)
|
|
67
|
-
} catch (e) {
|
|
68
|
-
setError(e instanceof Error ? e.message : String(e))
|
|
69
|
-
// Re-sync to server truth on failure.
|
|
70
|
-
void load()
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Compute the next command list for a workspace, THEN save + update state. */
|
|
75
|
-
const mutateCommands = (workspaceId: string, next: (current: CommandDraft[]) => CommandDraft[]): void => {
|
|
76
|
-
const entry = state.entries.find((e) => e.workspaceId === workspaceId)
|
|
77
|
-
if (entry === undefined) return
|
|
78
|
-
const commands = next(entry.commands)
|
|
79
|
-
// Optimistic update first (row stays visible immediately), then persist.
|
|
80
|
-
setState((s) => ({
|
|
81
|
-
...s,
|
|
82
|
-
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
83
|
-
}))
|
|
84
|
-
void saveCommands(workspaceId, commands)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const patchEntry = (workspaceId: string, index: number, field: 'name' | 'command', value: string): void => {
|
|
88
|
-
mutateCommands(workspaceId, (current) =>
|
|
89
|
-
current.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const addCommand = (workspaceId: string): void => {
|
|
94
|
-
mutateCommands(workspaceId, (current) => [...current, { name: '', command: '' }])
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const removeCommand = (workspaceId: string, index: number): void => {
|
|
98
|
-
mutateCommands(workspaceId, (current) => current.filter((_, i) => i !== index))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const setAnchor = async (anchor: QuickCommandsSettings['popupAnchor']): Promise<void> => {
|
|
102
|
-
setState((s) => ({ ...s, anchor }))
|
|
103
|
-
try {
|
|
104
|
-
const next = await quickApi.settingsSetAnchor(anchor)
|
|
105
|
-
setState((s) => ({ ...s, anchor: next.popupAnchor }))
|
|
106
|
-
} catch (e) {
|
|
107
|
-
setError(e instanceof Error ? e.message : String(e))
|
|
108
|
-
void load()
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const title = t('cardTitle')
|
|
113
|
-
const description = t('cardDescription')
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<li className={'qc-settings-card' + (open ? ' qc-settings-card-open' : '')}>
|
|
117
|
-
<button
|
|
118
|
-
type="button"
|
|
119
|
-
className="qc-settings-header"
|
|
120
|
-
aria-expanded={open}
|
|
121
|
-
aria-label={`${t('collapseOrExpand')}: ${title}`}
|
|
122
|
-
onClick={() => setOpen((v) => !v)}
|
|
123
|
-
>
|
|
124
|
-
<span className="qc-settings-head-text">
|
|
125
|
-
<span className="qc-settings-name">{title}</span>
|
|
126
|
-
<span className="qc-settings-description">{description}</span>
|
|
127
|
-
</span>
|
|
128
|
-
<IconChevronDownOutline14 className={'qc-settings-chevron' + (open ? ' qc-settings-chevron-open' : '')} />
|
|
129
|
-
</button>
|
|
130
|
-
|
|
131
|
-
{open && (
|
|
132
|
-
<div className="qc-settings-body">
|
|
133
|
-
<p className="qc-settings-intro">{t('cardIntro')}</p>
|
|
134
|
-
|
|
135
|
-
<div className="qc-settings-anchor">
|
|
136
|
-
<span className="qc-settings-anchor-label">{t('cardAnchor')}</span>
|
|
137
|
-
<label className="qc-settings-radio">
|
|
138
|
-
<input
|
|
139
|
-
type="radio"
|
|
140
|
-
name="qc-anchor"
|
|
141
|
-
checked={state.anchor === 'corner'}
|
|
142
|
-
onChange={() => void setAnchor('corner')}
|
|
143
|
-
/>
|
|
144
|
-
{t('cardAnchorCorner')}
|
|
145
|
-
</label>
|
|
146
|
-
<label className="qc-settings-radio">
|
|
147
|
-
<input
|
|
148
|
-
type="radio"
|
|
149
|
-
name="qc-anchor"
|
|
150
|
-
checked={state.anchor === 'button'}
|
|
151
|
-
onChange={() => void setAnchor('button')}
|
|
152
|
-
/>
|
|
153
|
-
{t('cardAnchorButton')}
|
|
154
|
-
</label>
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
{error !== null && <div className="qc-settings-error">{t('cardSaveError')}: {error}</div>}
|
|
158
|
-
|
|
159
|
-
{state.loaded && state.entries.length === 0 && (
|
|
160
|
-
<p className="qc-settings-empty">{t('cardNoWorkspaces')}</p>
|
|
161
|
-
)}
|
|
162
|
-
|
|
163
|
-
{state.entries.map((entry) => (
|
|
164
|
-
<section key={entry.workspaceId} className="qc-settings-workspace">
|
|
165
|
-
<header className="qc-settings-workspace-head">
|
|
166
|
-
<span className="qc-settings-workspace-title">{entry.title || entry.path || entry.workspaceId}</span>
|
|
167
|
-
<span className="qc-settings-workspace-path">{entry.path}</span>
|
|
168
|
-
</header>
|
|
169
|
-
{entry.commands.length === 0 && (
|
|
170
|
-
<p className="qc-settings-no-commands">{t('cardNoCommands')}</p>
|
|
171
|
-
)}
|
|
172
|
-
{entry.commands.map((command, index) => (
|
|
173
|
-
<div key={`${entry.workspaceId}-${index}`} className="qc-settings-command">
|
|
174
|
-
<input
|
|
175
|
-
className="qc-settings-field qc-settings-field-name"
|
|
176
|
-
placeholder={t('cardName')}
|
|
177
|
-
value={command.name}
|
|
178
|
-
aria-label={t('cardName')}
|
|
179
|
-
onChange={(e) => patchEntry(entry.workspaceId, index, 'name', e.target.value)}
|
|
180
|
-
/>
|
|
181
|
-
<input
|
|
182
|
-
className="qc-settings-field qc-settings-field-command"
|
|
183
|
-
placeholder={t('cardCommand')}
|
|
184
|
-
value={command.command}
|
|
185
|
-
aria-label={t('cardCommand')}
|
|
186
|
-
onChange={(e) => patchEntry(entry.workspaceId, index, 'command', e.target.value)}
|
|
187
|
-
/>
|
|
188
|
-
<button
|
|
189
|
-
type="button"
|
|
190
|
-
className="qc-settings-icon-btn"
|
|
191
|
-
aria-label={t('cardRemove')}
|
|
192
|
-
title={t('cardRemove')}
|
|
193
|
-
onClick={() => removeCommand(entry.workspaceId, index)}
|
|
194
|
-
>
|
|
195
|
-
<IconTrashOutline16 size={14} />
|
|
196
|
-
</button>
|
|
197
|
-
</div>
|
|
198
|
-
))}
|
|
199
|
-
<button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
|
|
200
|
-
<IconPlusOutline16 size={14} />
|
|
201
|
-
{t('cardAddCommand')}
|
|
202
|
-
</button>
|
|
203
|
-
</section>
|
|
204
|
-
))}
|
|
205
|
-
</div>
|
|
206
|
-
)}
|
|
207
|
-
</li>
|
|
208
|
-
)
|
|
209
|
-
}
|