@wenbin_wb/dsh-bridge 2.10.12 → 2.10.14

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/client/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  queuePendingOperation, unlockAdmin, onUnlocked,
5
5
  fetchLoopbackTokenOnce,
6
6
  } from './unlock-manager.js'
7
+ import { hasOfficialDirectoryPicker, shouldYieldToOfficialPicker } from './picker-yield.js'
7
8
  // dsh-bridge 客户端插件:设置页「远程访问」面板
8
9
 
9
10
  // 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
@@ -40,6 +41,21 @@ if (typeof window !== 'undefined') window.__dshResourceUrlCompat = RESOURCE_URL_
40
41
  // 避免 768px 单点上"桥渲染顶栏、宿主却按桌面(push)布局"的错位。
41
42
  const MOBILE_MAX_WIDTH = 767;
42
43
 
44
+ // 设置中心「两级钻取」的窄屏断点:必须与 mobile-styles.js 的
45
+ // `@media (max-width: 480px)` 6.2 块保持一致(改一处必须改另一处)。
46
+ // 481–767px 仍有 ~350px 以上内容宽度,保留左侧图标轨道即可;
47
+ // <=480px 时轨道要吃掉 21% 宽度、第三方设置节必然溢出,才切换为列表/详情两页。
48
+ const SETTINGS_DRILLDOWN_MAX_WIDTH = 480;
49
+ // 交互层的就绪开关,打在 <html> 上(监听器要在弹窗出现前装好,那时 panel 还不存在)。
50
+ // CSS 6.2 整块以它为前置选择器:开关没打开就整块惰性,<=480px 退回 88px 图标轨道,
51
+ // 不会出现「把内容藏了、却没人负责把用户带进去」的死列表。
52
+ const DRILLDOWN_GATE_ATTR = 'data-dshbr-drilldown';
53
+ const DRILLDOWN_GATE_READY = 'ready';
54
+ const DRILLDOWN_GATE_FLAG = 'dshBrDrilldownGate';
55
+ // 视图状态标记:打在宿主设置弹窗的 panel 上,CSS 6.2 块按此切换列表页/详情页。
56
+ const SETTINGS_VIEW_ATTR = 'data-dshbr-settings-view';
57
+ const SETTINGS_VIEW_DETAIL = 'detail';
58
+
43
59
  function isLocalEnvironment() {
44
60
  if (typeof window === 'undefined') return true;
45
61
  const host = window.location.hostname || '';
@@ -2607,7 +2623,11 @@ function VersionBanner({ rpcCall }) {
2607
2623
  gap: 8,
2608
2624
  },
2609
2625
  },
2610
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
2626
+ // 左组:版本徽标 + DSH 版本 + 检查更新。
2627
+ // flexWrap + minWidth:0 是必需的:此前只写 display:flex,左组既不能收缩也不能换行,
2628
+ // 会把整行顶到 305px;手机设置页内容列只有 277px,溢出部分被
2629
+ // overflow-x:hidden 静默裁掉(「检查更新」按钮被切一半)。
2630
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 } },
2611
2631
  // 版本状态徽标
2612
2632
  React.createElement('span', {
2613
2633
  style: {
@@ -2674,6 +2694,10 @@ function VersionBanner({ rpcCall }) {
2674
2694
  fontSize: 11,
2675
2695
  opacity: loading ? 0.5 : 1,
2676
2696
  gap: 4,
2697
+ // 按钮是 inline-flex,文字会成为匿名 flex 项;被挤窄时会把「检查更新」
2698
+ // 折成两行。nowrap + flexShrink:0 保证它整体换行(由父级 flexWrap 负责)。
2699
+ whiteSpace: 'nowrap',
2700
+ flexShrink: 0,
2677
2701
  },
2678
2702
  onClick: check,
2679
2703
  disabled: loading || upgrading || restarting,
@@ -2951,8 +2975,57 @@ const TABS = [
2951
2975
  { id: 'ops', label: '运维监控', icon: Icons.ops },
2952
2976
  ];
2953
2977
 
2978
+ // Tab 条在窄屏是横向可滑动的(5 个分类在手机内容列里放不下),但滚动条被
2979
+ // mobile-styles.js 整段隐藏(.dsh-tabbar-container),末尾分类又恰好被切断,
2980
+ // 用户既看不到"还有更多"、也不知道能横滑 —— 主观上就是"页面看不到"。
2981
+ // 这里补两件事:按可滚动方向做边缘渐隐提示;切换时把当前项滚进可视区。
2954
2982
  function TabBar({ active, onChange, dots }) {
2983
+ const scrollRef = React.useRef(null);
2984
+ const [edges, setEdges] = React.useState({ left: false, right: false });
2985
+
2986
+ React.useEffect(() => {
2987
+ const el = scrollRef.current;
2988
+ if (!el) return undefined;
2989
+ const update = () => {
2990
+ const max = el.scrollWidth - el.clientWidth;
2991
+ const left = el.scrollLeft > 1;
2992
+ const right = max > 1 && el.scrollLeft < max - 1;
2993
+ setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
2994
+ };
2995
+ update();
2996
+ el.addEventListener('scroll', update, { passive: true });
2997
+ let observer = null;
2998
+ if (typeof ResizeObserver === 'function') {
2999
+ observer = new ResizeObserver(update);
3000
+ observer.observe(el);
3001
+ }
3002
+ return () => {
3003
+ el.removeEventListener('scroll', update);
3004
+ if (observer) observer.disconnect();
3005
+ };
3006
+ }, []);
3007
+
3008
+ // 当前分类可能在可视区之外(例如从状态点跳进某个 Tab),切换后主动滚入
3009
+ React.useEffect(() => {
3010
+ const el = scrollRef.current;
3011
+ if (!el) return;
3012
+ const target = el.querySelector('[data-dsh-tab-active="true"]');
3013
+ if (!target) return;
3014
+ const elRect = el.getBoundingClientRect();
3015
+ const tabRect = target.getBoundingClientRect();
3016
+ if (tabRect.left < elRect.left) el.scrollLeft -= (elRect.left - tabRect.left) + 8;
3017
+ else if (tabRect.right > elRect.right) el.scrollLeft += (tabRect.right - elRect.right) + 8;
3018
+ }, [active]);
3019
+
3020
+ const maskStops = [];
3021
+ if (edges.left) maskStops.push('transparent 0px', '#000 20px');
3022
+ else maskStops.push('#000 0px');
3023
+ if (edges.right) maskStops.push('#000 calc(100% - 20px)', 'transparent 100%');
3024
+ else maskStops.push('#000 100%');
3025
+ const maskImage = `linear-gradient(to right, ${maskStops.join(', ')})`;
3026
+
2955
3027
  return React.createElement('div', {
3028
+ ref: scrollRef,
2956
3029
  className: 'dsh-tabbar-container',
2957
3030
  style: {
2958
3031
  display: 'flex', gap: 4, marginBottom: 20,
@@ -2961,6 +3034,8 @@ function TabBar({ active, onChange, dots }) {
2961
3034
  maxWidth: '100%', flexWrap: 'nowrap',
2962
3035
  scrollbarWidth: 'none',
2963
3036
  msOverflowStyle: 'none',
3037
+ WebkitMaskImage: maskImage,
3038
+ maskImage,
2964
3039
  },
2965
3040
  },
2966
3041
  TABS.map(({ id, label, icon: TabIcon }) => {
@@ -2968,6 +3043,7 @@ function TabBar({ active, onChange, dots }) {
2968
3043
  const hasDot = dots?.[id];
2969
3044
  return React.createElement('button', {
2970
3045
  key: id,
3046
+ 'data-dsh-tab-active': isActive ? 'true' : undefined,
2971
3047
  onClick: () => onChange(id),
2972
3048
  style: {
2973
3049
  font: 'inherit', cursor: 'pointer', border: 'none', background: 'none',
@@ -3756,6 +3832,86 @@ function injectMobileStyles() {
3756
3832
  document.head.appendChild(style);
3757
3833
  }
3758
3834
 
3835
+ // 设置中心两级钻取的交互层(配合 mobile-styles.js 6.2 的 <=480px 规则)。
3836
+ //
3837
+ // 为什么需要 JS:宿主的设置弹窗(@deepseek-ai/dsh-client-ui-settings-general)只有
3838
+ // 桌面布局,nav 与 content 是 panel 下的兄弟节点、由 React 按 activeId 渲染,
3839
+ // 没有任何"列表页/详情页"状态,也没有暴露给插件的 slot。
3840
+ //
3841
+ // 为什么用「委托点击 + 打 data 属性」而不是注入返回按钮:
3842
+ // panel 内部整棵树由 React 管理,往里插 DOM 会在 React 卸载/重渲染时触发
3843
+ // removeChild 失配。故不插入任何节点,只给 panel 打一个属性;详情页的返回入口
3844
+ // 复用宿主已有的标题行(CSS 把 navList 收起、给 navTitle 加 ‹ 箭头并显示 pointer),
3845
+ // 因此本函数对宿主 DOM 只做「读 + 打属性」,不改变结构。
3846
+ function setupSettingsDrilldown() {
3847
+ if (typeof document === 'undefined' || typeof window === 'undefined') return;
3848
+ const root = document.documentElement;
3849
+ // 幂等位刻意放在最前:它先于 matchMedia 守卫,故首次 apply() 时若 matchMedia 不可用,
3850
+ // 本会话内就不再尝试启用钻取。方向是 fail-safe —— 退回 88px 图标轨道、内容完整可达
3851
+ // (CSS 6.2 整段惰性),不会变成死列表。独立验收已确认该方向无风险。
3852
+ if (root.dataset[DRILLDOWN_GATE_FLAG] === '1') return;
3853
+ root.dataset[DRILLDOWN_GATE_FLAG] = '1';
3854
+
3855
+ // 断点谓词与 CSS 开关必须同源:两者都用这一个 MediaQueryList 实例。
3856
+ // 若让 CSS 走 `@media (max-width:480px)`、而 JS 各自去问 matchMedia,一旦
3857
+ // matchMedia 缺失或对该查询返回 false(polyfill/环境差异),就会出现
3858
+ // 「监听器装上了但恒不匹配 → 回调永远空转,而开关照常打开」的死列表。
3859
+ // 因此 matchMedia 不可用时不启用钻取,直接退回 88px 轨道。
3860
+ if (typeof window.matchMedia !== 'function') return;
3861
+ let narrowQuery;
3862
+ try {
3863
+ narrowQuery = window.matchMedia(`(max-width: ${SETTINGS_DRILLDOWN_MAX_WIDTH}px)`);
3864
+ } catch {
3865
+ return;
3866
+ }
3867
+ if (!narrowQuery) return;
3868
+ const isNarrow = () => narrowQuery.matches === true;
3869
+
3870
+ const onDocumentClick = (event) => {
3871
+ if (!isNarrow()) return;
3872
+ const target = event.target;
3873
+ if (!target || typeof target.closest !== 'function') return;
3874
+
3875
+ const panel = target.closest('div[class*="VOzbGW_panel"]');
3876
+ if (!panel) return;
3877
+ const nav = panel.querySelector('nav[class*="VOzbGW_nav"]');
3878
+ if (!nav || !nav.contains(target)) return;
3879
+
3880
+ // 点分类行 → 进入详情页(宿主自身的 onClick 负责切换 activeId,这里不拦截)
3881
+ if (target.closest('button[class*="VOzbGW_navCell"]')) {
3882
+ panel.setAttribute(SETTINGS_VIEW_ATTR, SETTINGS_VIEW_DETAIL);
3883
+ if (nav.scrollTop) nav.scrollTop = 0;
3884
+ return;
3885
+ }
3886
+
3887
+ // 详情页里点折叠后的标题行 → 返回分类列表
3888
+ if (panel.getAttribute(SETTINGS_VIEW_ATTR) === SETTINGS_VIEW_DETAIL) {
3889
+ panel.removeAttribute(SETTINGS_VIEW_ATTR);
3890
+ }
3891
+ };
3892
+
3893
+ const syncGate = () => {
3894
+ if (narrowQuery.matches === true) root.setAttribute(DRILLDOWN_GATE_ATTR, DRILLDOWN_GATE_READY);
3895
+ else root.removeAttribute(DRILLDOWN_GATE_ATTR);
3896
+ };
3897
+
3898
+ try {
3899
+ document.addEventListener('click', onDocumentClick, false);
3900
+ } catch {
3901
+ // 装不上监听器就不要打开开关:CSS 6.2 保持惰性,<=480px 退回 767 块的 88px 图标轨道
3902
+ return;
3903
+ }
3904
+
3905
+ // 断点变化(转屏/窗口缩放)时让开关跟随,避免开关与实际匹配状态漂移
3906
+ try {
3907
+ if (typeof narrowQuery.addEventListener === 'function') narrowQuery.addEventListener('change', syncGate);
3908
+ else if (typeof narrowQuery.addListener === 'function') narrowQuery.addListener(syncGate);
3909
+ } catch { /* 订阅失败不影响正确性:下面的 syncGate() 仍会按当前 matches 设一次 */ }
3910
+
3911
+ // 监听器装好之后才同步 CSS 开关(见 mobile-styles.js 6.2 块的安全说明)
3912
+ syncGate();
3913
+ }
3914
+
3759
3915
  function setupMobileExperience(rpcCall, ctx) {
3760
3916
  if (typeof document === 'undefined' || typeof window === 'undefined') return;
3761
3917
  injectMobileStyles();
@@ -4545,60 +4701,69 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
4545
4701
  async function switchToWorkspace(wsId, wsPath) {
4546
4702
  if (isSubmitting) return;
4547
4703
  isSubmitting = true;
4548
- statusMessage = `正在切换工作区…`;
4549
- isErrorMessage = false;
4550
- render();
4551
-
4552
- if (typeof onPicked === 'function' && wsPath) {
4553
- try {
4554
- onPicked(wsPath);
4555
- } catch (e) {}
4556
- }
4704
+ try {
4705
+ statusMessage = `正在切换工作区…`;
4706
+ isErrorMessage = false;
4707
+ render();
4557
4708
 
4558
- let switched = false;
4559
- if (clientCtx?.workspaces?.startSession && wsId) {
4560
- try {
4561
- clientCtx.workspaces.startSession(wsId);
4562
- switched = true;
4563
- } catch (e) {
4564
- console.warn('[dsh-bridge] startSession failed:', e);
4709
+ if (typeof onPicked === 'function' && wsPath) {
4710
+ try {
4711
+ onPicked(wsPath);
4712
+ } catch (e) {}
4565
4713
  }
4566
- }
4567
4714
 
4568
- if (!switched && wsPath) {
4569
- try {
4570
- if (clientCtx?.workspaces?.create) {
4571
- const ws = await clientCtx.workspaces.create({ path: wsPath });
4572
- if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
4573
- clientCtx.workspaces.startSession(ws.workspaceId);
4574
- switched = true;
4575
- }
4715
+ let switched = false;
4716
+ if (clientCtx?.workspaces?.startSession && wsId) {
4717
+ try {
4718
+ clientCtx.workspaces.startSession(wsId);
4719
+ switched = true;
4720
+ } catch (e) {
4721
+ console.warn('[dsh-bridge] startSession failed:', e);
4576
4722
  }
4577
- if (!switched) {
4578
- const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
4579
- const res = raw?.value || raw;
4580
- if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
4581
- try {
4582
- clientCtx.workspaces.startSession(res.workspaceId);
4723
+ }
4724
+
4725
+ if (!switched && wsPath) {
4726
+ try {
4727
+ if (clientCtx?.workspaces?.create) {
4728
+ const ws = await clientCtx.workspaces.create({ path: wsPath });
4729
+ if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
4730
+ clientCtx.workspaces.startSession(ws.workspaceId);
4583
4731
  switched = true;
4584
- } catch (e) {}
4732
+ }
4585
4733
  }
4586
- if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
4587
- try {
4588
- clientCtx.sessions.open(res.sessionId);
4589
- switched = true;
4590
- } catch (e) {}
4734
+ if (!switched) {
4735
+ const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
4736
+ const res = raw?.value || raw;
4737
+ if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
4738
+ try {
4739
+ clientCtx.workspaces.startSession(res.workspaceId);
4740
+ switched = true;
4741
+ } catch (e) {}
4742
+ }
4743
+ if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
4744
+ try {
4745
+ // 最后手段:直接打开会话。成功后无需再置位 switched ——
4746
+ // 此后不再有基于 switched 的分支(CodeQL js/useless-assignment-to-local)。
4747
+ clientCtx.sessions.open(res.sessionId);
4748
+ } catch (e) {}
4749
+ }
4591
4750
  }
4592
- }
4593
- } catch (e) {}
4594
- }
4751
+ } catch (e) {}
4752
+ }
4595
4753
 
4596
- statusMessage = `✓ 已切换至工作区!`;
4597
- render();
4598
- setTimeout(() => {
4599
- closeModal();
4600
- document.body.classList.remove('dsh-drawer-open');
4601
- }, 400);
4754
+ statusMessage = `✓ 已切换至工作区!`;
4755
+ render();
4756
+ setTimeout(() => {
4757
+ closeModal();
4758
+ document.body.classList.remove('dsh-drawer-open');
4759
+ }, 400);
4760
+ } finally {
4761
+ // 与 doSubmit 对称:本函数此前只置位、从不复位,导致点过一次「已注册工作区」后,
4762
+ // 同一弹窗实例的 isSubmitting 永久为 true,loadDirectory/doSubmit 的守卫
4763
+ // 让整个弹窗彻底无响应(点目录、点添加都没反应)。用 finally 兜住所有路径,
4764
+ // 包括 render() 抛错以及将来新增的提前 return。
4765
+ isSubmitting = false;
4766
+ }
4602
4767
  }
4603
4768
 
4604
4769
  async function loadDirectory(targetPath) {
@@ -4708,8 +4873,9 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
4708
4873
 
4709
4874
  if (!switched && clientCtx?.sessions?.open && res.sessionId) {
4710
4875
  try {
4876
+ // 最后手段:直接打开会话。此后不再读取 switched
4877
+ // (CodeQL js/useless-assignment-to-local),故不再置位。
4711
4878
  clientCtx.sessions.open(res.sessionId);
4712
- switched = true;
4713
4879
  } catch (e) {
4714
4880
  console.warn('[dsh-bridge] sessions.open failed:', e);
4715
4881
  }
@@ -5028,6 +5194,9 @@ function setupComposerCollapse() {
5028
5194
 
5029
5195
  function apply(ctx) {
5030
5196
  window.__dshClientCtx = ctx;
5197
+ // 放在最前:交互层先就绪,后续任何初始化抛异常都不会留下「CSS 生效但监听器缺失」
5198
+ // 的状态(CSS 6.2 块另有 <html> 就绪开关双重兜底)。
5199
+ setupSettingsDrilldown();
5031
5200
  const rpcCall = (endpoint, payload, signal) =>
5032
5201
  ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
5033
5202
 
@@ -5041,9 +5210,22 @@ function apply(ctx) {
5041
5210
 
5042
5211
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
5043
5212
 
5044
- // 注册至 DSH 原生目录选择 Slot(设置 priority: -10 覆盖原生 Electron 选择器,在远程/移动网页端生效)
5213
+ // 注册至 DSH 原生目录选择 Slot(priority: -10 覆盖原生 Electron 选择器,在远程/移动网页端生效)
5214
+ //
5215
+ // 但 DSH 0.1.5 起自带官方目录选择器(@deepseek-ai/dsh-host-directory-picker-auto
5216
+ // 按宿主能力分发 native / browse),且注册到完全相同的两个 Slot。DSH 的 slots 是
5217
+ // shadow 语义(动态注册的插件天然覆盖内置实现,详见 client/picker-yield.js),
5218
+ // 因此本机 + 官方 picker 可用时必须「不注册」才能让位 —— 用低优先级注册是无效的,
5219
+ // 否则本机 127.0.0.1 点「添加工作区」弹的是插件的远程抽屉(issue #28 第 2 条)。
5220
+ // 旧版 DSH(无官方 picker)与远程/移动访问继续由插件接管,后者保留管理密码解锁
5221
+ // 与 local_only 目录策略(换成官方 picker 会绕过插件的访问限制)。
5045
5222
  ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
5046
5223
  ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
5224
+ if (shouldYieldToOfficialPicker({
5225
+ local: isLocalEnvironment(),
5226
+ officialPicker: hasOfficialDirectoryPicker(ctx),
5227
+ })) return;
5228
+
5047
5229
  yield ctx.slots.register(
5048
5230
  {
5049
5231
  name: 'conversation.hero.workspace.directoryFlow',