@linxin666/dsh-ssh 0.1.20 → 0.2.1

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
- import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { Client } from "ssh2";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1414,26 +1414,26 @@ async function connectChain(engine, entry) {
1414
1414
  const hops = [];
1415
1415
  let sock;
1416
1416
  const chain = entry.proxyJump;
1417
- for (let index = 0; index < chain.length; index += 1) {
1418
- const hopAlias = chain[index];
1419
- const hop = engine.store.find(hopAlias);
1420
- if (hop === void 0) {
1421
- for (const client of hops) client.end();
1422
- throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1423
- }
1424
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1425
- hops.push(hopClient);
1426
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1427
- const nextHost = next !== void 0 ? next.host : entry.host;
1428
- const nextPort = next !== void 0 ? next.port : entry.port;
1429
- sock = await new Promise((resolve, reject) => {
1430
- hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1431
- if (error !== void 0) {
1432
- for (const client of hops) client.end();
1433
- reject(error);
1434
- } else resolve(stream);
1417
+ try {
1418
+ for (let index = 0; index < chain.length; index += 1) {
1419
+ const hopAlias = chain[index];
1420
+ const hop = engine.store.find(hopAlias);
1421
+ if (hop === void 0) throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1422
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1423
+ hops.push(hopClient);
1424
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1425
+ const nextHost = next !== void 0 ? next.host : entry.host;
1426
+ const nextPort = next !== void 0 ? next.port : entry.port;
1427
+ sock = await new Promise((resolve, reject) => {
1428
+ hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1429
+ if (error !== void 0) reject(error);
1430
+ else resolve(stream);
1431
+ });
1435
1432
  });
1436
- });
1433
+ }
1434
+ } catch (error) {
1435
+ for (const client of hops) client.end();
1436
+ throw error;
1437
1437
  }
1438
1438
  let target;
1439
1439
  try {
@@ -2283,7 +2283,10 @@ function makeRoutes(deps) {
2283
2283
  const { store, engine } = deps;
2284
2284
  const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
2285
2285
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
2286
- mkdirSync(staging, { recursive: true });
2286
+ mkdirSync(staging, {
2287
+ recursive: true,
2288
+ mode: 448
2289
+ });
2287
2290
  /** Guard helper: fence + method check. */
2288
2291
  const guard = (req, res, method) => {
2289
2292
  if (!isLoopbackRequest(req)) {
@@ -2543,7 +2546,7 @@ function makeRoutes(deps) {
2543
2546
  } catch {}
2544
2547
  };
2545
2548
  const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
2546
- const sink = createWriteStream(tmp);
2549
+ const sink = createWriteStream(tmp, { mode: 384 });
2547
2550
  let settled = false;
2548
2551
  const fail = (error) => {
2549
2552
  if (settled) return;
@@ -2553,13 +2556,22 @@ function makeRoutes(deps) {
2553
2556
  ok: false,
2554
2557
  error: error instanceof Error ? error.message : String(error)
2555
2558
  });
2556
- try {
2557
- sink.destroy();
2558
- } catch {}
2559
- unlink(tmp).catch(() => void 0);
2560
- try {
2561
- res.end();
2562
- } catch {}
2559
+ const cleanup = () => {
2560
+ unlink(tmp).catch(() => void 0).finally(() => {
2561
+ try {
2562
+ res.end();
2563
+ } catch {}
2564
+ });
2565
+ };
2566
+ if (sink.destroyed) cleanup();
2567
+ else {
2568
+ sink.once("close", cleanup);
2569
+ try {
2570
+ sink.destroy();
2571
+ } catch {
2572
+ cleanup();
2573
+ }
2574
+ }
2563
2575
  };
2564
2576
  const done = () => {
2565
2577
  if (settled) return;
@@ -2576,13 +2588,18 @@ function makeRoutes(deps) {
2576
2588
  if (!res.writableEnded) fail("connection closed");
2577
2589
  });
2578
2590
  let received = 0;
2591
+ let capped = false;
2579
2592
  req.on("data", (chunk) => {
2580
2593
  received += chunk.byteLength;
2581
- if (received > maxUploadBytes) {
2594
+ if (received > maxUploadBytes && !capped) {
2595
+ capped = true;
2582
2596
  fail("upload body too large");
2583
- try {
2584
- req.destroy();
2585
- } catch {}
2597
+ res.on("finish", () => {
2598
+ try {
2599
+ req.destroy();
2600
+ } catch {}
2601
+ });
2602
+ req.resume();
2586
2603
  }
2587
2604
  });
2588
2605
  req.pipe(sink);
@@ -2635,6 +2652,7 @@ function makeRoutes(deps) {
2635
2652
  }
2636
2653
  const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
2637
2654
  try {
2655
+ closeSync(openSync(tmp, "w", 384));
2638
2656
  const outcome = await engine.download(alias, remotePath, tmp);
2639
2657
  res.writeHead(200, {
2640
2658
  "content-type": "application/octet-stream",
@@ -2,133 +2,141 @@
2
2
  * dsh-ssh surface copy: zh is the key source, en mirrors every key.
3
3
  */
4
4
  export declare const zh: {
5
- readonly 'entry.label': 'SSH';
6
- readonly 'entry.tooltip': '远程 SSH 运维面板';
7
- readonly 'panel.title': '远程 SSH';
8
- readonly 'panel.backToConversation': '返回会话';
9
- readonly 'tab.hosts': '主机';
10
- readonly 'tab.terminal': '终端';
11
- readonly 'tab.transfer': '传输';
12
- readonly 'tab.tunnels': '隧道';
13
- readonly 'tab.cluster': '集群';
14
- readonly 'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。';
15
- readonly 'hosts.add': '新增主机';
16
- readonly 'hosts.import': '导入 ~/.ssh/config';
17
- readonly 'hosts.search': '搜索别名 / 描述 / 标签…';
18
- readonly 'hosts.col.alias': '别名';
19
- readonly 'hosts.col.host': '主机';
20
- readonly 'hosts.col.user': '用户';
21
- readonly 'hosts.col.auth': '认证';
22
- readonly 'hosts.col.environment': '环境';
23
- readonly 'hosts.col.tags': '标签';
24
- readonly 'hosts.col.description': '备注';
25
- readonly 'hosts.col.actions': '操作';
26
- readonly 'hosts.test': '测试';
27
- readonly 'hosts.edit': '编辑';
28
- readonly 'hosts.delete': '删除';
29
- readonly 'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。';
30
- readonly 'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。';
31
- readonly 'hosts.testing': '连接测试中…';
32
- readonly 'hosts.testOk': '连接成功({latency} ms)';
33
- readonly 'hosts.testFail': '连接失败:{error}';
34
- readonly 'hosts.connected': '连接';
35
- readonly 'hosts.notConnected': '未连接';
36
- readonly 'form.title.create': '新增主机';
37
- readonly 'form.title.edit': '编辑主机 {alias}';
38
- readonly 'form.alias': '别名';
39
- readonly 'form.aliasHint': '小写字母、数字与单连字符,操作时使用';
40
- readonly 'form.host': '主机地址';
41
- readonly 'form.port': '端口';
42
- readonly 'form.user': '用户名';
43
- readonly 'form.auth': '认证方式';
44
- readonly 'form.auth.key': '密钥';
45
- readonly 'form.auth.password': '密码';
46
- readonly 'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。';
47
- readonly 'form.keyPath': '私钥路径';
48
- readonly 'form.keyPathHint': '如 ~/.ssh/id_ed25519';
49
- readonly 'form.passphrase': '密钥口令';
50
- readonly 'form.password': '密码';
51
- readonly 'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)';
52
- readonly 'form.proxyJump': '跳板机别名';
53
- readonly 'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接';
54
- readonly 'form.description': '备注';
55
- readonly 'form.environment': '环境';
56
- readonly 'form.environmentHint': '如 development / production';
57
- readonly 'form.tags': '标签';
58
- readonly 'form.tagsHint': '逗号分隔';
59
- readonly 'form.location': '位置';
60
- readonly 'form.save': '保存';
61
- readonly 'form.cancel': '取消';
62
- readonly 'form.required': '请填写必填字段:别名、主机地址、用户名。';
63
- readonly 'form.portInvalid': '端口必须是 1-65535 之间的整数。';
64
- readonly 'terminal.selectHost': '选择主机';
65
- readonly 'terminal.connect': '连接';
66
- readonly 'terminal.disconnect': '断开';
67
- readonly 'terminal.placeholder': '选择主机后点击「连接」打开远程终端。';
68
- readonly 'terminal.connecting': '正在连接…';
69
- readonly 'terminal.ready': '终端已连接({alias})';
70
- readonly 'terminal.exited': '终端已退出({alias})';
71
- readonly 'terminal.error': '终端错误:{error}';
72
- readonly 'transfer.selectHost': '选择主机';
73
- readonly 'transfer.remotePath': '远程路径';
74
- readonly 'transfer.remotePathHint': '如 /tmp/ 或 /home/user/app.tar.gz';
75
- readonly 'transfer.upload': '上传';
76
- readonly 'transfer.uploading': '正在上传 {file}…';
77
- readonly 'transfer.download': '下载';
78
- readonly 'transfer.downloading': '正在下载…';
79
- readonly 'transfer.done': '完成:{bytes} 字节';
80
- readonly 'transfer.failed': '传输失败:{error}';
81
- readonly 'transfer.browseRemote': '浏览远程目录';
82
- readonly 'transfer.refresh': '刷新';
83
- readonly 'transfer.name': '名称';
84
- readonly 'transfer.upLevel': '上级目录';
85
- readonly 'transfer.size': '大小';
86
- readonly 'transfer.modified': '修改时间';
87
- readonly 'transfer.bytes': '{value} B';
88
- readonly 'transfer.kib': '{value} KB';
89
- readonly 'transfer.mib': '{value} MB';
90
- readonly 'transfer.gib': '{value} GB';
91
- readonly 'transfer.percent': '{value}%';
92
- readonly 'transfer.speed': '{value}/s';
93
- readonly 'transfer.dir': '目录';
94
- readonly 'transfer.file': '文件';
95
- readonly 'tunnel.empty': '没有活跃的隧道。';
96
- readonly 'tunnel.start': '新建隧道';
97
- readonly 'tunnel.alias': '主机别名';
98
- readonly 'tunnel.remotePort': '远程端口';
99
- readonly 'tunnel.remoteHost': '远程主机';
100
- readonly 'tunnel.remoteHostHint': '默认 127.0.0.1(服务器自身)';
101
- readonly 'tunnel.localPort': '本地端口';
102
- readonly 'tunnel.localPortHint': '留空自动分配';
103
- readonly 'tunnel.stop': '停止';
104
- readonly 'tunnel.stopAll': '全部停止';
105
- readonly 'tunnel.stopAllConfirm': '确定停止全部隧道吗?';
106
- readonly 'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}';
107
- readonly 'tunnel.started': '隧道已建立:127.0.0.1:{localPort}';
108
- readonly 'tunnel.failed': '隧道启动失败:{error}';
109
- readonly 'cluster.command': '命令';
110
- readonly 'cluster.run': '执行';
111
- readonly 'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。';
112
- readonly 'cluster.aliases': '主机别名(逗号分隔,留空为全部)';
113
- readonly 'cluster.environment': '环境过滤';
114
- readonly 'cluster.tags': '标签过滤(逗号分隔)';
115
- readonly 'cluster.results': '执行结果';
116
- readonly 'cluster.empty': '输入命令后点击「执行」。';
117
- readonly 'cluster.noMatch': '没有匹配的主机。';
118
- readonly 'cluster.ok': '成功';
119
- readonly 'cluster.fail': '失败';
120
- readonly 'cluster.timeout': '超时';
121
- readonly 'cluster.col.alias': '主机';
122
- readonly 'cluster.col.status': '状态';
123
- readonly 'cluster.col.exit': '退出码';
124
- readonly 'cluster.col.stdout': '标准输出';
125
- readonly 'cluster.col.stderr': '标准错误';
126
- readonly 'cluster.col.error': '错误';
127
- readonly 'cluster.col.duration': '耗时';
128
- readonly 'common.loading': '加载中…';
129
- readonly 'common.error': '出错:{error}';
130
- readonly 'common.refresh': '刷新';
131
- readonly 'common.confirm': '确定';
5
+ readonly 'entry.label': "SSH";
6
+ readonly 'entry.tooltip': "远程 SSH 运维面板";
7
+ readonly 'panel.title': "远程 SSH";
8
+ readonly 'panel.backToConversation': "返回会话";
9
+ readonly 'tab.hosts': "主机";
10
+ readonly 'tab.terminal': "终端";
11
+ readonly 'tab.transfer': "传输";
12
+ readonly 'tab.tunnels': "隧道";
13
+ readonly 'tab.cluster': "集群";
14
+ readonly 'hosts.empty': "尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。";
15
+ readonly 'hosts.add': "新增主机";
16
+ readonly 'hosts.import': "导入 ~/.ssh/config";
17
+ readonly 'hosts.search': "搜索别名 / 描述 / 标签…";
18
+ readonly 'hosts.groupBy.label': "分组方式";
19
+ readonly 'hosts.groupBy.none': "不分组";
20
+ readonly 'hosts.groupBy.environment': "按环境分组";
21
+ readonly 'hosts.groupBy.tags': "按标签分组";
22
+ readonly 'hosts.group.ungrouped': "未设置环境";
23
+ readonly 'hosts.group.noTags': "无标签";
24
+ readonly 'hosts.group.count': "{count} 台";
25
+ readonly 'hosts.group.testAll': "测试全部";
26
+ readonly 'hosts.col.alias': "别名";
27
+ readonly 'hosts.col.host': "主机";
28
+ readonly 'hosts.col.user': "用户";
29
+ readonly 'hosts.col.auth': "认证";
30
+ readonly 'hosts.col.environment': "环境";
31
+ readonly 'hosts.col.tags': "标签";
32
+ readonly 'hosts.col.description': "备注";
33
+ readonly 'hosts.col.actions': "操作";
34
+ readonly 'hosts.test': "测试";
35
+ readonly 'hosts.edit': "编辑";
36
+ readonly 'hosts.delete': "删除";
37
+ readonly 'hosts.deleteConfirm': "确定删除主机「{alias}」?将同时停止其隧道。";
38
+ readonly 'hosts.imported': "解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。";
39
+ readonly 'hosts.testing': "连接测试中…";
40
+ readonly 'hosts.testOk': "连接成功({latency} ms)";
41
+ readonly 'hosts.testFail': "连接失败:{error}";
42
+ readonly 'hosts.connected': "连接";
43
+ readonly 'hosts.notConnected': "未连接";
44
+ readonly 'form.title.create': "新增主机";
45
+ readonly 'form.title.edit': "编辑主机 {alias}";
46
+ readonly 'form.alias': "别名";
47
+ readonly 'form.aliasHint': "小写字母、数字与单连字符,操作时使用";
48
+ readonly 'form.host': "主机地址";
49
+ readonly 'form.port': "端口";
50
+ readonly 'form.user': "用户名";
51
+ readonly 'form.auth': "认证方式";
52
+ readonly 'form.auth.key': "密钥";
53
+ readonly 'form.auth.password': "密码";
54
+ readonly 'form.authKeepHint': "编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。";
55
+ readonly 'form.keyPath': "私钥路径";
56
+ readonly 'form.keyPathHint': "如 ~/.ssh/id_ed25519";
57
+ readonly 'form.passphrase': "密钥口令";
58
+ readonly 'form.password': "密码";
59
+ readonly 'form.passwordHint': "明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)";
60
+ readonly 'form.proxyJump': "跳板机别名";
61
+ readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,按顺序连接";
62
+ readonly 'form.description': "备注";
63
+ readonly 'form.environment': "环境";
64
+ readonly 'form.environmentHint': "如 development / production";
65
+ readonly 'form.tags': "标签";
66
+ readonly 'form.tagsHint': "逗号分隔";
67
+ readonly 'form.location': "位置";
68
+ readonly 'form.save': "保存";
69
+ readonly 'form.cancel': "取消";
70
+ readonly 'form.required': "请填写必填字段:别名、主机地址、用户名。";
71
+ readonly 'form.portInvalid': "端口必须是 1-65535 之间的整数。";
72
+ readonly 'terminal.selectHost': "选择主机";
73
+ readonly 'terminal.connect': "连接";
74
+ readonly 'terminal.disconnect': "断开";
75
+ readonly 'terminal.placeholder': "选择主机后点击「连接」打开远程终端。";
76
+ readonly 'terminal.connecting': "正在连接…";
77
+ readonly 'terminal.ready': "终端已连接({alias})";
78
+ readonly 'terminal.exited': "终端已退出({alias})";
79
+ readonly 'terminal.error': "终端错误:{error}";
80
+ readonly 'transfer.selectHost': "选择主机";
81
+ readonly 'transfer.remotePath': "远程路径";
82
+ readonly 'transfer.remotePathHint': "如 /tmp/ 或 /home/user/app.tar.gz";
83
+ readonly 'transfer.upload': "上传";
84
+ readonly 'transfer.uploading': "正在上传 {file}…";
85
+ readonly 'transfer.download': "下载";
86
+ readonly 'transfer.downloading': "正在下载…";
87
+ readonly 'transfer.done': "完成:{bytes} 字节";
88
+ readonly 'transfer.failed': "传输失败:{error}";
89
+ readonly 'transfer.browseRemote': "浏览远程目录";
90
+ readonly 'transfer.refresh': "刷新";
91
+ readonly 'transfer.name': "名称";
92
+ readonly 'transfer.upLevel': "上级目录";
93
+ readonly 'transfer.size': "大小";
94
+ readonly 'transfer.modified': "修改时间";
95
+ readonly 'transfer.bytes': "{value} B";
96
+ readonly 'transfer.kib': "{value} KB";
97
+ readonly 'transfer.mib': "{value} MB";
98
+ readonly 'transfer.gib': "{value} GB";
99
+ readonly 'transfer.percent': "{value}%";
100
+ readonly 'transfer.speed': "{value}/s";
101
+ readonly 'transfer.dir': "目录";
102
+ readonly 'transfer.file': "文件";
103
+ readonly 'tunnel.empty': "没有活跃的隧道。";
104
+ readonly 'tunnel.start': "新建隧道";
105
+ readonly 'tunnel.alias': "主机别名";
106
+ readonly 'tunnel.remotePort': "远程端口";
107
+ readonly 'tunnel.remoteHost': "远程主机";
108
+ readonly 'tunnel.remoteHostHint': "默认 127.0.0.1(服务器自身)";
109
+ readonly 'tunnel.localPort': "本地端口";
110
+ readonly 'tunnel.localPortHint': "留空自动分配";
111
+ readonly 'tunnel.stop': "停止";
112
+ readonly 'tunnel.stopAll': "全部停止";
113
+ readonly 'tunnel.stopAllConfirm': "确定停止全部隧道吗?";
114
+ readonly 'tunnel.row': "{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}";
115
+ readonly 'tunnel.started': "隧道已建立:127.0.0.1:{localPort}";
116
+ readonly 'tunnel.failed': "隧道启动失败:{error}";
117
+ readonly 'cluster.command': "命令";
118
+ readonly 'cluster.run': "执行";
119
+ readonly 'cluster.confirm': "确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。";
120
+ readonly 'cluster.aliases': "主机别名(逗号分隔,留空为全部)";
121
+ readonly 'cluster.environment': "环境过滤";
122
+ readonly 'cluster.tags': "标签过滤(逗号分隔)";
123
+ readonly 'cluster.results': "执行结果";
124
+ readonly 'cluster.empty': "输入命令后点击「执行」。";
125
+ readonly 'cluster.noMatch': "没有匹配的主机。";
126
+ readonly 'cluster.ok': "成功";
127
+ readonly 'cluster.fail': "失败";
128
+ readonly 'cluster.timeout': "超时";
129
+ readonly 'cluster.col.alias': "主机";
130
+ readonly 'cluster.col.status': "状态";
131
+ readonly 'cluster.col.exit': "退出码";
132
+ readonly 'cluster.col.stdout': "标准输出";
133
+ readonly 'cluster.col.stderr': "标准错误";
134
+ readonly 'cluster.col.error': "错误";
135
+ readonly 'cluster.col.duration': "耗时";
136
+ readonly 'common.loading': "加载中…";
137
+ readonly 'common.error': "出错:{error}";
138
+ readonly 'common.refresh': "刷新";
139
+ readonly 'common.confirm': "确定";
132
140
  };
133
141
  export declare const en: Record<keyof typeof zh, string>;
134
142
  /** Locale key union. */
@@ -1,9 +1,25 @@
1
1
  import type { SshApi } from '../api.ts';
2
+ import type { SshHostSummary } from '../../protocol.ts';
2
3
  /** Hosts tab props. */
3
4
  export interface HostsTabProps {
4
5
  api: SshApi;
5
6
  /** Connect the given alias in the terminal tab. */
6
7
  onConnect: (alias: string) => void;
7
8
  }
9
+ /** Host list grouping modes (#379). */
10
+ export type HostGroupBy = 'none' | 'environment' | 'tags';
11
+ /** One collapsible group section of the grouped host list. */
12
+ export interface HostGroup {
13
+ /** Group key: the environment name, one tag, or '' for the ungrouped bucket. */
14
+ key: string;
15
+ hosts: SshHostSummary[];
16
+ }
17
+ /**
18
+ * Bucket hosts into collapsible groups (#379). Grouping by tags places a
19
+ * multi-tag host in every one of its tag groups (folder view); hosts without
20
+ * the grouping key land in the '' bucket, which always sorts last. Groups
21
+ * sort alphabetically; host order inside a group follows the API listing.
22
+ */
23
+ export declare function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[];
8
24
  /** The hosts table plus its toolbar and dialogs. */
9
25
  export declare function HostsTab({ api, onConnect }: HostsTabProps): import("react").JSX.Element;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared sidebar entry injection core.
3
+ *
4
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
5
+ * so the entry row is injected between the shell's New Session button and the
6
+ * workspace browser. The injection self-heals: a MutationObserver watches the
7
+ * sidebar root and re-inserts the row whenever a React re-render displaces it
8
+ * (re-insertion happens in the same frame, before paint, so no flicker).
9
+ *
10
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
11
+ * reconciliation; the view it toggles is a separate root owned by the caller.
12
+ *
13
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
14
+ * edit the shared source and re-run the sync instead of editing a copy.
15
+ */
16
+ /** Per-package configuration for one sidebar entry row. */
17
+ export interface SidebarEntryOptions {
18
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
19
+ rowAttribute: string;
20
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
21
+ rowSelector: string;
22
+ /**
23
+ * L2 semantic-attribute plugin id (issue #506, enum table:
24
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
25
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
26
+ * leaves the row without semantic attributes.
27
+ */
28
+ plugin?: string;
29
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
30
+ icon: string;
31
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
32
+ css: Record<string, string>;
33
+ /** Localized row label (aria-label + visible text). */
34
+ label(): string;
35
+ /** Optional localized tooltip (title attribute). */
36
+ tooltip?(): string;
37
+ /** Click action (open/toggle the owning panel). */
38
+ onToggle(): void;
39
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
40
+ position: 'before' | 'after';
41
+ /**
42
+ * Selectors of the sibling plugin entry rows this package orders against
43
+ * (its own row included — the placement guard excludes a row that is
44
+ * already inside the root). Each package passes the same list it used
45
+ * before the consolidation so the rendered order stays stable.
46
+ */
47
+ familySelectors: readonly string[];
48
+ /** Optional active-state bridge; highlights the row while the panel is open. */
49
+ active?: {
50
+ subscribe(listener: () => void): () => void;
51
+ isOpen(): boolean;
52
+ };
53
+ }
54
+ /**
55
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
56
+ * on later React re-renders.
57
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
58
+ * @returns disposer removing the entry and its observers.
59
+ */
60
+ export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
@@ -1,16 +1,12 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so — following the task-board precedent of DOM-level extension — the entry
6
- * row is injected between the shell's New Session button and the workspace
7
- * browser. The injection self-heals: a MutationObserver watches the sidebar
8
- * root and re-inserts the row whenever a React re-render displaces it
9
- * (re-insertion happens in the same frame, before paint, so no flicker).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the panel view it toggles is a separate React root mounted
13
- * in the center column (see mount.tsx).
4
+ * The DOM injection / self-healing / idempotency logic lives exactly once in
5
+ * shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
6
+ * ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
7
+ * React tree) so it can never disturb the shell's reconciliation; the panel
8
+ * view it toggles is a separate React root mounted in the center column
9
+ * (see mount.tsx).
14
10
  */
15
11
  import type { PanelController } from './panel/controller.ts';
16
12
  /** Stable data attribute identifying the injected entry row. */
@@ -164,7 +164,7 @@ export type TerminalClientFrame = {
164
164
  rows: number;
165
165
  };
166
166
  /** Route paths the client calls (shared literals). */
167
- export declare const SSH_API_BASE: '/api/dsh-ssh';
167
+ export declare const SSH_API_BASE: "/api/dsh-ssh";
168
168
  export declare const SSH_API: {
169
169
  readonly hosts: string;
170
170
  readonly importSshConfig: string;
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.1.20",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -49,17 +49,17 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@deepseek-ai/cordis": "^4.0.1",
52
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
57
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
61
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
52
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
53
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
54
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
55
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
56
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
57
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
58
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
59
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
60
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
61
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.7",
62
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
63
63
  "@types/node": "^22.20.0",
64
64
  "@types/react": "~18.3.1",
65
65
  "@types/react-dom": "^18.3.5",
@@ -17,6 +17,14 @@ export const zh = {
17
17
  'hosts.add': '新增主机',
18
18
  'hosts.import': '导入 ~/.ssh/config',
19
19
  'hosts.search': '搜索别名 / 描述 / 标签…',
20
+ 'hosts.groupBy.label': '分组方式',
21
+ 'hosts.groupBy.none': '不分组',
22
+ 'hosts.groupBy.environment': '按环境分组',
23
+ 'hosts.groupBy.tags': '按标签分组',
24
+ 'hosts.group.ungrouped': '未设置环境',
25
+ 'hosts.group.noTags': '无标签',
26
+ 'hosts.group.count': '{count} 台',
27
+ 'hosts.group.testAll': '测试全部',
20
28
  'hosts.col.alias': '别名',
21
29
  'hosts.col.host': '主机',
22
30
  'hosts.col.user': '用户',
@@ -153,6 +161,14 @@ export const en: Record<keyof typeof zh, string> = {
153
161
  'hosts.add': 'Add host',
154
162
  'hosts.import': 'Import ~/.ssh/config',
155
163
  'hosts.search': 'Search alias / description / tags…',
164
+ 'hosts.groupBy.label': 'Group by',
165
+ 'hosts.groupBy.none': 'No grouping',
166
+ 'hosts.groupBy.environment': 'Group by environment',
167
+ 'hosts.groupBy.tags': 'Group by tags',
168
+ 'hosts.group.ungrouped': 'No environment',
169
+ 'hosts.group.noTags': 'No tags',
170
+ 'hosts.group.count': '{count} hosts',
171
+ 'hosts.group.testAll': 'Test all',
156
172
  'hosts.col.alias': 'Alias',
157
173
  'hosts.col.host': 'Host',
158
174
  'hosts.col.user': 'User',
@@ -57,6 +57,7 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
57
57
  if (column === undefined) return
58
58
  container = document.createElement('div')
59
59
  container.dataset.dshSshView = ''
60
+ container.dataset.dshPlugin = 'ssh'
60
61
  container.className = css.view
61
62
  column.appendChild(container)
62
63
  root = createRoot(container)