@linxin666/dsh-ssh 0.3.23 → 0.3.24

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.
@@ -1,3 +1,29 @@
1
+ /**
2
+ * One trailing action button seated at the entry row's right edge (e.g. a
3
+ * refresh command or a collapse chevron). Rows with actions switch from the
4
+ * classic single-button structure to a container with a main button plus the
5
+ * action buttons (nested interactive elements are invalid HTML); rows without
6
+ * actions keep the classic structure untouched.
7
+ */
8
+ export interface SidebarEntryAction {
9
+ /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
10
+ id: string;
11
+ /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
12
+ icon: string;
13
+ /**
14
+ * Optional icon while the row is inactive. Setting it makes the action a
15
+ * state mirror (a collapse chevron): the core swaps the icon and stamps
16
+ * aria-expanded on every active-state change. Requires the active bridge.
17
+ */
18
+ inactiveIcon?: string;
19
+ /** Localized accessible label (aria-label + title); receives the active state. */
20
+ label(active: boolean): string;
21
+ /**
22
+ * Click handler; receives the button so the caller can drive transient
23
+ * affordances (a refresh cooldown) without a re-render.
24
+ */
25
+ onClick(button: HTMLButtonElement): void;
26
+ }
1
27
  /** Per-package configuration for one sidebar entry row. */
2
28
  export interface SidebarEntryOptions {
3
29
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -31,6 +57,12 @@ export interface SidebarEntryOptions {
31
57
  };
32
58
  /** Click action (open/toggle the owning panel). */
33
59
  onToggle(): void;
60
+ /**
61
+ * Optional trailing action buttons at the row's right edge. Requires the
62
+ * package CSS to seat entryMain / entryAction; empty keeps the classic
63
+ * single-button row.
64
+ */
65
+ actions?: readonly SidebarEntryAction[];
34
66
  /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
35
67
  position: 'before' | 'after';
36
68
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.3.23",
4
+ "version": "0.3.24",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -69,6 +69,7 @@
69
69
  "@types/react-dom": "^18.3.5",
70
70
  "@types/ssh2": "^1.15.5",
71
71
  "@types/ws": "^8.5.13",
72
+ "@vitest/coverage-v8": "^3.2.7",
72
73
  "jsdom": "^25.0.0",
73
74
  "react": "^18.3.1",
74
75
  "react-dom": "^18.3.1",
@@ -16,6 +16,33 @@
16
16
  */
17
17
  import { subscribeBodyInvalidations } from './body-mutations.ts'
18
18
 
19
+ /**
20
+ * One trailing action button seated at the entry row's right edge (e.g. a
21
+ * refresh command or a collapse chevron). Rows with actions switch from the
22
+ * classic single-button structure to a container with a main button plus the
23
+ * action buttons (nested interactive elements are invalid HTML); rows without
24
+ * actions keep the classic structure untouched.
25
+ */
26
+ export interface SidebarEntryAction {
27
+ /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
28
+ id: string
29
+ /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
30
+ icon: string
31
+ /**
32
+ * Optional icon while the row is inactive. Setting it makes the action a
33
+ * state mirror (a collapse chevron): the core swaps the icon and stamps
34
+ * aria-expanded on every active-state change. Requires the active bridge.
35
+ */
36
+ inactiveIcon?: string
37
+ /** Localized accessible label (aria-label + title); receives the active state. */
38
+ label(active: boolean): string
39
+ /**
40
+ * Click handler; receives the button so the caller can drive transient
41
+ * affordances (a refresh cooldown) without a re-render.
42
+ */
43
+ onClick(button: HTMLButtonElement): void
44
+ }
45
+
19
46
  /** Per-package configuration for one sidebar entry row. */
20
47
  export interface SidebarEntryOptions {
21
48
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -47,6 +74,12 @@ export interface SidebarEntryOptions {
47
74
  refresh?: { subscribe(listener: () => void): () => void }
48
75
  /** Click action (open/toggle the owning panel). */
49
76
  onToggle(): void
77
+ /**
78
+ * Optional trailing action buttons at the row's right edge. Requires the
79
+ * package CSS to seat entryMain / entryAction; empty keeps the classic
80
+ * single-button row.
81
+ */
82
+ actions?: readonly SidebarEntryAction[]
50
83
  /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
51
84
  position: 'before' | 'after'
52
85
  /**
@@ -84,10 +117,12 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
84
117
  return undefined
85
118
  }
86
119
 
87
- /** Build the entry row (a detached button; insert once the shell is up). */
88
- function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
89
- const entry = document.createElement('button')
90
- entry.type = 'button'
120
+ /** Build the entry row (detached; inserted once the shell is up). */
121
+ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
122
+ const actions = options.actions ?? []
123
+ const composite = actions.length > 0
124
+ const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
125
+ if (!composite) (entry as HTMLButtonElement).type = 'button'
91
126
  entry.setAttribute(options.rowAttribute, '')
92
127
  if (options.plugin !== undefined) {
93
128
  entry.setAttribute('data-dsh-plugin', options.plugin)
@@ -99,19 +134,66 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement;
99
134
  const iconSpan = document.createElement('span')
100
135
  iconSpan.className = options.css['entryIcon'] ?? ''
101
136
  iconSpan.innerHTML = options.icon
102
- entry.append(iconSpan, labelSpan)
137
+ // The main hit area: the classic row IS the button; with trailing actions
138
+ // the row becomes a container and the main area its own button, so the row
139
+ // never nests interactive elements.
140
+ const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
141
+ if (composite) {
142
+ main.type = 'button'
143
+ main.className = options.css['entryMain'] ?? ''
144
+ main.append(iconSpan, labelSpan)
145
+ entry.append(main)
146
+ } else {
147
+ entry.append(iconSpan, labelSpan)
148
+ }
149
+ const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
150
+ for (const action of actions) {
151
+ const button = document.createElement('button')
152
+ button.type = 'button'
153
+ button.className = options.css['entryAction'] ?? ''
154
+ button.setAttribute('data-dsh-entry-action', action.id)
155
+ button.innerHTML = action.icon
156
+ button.addEventListener('click', () => { action.onClick(button) })
157
+ entry.append(button)
158
+ actionButtons.push({ action, button })
159
+ }
160
+ let open = false
161
+ const applyActions = (): void => {
162
+ for (const { action, button } of actionButtons) {
163
+ button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
164
+ const text = action.label(open)
165
+ button.setAttribute('aria-label', text)
166
+ button.setAttribute('title', text)
167
+ if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
168
+ }
169
+ }
103
170
  const applyLabel = (): void => {
104
- entry.setAttribute('aria-label', options.label())
105
- if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
171
+ main.setAttribute('aria-label', options.label())
172
+ if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
106
173
  labelSpan.textContent = options.label()
174
+ applyActions()
107
175
  }
108
176
  applyLabel()
109
- entry.addEventListener('click', options.onToggle)
110
- return { entry, applyLabel }
177
+ main.addEventListener('click', options.onToggle)
178
+ if (composite) {
179
+ // The old row was clickable edge to edge; keep the padding between the
180
+ // main area and the action buttons a toggle target as well.
181
+ entry.addEventListener('click', (event) => {
182
+ if (event.target === entry) options.onToggle()
183
+ })
184
+ }
185
+ return {
186
+ entry,
187
+ applyLabel,
188
+ setOpen: (next: boolean) => {
189
+ open = next
190
+ applyActions()
191
+ },
192
+ }
111
193
  }
112
194
 
113
195
  /** Re-insert the entry after the New Session row (before the browser region). */
114
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
196
+ function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
115
197
  const button = newSessionButton(root)
116
198
  if (button === undefined) return false
117
199
  if (entry.parentElement !== root) {
@@ -149,7 +231,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
149
231
  if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
150
232
  return () => {}
151
233
  }
152
- const { entry, applyLabel } = createEntry(options)
234
+ const { entry, applyLabel, setOpen } = createEntry(options)
153
235
  let root: HTMLElement | undefined
154
236
  let placed = false
155
237
  let unsubscribeRefresh: (() => void) | undefined
@@ -217,8 +299,10 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
217
299
  // row permanently highlighted — delete the attribute instead.
218
300
  const unsubscribeActive = options.active === undefined ? undefined : (() => {
219
301
  const syncActive = (): void => {
220
- if (options.active!.isOpen()) entry.dataset.active = 'true'
302
+ const open = options.active!.isOpen()
303
+ if (open) entry.dataset.active = 'true'
221
304
  else delete entry.dataset.active
305
+ setOpen(open)
222
306
  }
223
307
  const unsubscribe = options.active.subscribe(syncActive)
224
308
  syncActive()