@linxin666/dsh-ssh 0.1.20 → 0.2.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.
@@ -3,7 +3,7 @@
3
3
  * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
4
4
  * that hands the alias to the terminal tab via onConnect.
5
5
  */
6
- import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
7
7
  import type { SshApi } from '../api.ts'
8
8
  import type { SshHostSummary, TestResult } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
@@ -20,6 +20,44 @@ export interface HostsTabProps {
20
20
  /** The host-form dialog invocation. */
21
21
  type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
22
22
 
23
+ /** Host list grouping modes (#379). */
24
+ export type HostGroupBy = 'none' | 'environment' | 'tags'
25
+
26
+ /** One collapsible group section of the grouped host list. */
27
+ export interface HostGroup {
28
+ /** Group key: the environment name, one tag, or '' for the ungrouped bucket. */
29
+ key: string
30
+ hosts: SshHostSummary[]
31
+ }
32
+
33
+ /**
34
+ * Bucket hosts into collapsible groups (#379). Grouping by tags places a
35
+ * multi-tag host in every one of its tag groups (folder view); hosts without
36
+ * the grouping key land in the '' bucket, which always sorts last. Groups
37
+ * sort alphabetically; host order inside a group follows the API listing.
38
+ */
39
+ export function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[] {
40
+ if (groupBy === 'none') return [{ key: '', hosts }]
41
+ const buckets = new Map<string, SshHostSummary[]>()
42
+ const push = (key: string, host: SshHostSummary): void => {
43
+ const bucket = buckets.get(key)
44
+ if (bucket === undefined) buckets.set(key, [host])
45
+ else bucket.push(host)
46
+ }
47
+ for (const host of hosts) {
48
+ if (groupBy === 'environment') {
49
+ push(host.environment ?? '', host)
50
+ } else if (host.tags.length === 0) {
51
+ push('', host)
52
+ } else {
53
+ for (const tag of host.tags) push(tag, host)
54
+ }
55
+ }
56
+ return [...buckets.entries()]
57
+ .sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)))
58
+ .map(([key, group]) => ({ key, hosts: group }))
59
+ }
60
+
23
61
  /** The hosts table plus its toolbar and dialogs. */
24
62
  export function HostsTab({ api, onConnect }: HostsTabProps) {
25
63
  const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
@@ -30,6 +68,9 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
30
68
  const [importing, setImporting] = useState(false)
31
69
  const [notice, setNotice] = useState<string | null>(null)
32
70
  const [dialog, setDialog] = useState<DialogState | null>(null)
71
+ const [groupBy, setGroupBy] = useState<HostGroupBy>('none')
72
+ const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
73
+ const [testingGroup, setTestingGroup] = useState<string | null>(null)
33
74
  const seqRef = useRef(0)
34
75
 
35
76
  const load = useCallback(async (query?: string): Promise<void> => {
@@ -78,6 +119,16 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
78
119
  }
79
120
  }
80
121
 
122
+ // Group-header batch action (#379): test every host in the group.
123
+ const testGroup = async (group: HostGroup): Promise<void> => {
124
+ setTestingGroup(group.key)
125
+ try {
126
+ await Promise.all(group.hosts.map(host => runTest(host.alias)))
127
+ } finally {
128
+ setTestingGroup(null)
129
+ }
130
+ }
131
+
81
132
  const importConfig = async (): Promise<void> => {
82
133
  setImporting(true)
83
134
  try {
@@ -91,10 +142,73 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
91
142
  }
92
143
  }
93
144
 
145
+ const renderHostRow = (host: SshHostSummary): ReactNode => {
146
+ const test = testResults[host.alias]
147
+ return (
148
+ <tr key={host.alias}>
149
+ <td className={css.mono}>{host.alias}</td>
150
+ <td className={css.mono}>{host.host}:{host.port}</td>
151
+ <td>{host.user}</td>
152
+ <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
153
+ <td className={css.cellMuted}>{host.environment ?? ''}</td>
154
+ <td className={css.cellMuted}>{host.tags.join(', ')}</td>
155
+ <td className={css.cellMuted}>{host.description ?? ''}</td>
156
+ <td>
157
+ <div className={css.actions}>
158
+ <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
159
+ {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
160
+ </button>
161
+ {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
162
+ {test !== undefined && (
163
+ <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
164
+ {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
165
+ </span>
166
+ )}
167
+ <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
168
+ <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
169
+ <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
170
+ </div>
171
+ </td>
172
+ </tr>
173
+ )
174
+ }
175
+
176
+ const renderHostTable = (rows: SshHostSummary[]): ReactNode => (
177
+ <table className={css.table}>
178
+ <thead>
179
+ <tr>
180
+ <th>{tt('hosts.col.alias')}</th>
181
+ <th>{tt('hosts.col.host')}</th>
182
+ <th>{tt('hosts.col.user')}</th>
183
+ <th>{tt('hosts.col.auth')}</th>
184
+ <th>{tt('hosts.col.environment')}</th>
185
+ <th>{tt('hosts.col.tags')}</th>
186
+ <th>{tt('hosts.col.description')}</th>
187
+ <th>{tt('hosts.col.actions')}</th>
188
+ </tr>
189
+ </thead>
190
+ <tbody>
191
+ {rows.map(renderHostRow)}
192
+ </tbody>
193
+ </table>
194
+ )
195
+
196
+ const groups = hosts === null ? [] : groupHosts(hosts, groupBy)
197
+
94
198
  return (
95
199
  <div className={css.fillBody}>
96
200
  <div className={css.toolbar}>
97
201
  <input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
202
+ <select
203
+ className={css.groupBySelect}
204
+ aria-label={tt('hosts.groupBy.label')}
205
+ value={groupBy}
206
+ onChange={event => { setGroupBy(event.target.value as HostGroupBy) }}
207
+ >
208
+ <option value="none">{tt('hosts.groupBy.none')}</option>
209
+ <option value="environment">{tt('hosts.groupBy.environment')}</option>
210
+ <option value="tags">{tt('hosts.groupBy.tags')}</option>
211
+ </select>
98
212
  <div className={css.toolbarSpacer} />
99
213
  <button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
100
214
  <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
@@ -103,54 +217,44 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
103
217
  {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
104
218
  {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
105
219
  {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
106
- {hosts !== null && hosts.length > 0 && (
220
+ {hosts !== null && hosts.length > 0 && groupBy === 'none' && (
221
+ <div className={css.tableWrap}>
222
+ {renderHostTable(hosts)}
223
+ </div>
224
+ )}
225
+ {hosts !== null && hosts.length > 0 && groupBy !== 'none' && (
107
226
  <div className={css.tableWrap}>
108
- <table className={css.table}>
109
- <thead>
110
- <tr>
111
- <th>{tt('hosts.col.alias')}</th>
112
- <th>{tt('hosts.col.host')}</th>
113
- <th>{tt('hosts.col.user')}</th>
114
- <th>{tt('hosts.col.auth')}</th>
115
- <th>{tt('hosts.col.environment')}</th>
116
- <th>{tt('hosts.col.tags')}</th>
117
- <th>{tt('hosts.col.description')}</th>
118
- <th>{tt('hosts.col.actions')}</th>
119
- </tr>
120
- </thead>
121
- <tbody>
122
- {hosts.map(host => {
123
- const test = testResults[host.alias]
124
- return (
125
- <tr key={host.alias}>
126
- <td className={css.mono}>{host.alias}</td>
127
- <td className={css.mono}>{host.host}:{host.port}</td>
128
- <td>{host.user}</td>
129
- <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
130
- <td className={css.cellMuted}>{host.environment ?? ''}</td>
131
- <td className={css.cellMuted}>{host.tags.join(', ')}</td>
132
- <td className={css.cellMuted}>{host.description ?? ''}</td>
133
- <td>
134
- <div className={css.actions}>
135
- <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
136
- {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
137
- </button>
138
- {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
139
- {test !== undefined && (
140
- <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
141
- {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
142
- </span>
143
- )}
144
- <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
145
- <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
146
- <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
147
- </div>
148
- </td>
149
- </tr>
150
- )
151
- })}
152
- </tbody>
153
- </table>
227
+ {groups.map(group => {
228
+ const isCollapsed = collapsed[group.key] === true
229
+ const label = group.key === ''
230
+ ? (groupBy === 'tags' ? tt('hosts.group.noTags') : tt('hosts.group.ungrouped'))
231
+ : group.key
232
+ return (
233
+ <section key={group.key} className={css.groupSection}>
234
+ <div className={css.groupHeader}>
235
+ <button
236
+ type="button"
237
+ className={css.groupToggle}
238
+ aria-expanded={!isCollapsed}
239
+ onClick={() => { setCollapsed(prev => ({ ...prev, [group.key]: !isCollapsed })) }}
240
+ >
241
+ <span className={css.groupChevron} data-collapsed={isCollapsed || undefined} aria-hidden="true" />
242
+ <span className={css.groupName}>{label}</span>
243
+ <span className={css.groupCount}>{tt('hosts.group.count', { count: group.hosts.length })}</span>
244
+ </button>
245
+ <button
246
+ type="button"
247
+ className={css.linkButton}
248
+ disabled={testingGroup === group.key}
249
+ onClick={() => { void testGroup(group) }}
250
+ >
251
+ {testingGroup === group.key ? tt('hosts.testing') : tt('hosts.group.testAll')}
252
+ </button>
253
+ </div>
254
+ {!isCollapsed && renderHostTable(group.hosts)}
255
+ </section>
256
+ )
257
+ })}
154
258
  </div>
155
259
  )}
156
260
  {dialog !== null && (
@@ -242,6 +242,81 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
242
242
  color: var(--dsw-alias-label-tertiary);
243
243
  }
244
244
 
245
+ .groupBySelect {
246
+ padding: 6px 8px;
247
+ font-size: 13px;
248
+ color: var(--dsw-alias-label-primary);
249
+ background: var(--dsw-specific-input-major);
250
+ border: 1px solid var(--dsw-alias-border-l2);
251
+ border-radius: 8px;
252
+ outline: none;
253
+ }
254
+
255
+ /* --- host groups (#379) ------------------------------------------------------- */
256
+
257
+ .groupSection + .groupSection {
258
+ border-top: 1px solid var(--dsw-alias-border-l1);
259
+ }
260
+
261
+ .groupHeader {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 8px;
265
+ padding: 4px 8px;
266
+ position: sticky;
267
+ top: 0;
268
+ background: var(--dsw-alias-bg-base, var(--dsw-specific-input-major));
269
+ }
270
+
271
+ .groupToggle {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 6px;
275
+ flex: 1;
276
+ min-width: 0;
277
+ padding: 4px 6px;
278
+ border: none;
279
+ background: none;
280
+ color: var(--dsw-alias-label-primary);
281
+ font-size: 12.5px;
282
+ font-weight: 500;
283
+ cursor: pointer;
284
+ text-align: left;
285
+ border-radius: 6px;
286
+ }
287
+
288
+ .groupToggle:hover {
289
+ background: var(--dsw-alias-interactive-bg-hover);
290
+ }
291
+
292
+ .groupChevron {
293
+ width: 7px;
294
+ height: 7px;
295
+ border-right: 1.5px solid var(--dsw-alias-label-tertiary);
296
+ border-bottom: 1.5px solid var(--dsw-alias-label-tertiary);
297
+ transform: rotate(45deg);
298
+ transition: transform 120ms ease;
299
+ flex: none;
300
+ margin-bottom: 2px;
301
+ }
302
+
303
+ .groupChevron[data-collapsed] {
304
+ transform: rotate(-45deg);
305
+ margin-bottom: 0;
306
+ }
307
+
308
+ .groupName {
309
+ overflow: hidden;
310
+ text-overflow: ellipsis;
311
+ white-space: nowrap;
312
+ }
313
+
314
+ .groupCount {
315
+ color: var(--dsw-alias-label-tertiary);
316
+ font-weight: 400;
317
+ flex: none;
318
+ }
319
+
245
320
  /* --- tables ------------------------------------------------------------------- */
246
321
 
247
322
  .tableWrap {
package/src/routes.ts CHANGED
@@ -361,9 +361,22 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
361
361
  if (settled) return
362
362
  settled = true
363
363
  emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
364
- try { sink.destroy() } catch { /* closed */ }
365
- void unlink(tmp).catch(() => undefined)
366
- try { res.end() } catch { /* closed */ }
364
+ // End the response only after the tmp file is gone, and unlink only
365
+ // after the sink is fully closed: destroying a WriteStream whose
366
+ // fs.open is still pending lets the open RE-CREATE the file after
367
+ // an early unlink, leaving staging populated (this raced the
368
+ // response end and made the byte-cap test flaky).
369
+ const cleanup = (): void => {
370
+ void unlink(tmp).catch(() => undefined).finally(() => {
371
+ try { res.end() } catch { /* closed */ }
372
+ })
373
+ }
374
+ if (sink.destroyed) {
375
+ cleanup()
376
+ } else {
377
+ sink.once('close', cleanup)
378
+ try { sink.destroy() } catch { cleanup() }
379
+ }
367
380
  }
368
381
  const done = (): void => {
369
382
  if (settled) return
@@ -379,11 +392,18 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
379
392
  // header-less requests: count the bytes as they actually arrive and
380
393
  // abort the moment the cap is exceeded.
381
394
  let received = 0
395
+ let capped = false
382
396
  req.on('data', (chunk: Buffer) => {
383
397
  received += chunk.byteLength
384
- if (received > maxUploadBytes) {
398
+ if (received > maxUploadBytes && !capped) {
399
+ capped = true
385
400
  fail('upload body too large')
386
- try { req.destroy() } catch { /* closed */ }
401
+ // Keep the socket alive until the response flush finishes:
402
+ // destroying the request here races res.end() and the client
403
+ // sees a hang-up instead of the result frame. Drain the rest of
404
+ // the body so the socket can close cleanly afterwards.
405
+ res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
406
+ req.resume()
387
407
  }
388
408
  })
389
409
  req.pipe(sink)