@mrweicodes/dsh-permgate 1.3.11 → 1.5.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.
Files changed (4) hide show
  1. package/README.md +211 -211
  2. package/client.js +247 -36
  3. package/index.js +1457 -392
  4. package/package.json +12 -2
package/client.js CHANGED
@@ -261,6 +261,7 @@ window.__ModuleLoader__.load({
261
261
  'permgate:reload': ['POST', '/permgate/reload'],
262
262
  'permgate:open-config': ['POST', '/permgate/open-config'],
263
263
  'permgate:open-file': ['POST', '/permgate/open-file'],
264
+ 'permgate:set-fallback': ['POST', '/permgate/set-fallback'],
264
265
  };
265
266
  function call(method, args) {
266
267
  const entry = ROUTES[method] || ['GET', '/permgate/status'];
@@ -659,17 +660,31 @@ window.__ModuleLoader__.load({
659
660
  };
660
661
  }
661
662
 
662
- const CATS = ['directory', 'command', 'read', 'edit', 'subagent', 'doomloop'];
663
- const EXC_CATS = ['directory', 'command', 'read', 'edit'];
663
+ // 分类清单默认值:以宿主下发为准(见 applyStatusLists),避免双端各存一份而漂移
664
+ let CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo', 'subagent', 'doomloop'];
665
+ let EXC_CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo'];
666
+ function applyStatusLists(s) {
667
+ if (s && Array.isArray(s.cats) && s.cats.length) CATS = s.cats;
668
+ if (s && Array.isArray(s.excCats) && s.excCats.length) EXC_CATS = s.excCats;
669
+ if (s && Array.isArray(s.modes) && s.modes.length) MODES = s.modes;
670
+ if (s && Array.isArray(s.allModes) && s.allModes.length) ALL_MODES = s.allModes;
671
+ if (s && Array.isArray(s.quickPreset) && s.quickPreset.length) QUICK_PRESET = s.quickPreset;
672
+ if (s && s.quickDefaults && typeof s.quickDefaults === 'object') QUICK_DEFAULTS = s.quickDefaults;
673
+ }
664
674
  // 例外列表超过该数量默认折叠(展开/折叠按钮在标题行右侧)
665
675
  const EXC_COLLAPSE_THRESHOLD = 6;
666
676
  // 文件对比缓存:按审批 id 缓存 /permgate/file-diff 结果(弹窗详情与右侧抽屉共用)
667
677
  const diffCache = new Map();
668
678
  const diffFetching = new Set();
669
- const ALL_MODES = ['ask', 'allow', 'deny', 'inherit'];
670
- const MODES = ['ask', 'allow', 'deny'];
679
+ let ALL_MODES = ['ask', 'allow', 'deny', 'inherit'];
680
+ let MODES = ['ask', 'allow', 'deny'];
671
681
  const MODE_COLORS = { ask: '#e65100', allow: '#2e7d32', deny: '#c62828', inherit: '#888' };
672
- const QUICK_PRESET = ['web_search', 'skill', 'grep', 'glob', 'web_fetch'];
682
+ // 快捷工具预设清单与默认动作都由宿主下发(permgate:status quickPreset/quickDefaults),
683
+ // 与 CATS/MODES 同口径,避免宿主与浏览器各存一份清单、新增工具时漂移;
684
+ // 未显式配置的工具按「项目键 → 全局键 → 预设默认 → 会话兜底」显示,与服务端 quickAction 同链。
685
+ let QUICK_PRESET = [];
686
+ let QUICK_DEFAULTS = {};
687
+ let QUICK_FALLBACK = 'ask';
673
688
 
674
689
  // ── 中英文适配:字典 + locale 绑定(跟随 dsh 语言设置自动切换)──────────
675
690
  let LC = null; // apply 时注入的 ctx
@@ -694,6 +709,10 @@ window.__ModuleLoader__.load({
694
709
  'app.diffMore': '其余 {n} 行',
695
710
  'app.diffLoading': '加载中…',
696
711
  'app.diffErr': '无法生成对比',
712
+ 'app.imageTag': '图片',
713
+ 'app.imageTooLarge': '图片过大,未生成缩略图',
714
+ 'app.imageSizeUnknown': '无法确认图片尺寸,未生成缩略图',
715
+ 'app.imageNoPreview': '暂无可用的缩略图',
697
716
  'app.uiErr': '界面渲染出错',
698
717
  'app.eolNote': '行尾已按 LF 归一化匹配(磁盘为 CRLF/CR 而参数为 LF):预览仅为意图展示,实际编辑可能因行尾不一致失败',
699
718
  'app.diffGapShow': '显示 {n} 行未更改',
@@ -718,13 +737,17 @@ window.__ModuleLoader__.load({
718
737
  'cat.directory': '目录访问(工作区外)',
719
738
  'cat.command': '执行命令',
720
739
  'cat.read': '读取文件',
740
+ 'cat.image': '读取图片',
721
741
  'cat.edit': '编辑文件',
742
+ 'cat.undo': '撤销操作(恢复上次编辑前的内容)',
722
743
  'cat.subagent': '启动子代理',
723
744
  'cat.doomloop': '重复操作(Doom Loop)',
724
745
  'catS.directory': '目录',
725
746
  'catS.command': '命令',
726
747
  'catS.read': '读取',
748
+ 'catS.image': '图片',
727
749
  'catS.edit': '编辑',
750
+ 'catS.undo': '撤销',
728
751
  'catS.subagent': '子代理',
729
752
  'catS.doomloop': '循环',
730
753
  'mode.ask': '询问',
@@ -758,11 +781,10 @@ window.__ModuleLoader__.load({
758
781
  'sandbox.desc.fa': '文件操作不受工作区限制',
759
782
  'sandbox.desc.inherit': '跟随全局设置(当前全局:{g})',
760
783
  'panel.quick': '快捷工具(其他工具快速设置)',
761
- 'panel.quickHint': '适用于网页搜索、技能加载、grep/glob 等未归类的工具;改动即时生效。',
784
+ 'panel.quickHint': '无文件/命令语义的工具,按工具名设定默认动作;不在清单里的工具走兜底策略,改动即时生效。',
762
785
  'panel.quickAdd': '新增工具名(如 todo_write,支持通配)',
763
- 'panel.quickAddBtn': '添加(默认允许)',
786
+ 'panel.quickAddBtn': '添加',
764
787
  'panel.rules': '自定义规则(通用匹配)',
765
- 'panel.rulesHint': '优先级:自定义规则 > 分类例外 > 分类默认/快捷工具。',
766
788
  'panel.actionDeny': 'deny 拒绝',
767
789
  'panel.actionAsk': 'ask 审批',
768
790
  'panel.actionAllow': 'allow 放行',
@@ -784,12 +806,39 @@ window.__ModuleLoader__.load({
784
806
  'panel.savedToProject': '已保存到当前项目',
785
807
  'panel.all': '(全部)',
786
808
  'panel.sandboxSaved': '底层沙箱已保存({t}):{v}',
809
+ 'panel.fallbackSaved': '兜底策略已保存({t}):{v}',
810
+ 'panel.fallback': '兜底策略(未匹配任何规则)',
811
+ 'panel.fallbackHint': '以上各分类之外、且不在快捷工具清单里的调用(如 mcp__*、cordis_run)如何处理。默认「询问」:每个未匹配的调用都会弹出审批。',
787
812
  'panel.needValue': '请先输入例外匹配',
813
+ 'panel.excRemaining': '该路径仍有 {n} 条同值例外,可继续逐条删除',
814
+ 'panel.delFailed': '删除失败',
788
815
  'panel.needTool': '请输入工具名',
789
816
  'panel.excCount': '例外({n})',
790
817
  'panel.excCmdHint': ':匹配命令子串/glob,优先于分类默认',
791
818
  'panel.excPathHint': ':路径 glob,优先于分类默认',
792
819
  'panel.excNone': '无例外',
820
+ 'quick.web_search': '网页搜索',
821
+ 'quick.skill': '加载技能',
822
+ 'quick.grep': '内容检索',
823
+ 'quick.glob': '按名查找文件',
824
+ 'quick.web_fetch': '抓取网页内容',
825
+ 'quick.ask_user_question': '向你提问',
826
+ 'quick.todo_write': '任务清单',
827
+ 'quick.list_agents': '列出子代理',
828
+ 'quick.job_list': '后台任务列表',
829
+ 'quick.job_output': '读取任务输出',
830
+ 'quick.job_kill': '终止后台任务',
831
+ 'quick.get_goal': '查看当前目标',
832
+ 'quick.create_goal': '创建目标',
833
+ 'quick.update_goal': '更新目标状态',
834
+ 'quick.send_message': '给子代理发消息',
835
+ 'quick.interrupt_agent': '打断子代理',
836
+ 'quick.present': '声明交付文件',
837
+ 'quick.exit_plan_mode': '退出计划模式',
838
+ 'quick.cordis_define': '定义动态插件',
839
+ 'quick.cordis_inspect_list': '检视能力清单',
840
+ 'quick.cordis_inspect_query': '执行只读检视',
841
+ 'quick.cordis_inspect_self': '检视本会话插件',
793
842
  'panel.excExpand': '展开',
794
843
  'panel.excCollapse': '折叠',
795
844
  'panel.excListLink': '例外列表',
@@ -825,6 +874,10 @@ window.__ModuleLoader__.load({
825
874
  'app.diffMore': '{n} more lines',
826
875
  'app.diffLoading': 'Loading…',
827
876
  'app.diffErr': 'Cannot build comparison',
877
+ 'app.imageTag': 'Image',
878
+ 'app.imageTooLarge': 'Image too large; thumbnail not generated',
879
+ 'app.imageSizeUnknown': 'Image size could not be determined; thumbnail not generated',
880
+ 'app.imageNoPreview': 'No thumbnail available',
828
881
  'app.uiErr': 'UI render error',
829
882
  'app.eolNote': 'Line endings normalized to LF for matching (CRLF/CR file with LF args): preview is indicative only; the actual edit may fail on line-ending mismatch',
830
883
  'app.diffGapShow': 'Show {n} unchanged lines',
@@ -849,13 +902,17 @@ window.__ModuleLoader__.load({
849
902
  'cat.directory': 'Directory access (outside workspace)',
850
903
  'cat.command': 'Run command',
851
904
  'cat.read': 'Read file',
905
+ 'cat.image': 'Read image',
852
906
  'cat.edit': 'Edit file',
907
+ 'cat.undo': 'Undo edit (revert last edit)',
853
908
  'cat.subagent': 'Spawn subagent',
854
909
  'cat.doomloop': 'Doom Loop',
855
910
  'catS.directory': 'Dir',
856
911
  'catS.command': 'Cmd',
857
912
  'catS.read': 'Read',
913
+ 'catS.image': 'Image',
858
914
  'catS.edit': 'Edit',
915
+ 'catS.undo': 'Undo',
859
916
  'catS.subagent': 'Sub',
860
917
  'catS.doomloop': 'Loop',
861
918
  'mode.ask': 'Ask',
@@ -889,11 +946,10 @@ window.__ModuleLoader__.load({
889
946
  'sandbox.desc.fa': 'File operations are not workspace-restricted',
890
947
  'sandbox.desc.inherit': 'Follows global (currently: {g})',
891
948
  'panel.quick': 'Quick tools (other tools)',
892
- 'panel.quickHint': 'For uncategorized tools like web search, skills, grep/glob; changes apply immediately.',
949
+ 'panel.quickHint': 'Tools without file/command semantics: set a default per tool name; anything outside this list follows the fallback policy. Changes apply immediately.',
893
950
  'panel.quickAdd': 'New tool name (e.g. todo_write, wildcards allowed)',
894
- 'panel.quickAddBtn': 'Add (allow by default)',
951
+ 'panel.quickAddBtn': 'Add',
895
952
  'panel.rules': 'Custom rules (generic matching)',
896
- 'panel.rulesHint': 'Priority: custom rules > category exceptions > category default / quick tools.',
897
953
  'panel.actionDeny': 'deny Deny',
898
954
  'panel.actionAsk': 'ask Ask',
899
955
  'panel.actionAllow': 'allow Allow',
@@ -912,12 +968,39 @@ window.__ModuleLoader__.load({
912
968
  'panel.openConfigDone': 'Opened with default editor',
913
969
  'panel.saved': 'Saved',
914
970
  'panel.sandboxSaved': 'Sandbox saved ({t}): {v}',
971
+ 'panel.fallbackSaved': 'Fallback policy saved ({t}): {v}',
972
+ 'panel.fallback': 'Fallback policy (no rule matched)',
973
+ 'panel.fallbackHint': 'How calls outside the categories above and not listed as quick tools (e.g. mcp__*, cordis_run) are handled. Default Ask: every unmatched call prompts for approval.',
915
974
  'panel.needValue': 'Enter a match value first',
975
+ 'panel.excRemaining': '{n} exception(s) with the same value remain on this path; delete them individually if not wanted',
976
+ 'panel.delFailed': 'Delete failed',
916
977
  'panel.needTool': 'Enter a tool name',
917
978
  'panel.excCount': 'Exceptions ({n})',
918
979
  'panel.excCmdHint': ': command substring/glob, overrides category default',
919
980
  'panel.excPathHint': ': path glob, overrides category default',
920
981
  'panel.excNone': 'No exceptions',
982
+ 'quick.web_search': 'web search',
983
+ 'quick.skill': 'load skill',
984
+ 'quick.grep': 'content search',
985
+ 'quick.glob': 'find files by name',
986
+ 'quick.web_fetch': 'fetch page content',
987
+ 'quick.ask_user_question': 'ask you a question',
988
+ 'quick.todo_write': 'todo list',
989
+ 'quick.list_agents': 'list subagents',
990
+ 'quick.job_list': 'list background jobs',
991
+ 'quick.job_output': 'read job output',
992
+ 'quick.job_kill': 'kill a background job',
993
+ 'quick.get_goal': 'view current goal',
994
+ 'quick.create_goal': 'create a goal',
995
+ 'quick.update_goal': 'update goal state',
996
+ 'quick.send_message': 'message a subagent',
997
+ 'quick.interrupt_agent': 'interrupt a subagent',
998
+ 'quick.present': 'present deliverable files',
999
+ 'quick.exit_plan_mode': 'exit plan mode',
1000
+ 'quick.cordis_define': 'define a dynamic plugin',
1001
+ 'quick.cordis_inspect_list': 'list inspect capabilities',
1002
+ 'quick.cordis_inspect_query': 'run a read-only inspect query',
1003
+ 'quick.cordis_inspect_self': 'inspect session plugins',
921
1004
  'panel.excExpand': 'Expand',
922
1005
  'panel.excCollapse': 'Collapse',
923
1006
  'panel.excListLink': 'Exception list',
@@ -946,6 +1029,23 @@ window.__ModuleLoader__.load({
946
1029
  function catLabel(c) { return T('cat.' + c); }
947
1030
  function catShort(c) { return T('catS.' + c); }
948
1031
  function modeLabel(m) { return T('mode.' + m); }
1032
+ // 快捷工具的用途说明:仅当 i18n 有对应文案时显示(用户自加的工具名不显示说明)
1033
+ function quickDesc(t) {
1034
+ const k = 'quick.' + t;
1035
+ const d = T(k);
1036
+ return d && d !== k ? d : '';
1037
+ }
1038
+
1039
+ // 只认「配置里自己写入的键」:不能用 o[k] !== undefined 判断,那会把 Object.prototype 的成员
1040
+ // (constructor/toString 等)当成「该层已配置」,与服务端 hasOwnProperty 的口径分叉。
1041
+ function hasOwnKey(o, k) { return !!(o && Object.prototype.hasOwnProperty.call(o, k)); }
1042
+
1043
+ // 未显式配置工具的全局列显示值:与服务端 quickAction 同一条链(全局键 → 预设默认 → 会话兜底)
1044
+ function quickGlobalValue(t, gq) {
1045
+ if (hasOwnKey(gq, t)) return gq[t];
1046
+ if (hasOwnKey(QUICK_DEFAULTS, t)) return QUICK_DEFAULTS[t];
1047
+ return QUICK_FALLBACK;
1048
+ }
949
1049
  const FONT = 'system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
950
1050
  const card = { border: '1px solid rgba(128,128,128,0.35)', borderRadius: 8, padding: 12, marginBottom: 12 };
951
1051
  const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid rgba(128,128,128,0.15)', flexWrap: 'wrap' };
@@ -981,8 +1081,11 @@ window.__ModuleLoader__.load({
981
1081
  for (const key of diffFetching) if (!aliveIds.has(key)) diffFetching.delete(key);
982
1082
  for (const p of pending) {
983
1083
  if (!p.hasDiff || diffCache.has(p.id) || diffFetching.has(p.id)) continue
984
- const open = openDetail[p.id] === undefined ? true : openDetail[p.id] === true
1084
+ const userOpened = openDetail[p.id] === true
1085
+ const open = openDetail[p.id] === undefined ? true : userOpened
985
1086
  if (!open) continue
1087
+ // 图片详情是整图 data URL(体积大):不做自动预取,只有用户主动展开该条时才拉取
1088
+ if (p.imagePreview && !userOpened) continue
986
1089
  diffFetching.add(p.id)
987
1090
  schedulePrismIdle()
988
1091
  call('permgate:file-diff', { id: p.id }).then((r) => {
@@ -1007,24 +1110,36 @@ window.__ModuleLoader__.load({
1007
1110
  else next[candId] = v;
1008
1111
  setSel(next);
1009
1112
  };
1113
+ const pickedRules = (p) => (p.candidates || []).filter((c) => sel[c.id]).map((c) => ({ id: c.id, value: c.value, kind: c.kind, decision: sel[c.id] }));
1114
+ // 拒绝语义下只接受 deny 方向的规则:候选行残留的「允许此项」勾选绝不能变成持久 allow
1115
+ const pickedDenyRules = (p) => pickedRules(p).filter((r) => r.decision === 'deny');
1010
1116
  const submit = (p, action) => {
1011
- const rules = (p.candidates || []).filter((c) => sel[c.id]).map((c) => ({ value: c.value, kind: c.kind, decision: sel[c.id] }));
1012
- decide(p.id, action, rules);
1117
+ decide(p.id, action, pickedRules(p));
1013
1118
  };
1014
1119
  // 两段式拒绝:确认阶段提交(携带意见)或取消返回
1015
1120
  const confirmDeny = (p) => {
1016
1121
  const reason = (denyText[p.id] || '').trim();
1017
1122
  setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1018
- decide(p.id, 'deny', [], reason || undefined);
1123
+ decide(p.id, 'deny', pickedDenyRules(p), reason || undefined);
1019
1124
  };
1020
1125
  const cancelDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1021
- const radio = (p, c, v, label, cls) => React.createElement('button', {
1022
- className: 'pg-radio' + (sel[c.id] === v ? ' ' + cls : ''),
1023
- disabled: busyId === p.id,
1024
- onClick: () => pick(c.id, v),
1025
- }, label);
1126
+ // 进入拒绝态:只切状态,不动用户已勾选的内容。拒绝语义下「允许此项」由 radio 置灰,
1127
+ // 确认拒绝时也只提交 deny 方向(pickedDenyRules),因此点「取消」能原样回到进入前的勾选。
1128
+ const enterDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: true }));
1129
+ const radio = (p, c, v, label, cls) => {
1130
+ // 拒绝态下「允许此项」不生效(confirmDeny 只提交 deny 方向):置灰禁用而不是清除勾选,
1131
+ // 这样点「取消」保留进入前选择,点「确认拒绝」也不会把 allow 方向写进配置。
1132
+ const muted = v === 'allow' && !!denyMode[p.id];
1133
+ return React.createElement('button', {
1134
+ className: 'pg-radio' + (sel[c.id] === v ? ' ' + cls : ''),
1135
+ disabled: busyId === p.id || muted,
1136
+ style: muted ? { opacity: 0.45, cursor: 'not-allowed' } : undefined,
1137
+ onClick: () => pick(c.id, v),
1138
+ }, label);
1139
+ };
1026
1140
  // 编辑/写入审批(有 diff):详情默认展开、参数默认收起;无 diff 时参数照常显示
1027
- const detailOpen = (p) => (openDetail[p.id] === undefined ? !!p.hasDiff : openDetail[p.id]);
1141
+ // 图片详情是整图 data URL(体积大):默认收起,用户点开该条时才拉取
1142
+ const detailOpen = (p) => (openDetail[p.id] === undefined ? (!!p.hasDiff && !p.imagePreview) : openDetail[p.id]);
1028
1143
  const argsOpen = (p) => (openArgs[p.id] === undefined ? !p.hasDiff : openArgs[p.id]);
1029
1144
  const toggle = (map, setMap, p, v) => setMap(Object.assign({}, map, { [p.id]: v }));
1030
1145
  // 点击文件名 → 打开右侧对比抽屉(父组件 OverlayRoot 持有 pin 状态)
@@ -1066,9 +1181,7 @@ window.__ModuleLoader__.load({
1066
1181
  const c = diffCache.get(p.id)
1067
1182
  if (!c) return React.createElement('div', { className: 'pg2-load' }, T('app.diffLoading'))
1068
1183
  if (!c.ok) return React.createElement('div', { className: 'pg2-err' }, (c.error || T('app.diffErr')))
1069
- return c.kind === 'read'
1070
- ? React.createElement(PGErrorBoundary, { resetKey: payloadKey(c), fallback: T('app.diffErr') }, React.createElement(ReadBlockMemo, { data: c, onOpenFile: (f) => openFile(p, f) }))
1071
- : React.createElement(PGErrorBoundary, { resetKey: payloadKey(c), fallback: T('app.diffErr') }, React.createElement(DiffBlockMemo, { data: c, onOpenFile: (f) => openFile(p, f), changesOnly: true }))
1184
+ return detailBody(c, { onOpenFile: (f) => openFile(p, f), changesOnly: true, resetKey: payloadKey(c) })
1072
1185
  })() : null,
1073
1186
  ) : null,
1074
1187
  (p.candidates || []).length ? React.createElement('div', null,
@@ -1093,7 +1206,7 @@ window.__ModuleLoader__.load({
1093
1206
  React.createElement('button', { className: 'pg-action', disabled: busyId === p.id, onClick: () => cancelDeny(p) }, T('panel.cancel')),
1094
1207
  ),
1095
1208
  ) : React.createElement('div', { className: 'pg-footer' },
1096
- React.createElement('button', { className: 'pg-action pg-action-deny', disabled: busyId === p.id, onClick: () => setDenyMode(Object.assign({}, denyMode, { [p.id]: true })) }, T('app.deny')),
1209
+ React.createElement('button', { className: 'pg-action pg-action-deny', disabled: busyId === p.id, onClick: () => enterDeny(p) }, T('app.deny')),
1097
1210
  React.createElement('button', { className: 'pg-action pg-action-allow', disabled: busyId === p.id, onClick: () => submit(p, 'allow') }, T('app.allow')),
1098
1211
  ),
1099
1212
  )),
@@ -1323,6 +1436,49 @@ window.__ModuleLoader__.load({
1323
1436
  const diffPropsEqual = (a, b) => a.data === b.data && a.changesOnly === b.changesOnly;
1324
1437
  const DiffBlockMemo = React.memo(DiffBlock, diffPropsEqual);
1325
1438
  const ReadBlockMemo = React.memo(ReadBlock, diffPropsEqual);
1439
+
1440
+ // kind → 详情组件 分派单点:待审批内联详情与右侧对比抽屉共用,新增 kind(如 image)只改这一处,
1441
+ // 避免两处漏改导致某处把数据落到 DiffBlock,被错误边界兜成「无法生成对比」。
1442
+ function detailBody(data, opts) {
1443
+ const o = opts || {};
1444
+ const fb = T('app.diffErr');
1445
+ const boundaryProps = o.resetKey !== undefined ? { resetKey: o.resetKey, fallback: fb } : { fallback: fb };
1446
+ if (data.kind === 'image') return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(ImageBlock, { data }));
1447
+ if (data.kind === 'read') return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(ReadBlockMemo, { data, onOpenFile: o.onOpenFile || null, onCollapse: o.onCollapse }));
1448
+ return React.createElement(PGErrorBoundary, boundaryProps, React.createElement(DiffBlockMemo, { data, onOpenFile: o.onOpenFile || null, onCollapse: o.onCollapse, changesOnly: o.changesOnly }));
1449
+ }
1450
+
1451
+ // 图片详情块:头部显示 格式 · 像素尺寸 · 体积,下面给缩略图;
1452
+ // 超过上限或读取失败时不给图片本体,改显示说明文本(宿主下发的 error 由外层按 .pg2-err 渲染)。
1453
+ function ImageBlock({ data }) {
1454
+ const fmtBytes = (n) => {
1455
+ const v = Number(n) || 0;
1456
+ if (v < 1024) return v + ' B';
1457
+ if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
1458
+ return (v / (1024 * 1024)).toFixed(2) + ' MB';
1459
+ };
1460
+ const meta = [
1461
+ String(data.format || '').toUpperCase(),
1462
+ data.width && data.height ? data.width + '×' + data.height : '',
1463
+ data.size ? fmtBytes(data.size) : '',
1464
+ ].filter(Boolean).join(' · ');
1465
+ const note = data.sizeUnknown
1466
+ ? T('app.imageSizeUnknown')
1467
+ : data.tooLarge
1468
+ ? T('app.imageTooLarge') + (data.limit ? '(' + fmtBytes(data.limit) + ')' : '')
1469
+ : T('app.imageNoPreview');
1470
+ return React.createElement('div', { className: 'pg2-block' },
1471
+ React.createElement('div', { className: 'pg2-header' },
1472
+ React.createElement('span', { className: 'pg2-status pg2-status-read' }, T('app.imageTag')),
1473
+ React.createElement('span', { className: 'pg2-path', title: data.file }, data.file),
1474
+ meta ? React.createElement('span', { style: { marginLeft: 'auto', color: 'rgba(128,128,128,0.9)' } }, meta) : null,
1475
+ ),
1476
+ data.dataUrl
1477
+ ? React.createElement('div', { style: { padding: 10, display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'auto', background: 'rgba(128,128,128,0.06)' } },
1478
+ React.createElement('img', { src: data.dataUrl, alt: data.file, style: { maxWidth: '100%', maxHeight: 320, objectFit: 'contain', borderRadius: 4, display: 'block' } }))
1479
+ : React.createElement('div', { className: 'pg2-foot' }, note),
1480
+ );
1481
+ }
1326
1482
  function CompareDrawer({ pin, onClose }) {
1327
1483
  const [data, setData] = React.useState(null);
1328
1484
  const [err, setErr] = React.useState('');
@@ -1388,9 +1544,7 @@ window.__ModuleLoader__.load({
1388
1544
  };
1389
1545
  let body;
1390
1546
  if (data) {
1391
- body = data.kind === 'read'
1392
- ? React.createElement(PGErrorBoundary, { fallback: T('app.diffErr') }, React.createElement(ReadBlockMemo, { data, onOpenFile: null, onCollapse: onClose }))
1393
- : React.createElement(PGErrorBoundary, { fallback: T('app.diffErr') }, React.createElement(DiffBlockMemo, { data, onOpenFile: null, onCollapse: onClose }));
1547
+ body = detailBody(data, { onCollapse: onClose })
1394
1548
  } else if (err) {
1395
1549
  body = React.createElement('div', { className: 'pg2-err' }, err);
1396
1550
  } else {
@@ -1477,7 +1631,7 @@ window.__ModuleLoader__.load({
1477
1631
  return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'rgba(128,128,128,0.95)', padding: '2px 0' } },
1478
1632
  React.createElement('span', { style: { fontWeight: 600 } }, T('dock.title')),
1479
1633
  React.createElement('span', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1480
- chip(catShort('directory'), eff.directory), chip(catShort('command'), eff.command), chip(catShort('read'), eff.read), chip(catShort('edit'), eff.edit), chip(catShort('subagent'), eff.subagent), chip(catShort('doomloop'), eff.doomloop),
1634
+ chip(catShort('directory'), eff.directory), chip(catShort('command'), eff.command), chip(catShort('read'), eff.read), chip(catShort('image'), eff.image), chip(catShort('edit'), eff.edit), chip(catShort('undo'), eff.undo), chip(catShort('subagent'), eff.subagent), chip(catShort('doomloop'), eff.doomloop),
1481
1635
  ),
1482
1636
  React.createElement('span', { style: { cursor: 'pointer', padding: '0 4px' }, onClick: refresh, title: T('dock.refresh') }, '↻'),
1483
1637
  );
@@ -1498,6 +1652,7 @@ window.__ModuleLoader__.load({
1498
1652
  const [exAction, setExAction] = React.useState('allow');
1499
1653
  const [exReasonVal, setExReasonVal] = React.useState('');
1500
1654
  const [newTool, setNewTool] = React.useState('');
1655
+ const [newToolAction, setNewToolAction] = React.useState('allow');
1501
1656
  const [form, setForm] = React.useState({ action: 'deny', tool: '', path: '', args: '', reason: '' });
1502
1657
  const [confirm, setConfirm] = React.useState(null);
1503
1658
  const [excCollapsed, setExcCollapsed] = React.useState({});
@@ -1508,8 +1663,13 @@ window.__ModuleLoader__.load({
1508
1663
  return () => clearTimeout(id);
1509
1664
  }, [confirm]);
1510
1665
 
1666
+ // 切换全局/项目 tab 时清掉未完成的二次确认:确认的对象是「某一层的某个键」,换层后不能沿用
1667
+ // 同一确认态,否则再点会删掉另一层。
1668
+ React.useEffect(() => { setConfirm(null); }, [tab]);
1669
+
1511
1670
  const applyStatus = (s) => {
1512
1671
  if (!s) return;
1672
+ applyStatusLists(s);
1513
1673
  setStatus(s);
1514
1674
  const cs = { global: {}, project: {} };
1515
1675
  for (const t of ['global', 'project']) {
@@ -1522,12 +1682,15 @@ window.__ModuleLoader__.load({
1522
1682
  const qs = {};
1523
1683
  const gq = s.quickTools ? s.quickTools.global : {};
1524
1684
  const pq = s.quickTools ? s.quickTools.project : {};
1685
+ // 会话兜底只作最后一级(预设之外、且未配置的工具);宿主 statusView 恒下发合法的
1686
+ // fallback.effective,下面的 `|| 'ask'` 只是协议异常兜底,不是决策链的一环。
1687
+ QUICK_FALLBACK = (s.fallback && s.fallback.effective) || 'ask';
1525
1688
  const names = {};
1526
1689
  for (const t of QUICK_PRESET) names[t] = 1;
1527
1690
  for (const k of Object.keys(gq)) names[k] = 1;
1528
1691
  for (const k of Object.keys(pq)) names[k] = 1;
1529
1692
  for (const t of Object.keys(names)) {
1530
- qs[t] = { g: gq[t] || 'allow', p: pq[t] && pq[t] !== 'inherit' ? pq[t] : 'inherit' };
1693
+ qs[t] = { g: quickGlobalValue(t, gq), p: hasOwnKey(pq, t) && pq[t] !== 'inherit' ? pq[t] : 'inherit' };
1531
1694
  }
1532
1695
  setQuickSel(qs);
1533
1696
  };
@@ -1577,7 +1740,14 @@ window.__ModuleLoader__.load({
1577
1740
 
1578
1741
  const addQuick = () => {
1579
1742
  if (!newTool || !String(newTool).trim()) { setMsg(T('panel.needTool')); return; }
1580
- invoke('permgate:set-quick', { target: tab, tool: String(newTool).trim(), action: 'allow' }, () => setNewTool(''));
1743
+ invoke('permgate:set-quick', { target: tab, tool: String(newTool).trim(), action: newToolAction }, () => setNewTool(''));
1744
+ };
1745
+
1746
+ // 删除该行在当前 tab 的快捷工具设置(服务端收到 action=inherit 即删除该键);
1747
+ // 只发请求、不做本地乐观删除:quickSel 完全由服务端返回的 status 收敛,请求失败时
1748
+ // 不会留下「面板已删、服务端仍生效」的漂移(与例外/规则的删除入口一致)。
1749
+ const removeQuick = (tool) => {
1750
+ invoke('permgate:set-quick', { target: tab, tool, action: 'inherit' });
1581
1751
  };
1582
1752
 
1583
1753
  const addException = (c) => {
@@ -1588,7 +1758,18 @@ window.__ModuleLoader__.load({
1588
1758
  invoke('permgate:add-exception', { target: tab, category: c, match: v, action: exAction, reason: exAction === 'deny' ? (String(exReasonVal || '').trim() || undefined) : undefined });
1589
1759
  };
1590
1760
 
1591
- const removeException = (c, id) => invoke('permgate:remove-exception', { target: tab, category: c, id });
1761
+ const removeException = (c, id) => {
1762
+ setBusy(true);
1763
+ setMsg('');
1764
+ // 注意:invoke 是无返回值的回调式封装,这里必须用返回 promise 的 call
1765
+ call('permgate:remove-exception', { target: tab, category: c, id }).then((r) => {
1766
+ if (r && r.error) { setMsg(String(r.error)); return; }
1767
+ applyStatus(r && r.status ? r.status : r);
1768
+ // 删除只作用于选中行:明确反馈结果;同路径若仍有例外则提示剩余条数,否则清掉旧提示
1769
+ if (r && r.removed === false) setMsg(r.reason ? String(r.reason) : T('panel.delFailed'));
1770
+ else setMsg(r && r.remaining > 0 ? T('panel.excRemaining').replace('{n}', String(r.remaining)) : '');
1771
+ }).catch((e) => setMsg(String((e && e.message) || e))).then(() => setBusy(false));
1772
+ };
1592
1773
 
1593
1774
  const setFormKey = (key) => (e) => setForm(Object.assign({}, form, { [key]: e.target.value }));
1594
1775
  const addRule = () => invoke('permgate:add-rule', { target: tab, action: form.action, tool: form.tool || undefined, path: form.path || undefined, args: form.args || undefined, reason: form.reason || undefined });
@@ -1651,13 +1832,31 @@ window.__ModuleLoader__.load({
1651
1832
  );
1652
1833
  };
1653
1834
 
1654
- const quickTools = Object.keys(quickSel);
1835
+ // 行集合按 tab 区分:项目 tab 显示全集(预设 + 全局键 + 项目键),便于在项目里覆盖全局;
1836
+ // 全局 tab 只显示「预设 + 全局配置里确实存在的键」,避免项目层专有的键在这里冒充全局设置。
1837
+ const quickGq = status && status.quickTools ? status.quickTools.global : null;
1838
+ const quickTools = Object.keys(quickSel).filter((t) => {
1839
+ if (tab === 'project') return true;
1840
+ if (QUICK_PRESET.indexOf(t) !== -1) return true;
1841
+ return hasOwnKey(quickGq, t);
1842
+ });
1655
1843
  const quickRow = (t) => {
1656
1844
  const key = tab === 'global' ? 'g' : 'p';
1657
- const mode = quickSel[t] ? (quickSel[t][key] || (tab === 'project' ? 'inherit' : 'allow')) : (tab === 'project' ? 'inherit' : 'allow');
1845
+ const gqMap = status && status.quickTools ? status.quickTools.global : null;
1846
+ const pqMap = status && status.quickTools ? status.quickTools.project : null;
1847
+ const mode = quickSel[t] && quickSel[t][key] ? quickSel[t][key] : (tab === 'project' ? 'inherit' : quickGlobalValue(t, gqMap));
1848
+ // 删除按钮只给「用户自己添加的名字」(不在 22 个预设清单里的行),且本行在当前 tab 的配置里
1849
+ // 确实存在该键时才显示:预设行始终会列出,删掉配置键也只是回退默认值,容易被误读成「收紧」。
1850
+ const isPreset = QUICK_PRESET.indexOf(t) !== -1;
1851
+ const hasKey = key === 'g' ? hasOwnKey(gqMap, t) : hasOwnKey(pqMap, t);
1852
+ const desc = quickDesc(t);
1658
1853
  return React.createElement('div', { key: t, style: rowStyle },
1659
- React.createElement('span', { style: { fontFamily: 'monospace', fontSize: 13, width: 150 } }, t),
1854
+ React.createElement('span', { style: { display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 260 } },
1855
+ React.createElement('span', { style: { fontFamily: 'monospace', fontSize: 13 } }, t),
1856
+ desc ? React.createElement('span', { style: { fontSize: 12, color: 'rgba(128,128,128,0.9)' } }, desc) : null,
1857
+ ),
1660
1858
  sel(mode, changeQuick(t), tab === 'project' ? ALL_MODES : MODES, busy),
1859
+ (!isPreset && hasKey) ? React.createElement('button', { className: 'pg-btn pg-btn-danger' + (confirm === 'quick:' + tab + ':' + t ? ' pg-btn-confirm' : ''), disabled: busy, onClick: () => confirmDelete('quick:' + tab + ':' + t, () => removeQuick(t)) }, confirm === 'quick:' + tab + ':' + t ? T('panel.confirmDel') : T('panel.del')) : null,
1661
1860
  );
1662
1861
  };
1663
1862
 
@@ -1687,6 +1886,11 @@ window.__ModuleLoader__.load({
1687
1886
 
1688
1887
  const sandboxLabel = (m) => (m === 'workspace-write' ? T('sandbox.ww') : m === 'danger-full-access' ? T('sandbox.fa') : T('sandbox.inherit'));
1689
1888
  const sandboxVal = tab === 'global' ? sandbox.global : sandbox.project;
1889
+ const fbVal = status && status.fallback ? (tab === 'global' ? status.fallback.global : status.fallback.project) : (tab === 'global' ? 'ask' : 'inherit');
1890
+ const changeFallback = (e) => {
1891
+ const mode = e.target.value;
1892
+ invoke('permgate:set-fallback', { target: tab, mode }, () => setMsg(T('panel.fallbackSaved').replace('{t}', T(tab === 'global' ? 'panel.tabGlobal' : 'panel.tabProject')).replace('{v}', modeLabel(mode))));
1893
+ };
1690
1894
  const sandboxOptions = tab === 'global' ? ['workspace-write', 'danger-full-access'] : ['workspace-write', 'danger-full-access', 'inherit'];
1691
1895
  const changeSandbox = (e) => {
1692
1896
  const mode = e.target.value;
@@ -1745,12 +1949,12 @@ window.__ModuleLoader__.load({
1745
1949
  quickTools.map(quickRow),
1746
1950
  React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
1747
1951
  React.createElement('input', { className: 'pg-field', placeholder: T('panel.quickAdd'), value: newTool, onChange: (e) => setNewTool(e.target.value), disabled: busy }),
1952
+ sel(newToolAction, (e) => setNewToolAction(e.target.value), MODES, busy),
1748
1953
  React.createElement('button', { className: 'pg-btn', disabled: busy, onClick: addQuick }, T('panel.quickAddBtn')),
1749
1954
  ),
1750
1955
  ),
1751
1956
  React.createElement('div', { style: card },
1752
1957
  React.createElement('div', { style: h() }, T('panel.rules')),
1753
- React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.8)', marginBottom: 8 } }, T('panel.rulesHint')),
1754
1958
  React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginBottom: 8 } },
1755
1959
  React.createElement('select', { value: form.action, onChange: setFormKey('action'), className: 'pg-field', disabled: busy },
1756
1960
  React.createElement('option', { value: 'deny' }, T('panel.actionDeny')), React.createElement('option', { value: 'ask' }, T('panel.actionAsk')), React.createElement('option', { value: 'allow' }, T('panel.actionAllow')),
@@ -1766,6 +1970,13 @@ window.__ModuleLoader__.load({
1766
1970
  customList && customList.length ? customList.map(ruleRow) : React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.8)' } }, T('panel.noRules')),
1767
1971
  ),
1768
1972
  ),
1973
+ React.createElement('div', { style: card },
1974
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 } },
1975
+ React.createElement('span', { style: { fontSize: 13, fontWeight: 600 } }, T('panel.fallback')),
1976
+ sel(fbVal, changeFallback, tab === 'global' ? MODES : ALL_MODES, busy),
1977
+ ),
1978
+ React.createElement('div', { style: { fontSize: 12, color: 'rgba(128,128,128,0.85)' } }, T('panel.fallbackHint')),
1979
+ ),
1769
1980
  React.createElement('div', { style: card },
1770
1981
  React.createElement('div', { style: h() }, T('panel.decisions')),
1771
1982
  decisions.length ? decisions.map((d, i) => React.createElement('div', { key: i, style: rowStyle },