@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.
@@ -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
- /** No plugin-level config; all configuration lives in the settings namespace. */
26
- export function apply(ctx: Context): void {
27
- const settings = registerSettings(ctx)
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')
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * User-facing plugin settings (per-workspace command sets + popup anchor),
3
- * persisted through the settings service namespace `quick-commands`.
4
- * The DSH settings service requires a lowercase kebab-case namespace
5
- * (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
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
- workspaces: z.array(WorkspaceCommandsSchema).max(500),
32
- popupAnchor: z.union([z.const('corner'), z.const('button')]).default('corner'),
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
- const DEFAULT_SETTINGS: QuickCommandsSettings = {
43
- workspaces: [],
44
- popupAnchor: 'corner',
54
+ /** Live handle schemastery wraps around one `volatile()` config field. */
55
+ interface VolatileField<T> {
56
+ get(): T
45
57
  }
46
58
 
47
- /** Structural settings-service subset; the real service resolves the schema. */
48
- interface SettingsServiceLike {
49
- register(ns: unknown, schema: unknown): unknown
50
- update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
51
- mutate?(ns: unknown, ops: readonly unknown[], expectedRevision?: number): Promise<unknown>
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
- let current: QuickCommandsSettings = { ...DEFAULT_SETTINGS, workspaces: [] }
69
- let service: SettingsServiceLike | undefined
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.settings as unknown as SettingsServiceLike
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: () => current,
134
+ get: readDocument,
90
135
  commandsOf: (workspaceId) => {
91
- const set = current.workspaces.find((w) => w.workspaceId === workspaceId)
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: WorkspaceCommands[] = current.workspaces.filter((w) => w.workspaceId !== workspaceId)
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
  },
@@ -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
- }