dsh-sidebar-row-menu 1.9.2 → 1.10.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.
package/lib/client.js CHANGED
@@ -94,6 +94,18 @@ window.__ModuleLoader__.load({
94
94
  const DELETE_ENDPOINT = '/dsh-sidebar-row-menu/session/delete';
95
95
  /** 删除「工作区 + 其下全部会话」的宿主端点。 */
96
96
  const WORKSPACE_DELETE_ENDPOINT = '/dsh-sidebar-row-menu/workspace/delete';
97
+ /** 按工作区读写「新会话默认权限」的宿主端点。 */
98
+ const PERMISSION_DEFAULTS_ENDPOINT = '/dsh-sidebar-row-menu/permission-defaults';
99
+ /** 宿主原生预设的本地化名称;catalog 里的 name 是英文,中文界面用这张表。 */
100
+ const PRESET_LABEL_KEYS = {
101
+ 'read-only': 'permission.readOnly',
102
+ 'workspace-write': 'permission.workspaceWrite',
103
+ 'danger-full-access': 'permission.fullAccess',
104
+ auto: 'permission.autoReview',
105
+ custom: 'permission.custom',
106
+ };
107
+ /** 宿主还没连上时先按内置预设渲染,catalog 到了再替换。 */
108
+ const FALLBACK_PRESETS = ['read-only', 'workspace-write', 'danger-full-access'];
97
109
  /** 官方 open-in-app 的三条路由;用文档相对路径让浏览器自带认证。 */
98
110
  const APPS_ROUTE = 'open-in-app/apps';
99
111
  const OPEN_ROUTE = 'open-in-app/open';
@@ -121,10 +133,19 @@ window.__ModuleLoader__.load({
121
133
  };
122
134
  const ITEM_KEYS = Object.keys(ITEM_DEFAULTS);
123
135
  /**
124
- * 「右键专属条目是否也放进行尾…菜单」的逐项开关。
125
- * 全部默认关闭:不勾选时点「…」看到的仍是 DSH 原生菜单。
136
+ * 「右键专属条目是否也放进行尾…菜单」的逐项开关(旧版数据)。
137
+ * 0.2.2 起改用下面的 PLACEMENT_KEY 三态;这里只保留迁移读取。
126
138
  */
127
139
  const ELLIPSIS_KEY = 'dsh.sidebar-row-menu.ellipsis.v1';
140
+ /**
141
+ * 每个附加条目在两种入口里的显示位置:
142
+ * context 只在右键菜单
143
+ * ellipsis 只在「…」菜单
144
+ * both 两边都显示
145
+ * 旧版只有一个「同时放进…菜单」布尔值;读取时 true → both、false → context。
146
+ */
147
+ const PLACEMENT_KEY = 'dsh.sidebar-row-menu.placement.v1';
148
+ const PLACEMENT_VALUES = ['context', 'ellipsis', 'both'];
128
149
  /**
129
150
  * 按工作区「已隐藏归档会话」的集合。宿主只有全局归档筛选(default/show/only),
130
151
  * 没有「这个工作区显示归档、那个工作区不显示」的概念,所以这里记住要单独
@@ -134,6 +155,7 @@ window.__ModuleLoader__.load({
134
155
  const ARCHIVED_HIDDEN_KEY = 'dsh.sidebar-row-menu.archived-hidden.v1';
135
156
  const ELLIPSIS_DEFAULTS = {
136
157
  newSession: false,
158
+ permissionDefault: false,
137
159
  showArchived: false,
138
160
  openWith: false,
139
161
  copyPath: false,
@@ -194,6 +216,14 @@ window.__ModuleLoader__.load({
194
216
  actionFailed: '操作失败,请稍后重试',
195
217
  openWith: '打开方式',
196
218
  openWithApp: '用 {name} 打开',
219
+ newSessionPermission: '新会话默认权限',
220
+ permissionFollowGlobal: '跟随全局默认',
221
+ 'permission.readOnly': '仅可查看',
222
+ 'permission.workspaceWrite': '工作区内修改',
223
+ 'permission.fullAccess': '完全权限',
224
+ 'permission.autoReview': '自动审查(实验)',
225
+ 'permission.custom': '自定义',
226
+ 'permission.unavailable': '宿主没有提供权限预设',
197
227
  deleteSession: '永久删除会话',
198
228
  deleteArchived: '删除所有已归档会话',
199
229
  'settings.title': '行菜单',
@@ -220,12 +250,17 @@ window.__ModuleLoader__.load({
220
250
  'settings.appsAllOff': '所有应用都被二级开关隐藏了',
221
251
  'settings.expand': '展开应用列表',
222
252
  'settings.collapse': '收起应用列表',
223
- 'settings.ellipsisTitle': '同时放进「…」菜单',
224
- 'settings.ellipsisHint': 'DSH 0.2.2 起「…」按钮与右键共用同一份菜单:勾选后对应条目也会出现在「…」菜单里,并沿用上面的单项开关;不勾选则只在右键菜单出现。',
253
+ 'settings.ellipsisTitle': '菜单显示位置',
254
+ 'settings.ellipsisHint': '每个条目都可以分别选择出现在右键菜单、「…」菜单,或两边都出现;至少保留一边。上面的单项开关仍然决定条目是否启用。',
255
+ 'settings.placement.context': '右键菜单',
256
+ 'settings.placement.ellipsis': '「…」菜单',
257
+ 'settings.placement.locked': '至少要在一边显示',
225
258
  'settings.ellipsisWorkspace': '工作区行',
226
259
  'settings.ellipsisSession': '会话行',
227
260
  'settings.ellipsis.newSession.label': '新建会话',
228
261
  'settings.ellipsis.newSession.hint': '在「…」菜单里直接新建该工作区的会话。',
262
+ 'settings.ellipsis.permissionDefault.label': '新会话默认权限',
263
+ 'settings.ellipsis.permissionDefault.hint': '在「…」菜单里为该工作区设置新建会话的默认权限;只在带原生权限预设的宿主上出现。',
229
264
  'settings.ellipsis.showArchived.label': '显示已归档会话',
230
265
  'settings.ellipsis.showArchived.hint': '在「…」菜单里单独显示或隐藏该工作区的已归档会话。',
231
266
  'settings.ellipsis.openWith.label': '打开方式',
@@ -292,6 +327,14 @@ window.__ModuleLoader__.load({
292
327
  actionFailed: 'Action failed, please retry',
293
328
  openWith: 'Open with',
294
329
  openWithApp: 'Open with {name}',
330
+ newSessionPermission: 'Default permission for new sessions',
331
+ permissionFollowGlobal: 'Follow the global default',
332
+ 'permission.readOnly': 'Read Only',
333
+ 'permission.workspaceWrite': 'Workspace Write',
334
+ 'permission.fullAccess': 'Full access',
335
+ 'permission.autoReview': 'Auto review (experimental)',
336
+ 'permission.custom': 'Custom',
337
+ 'permission.unavailable': 'The host exposes no permission presets',
295
338
  deleteSession: 'Delete session permanently',
296
339
  deleteArchived: 'Delete all archived sessions',
297
340
  'settings.title': 'Row menu',
@@ -318,12 +361,17 @@ window.__ModuleLoader__.load({
318
361
  'settings.appsAllOff': 'Every application is hidden by the switches above',
319
362
  'settings.expand': 'Show applications',
320
363
  'settings.collapse': 'Hide applications',
321
- 'settings.ellipsisTitle': 'Also show in the "…" menu',
322
- 'settings.ellipsisHint': 'Since DSH 0.2.2 the "…" button opens the same menu as right-click: checked entries also appear there, still subject to the switches above. Unchecked entries stay right-click only.',
364
+ 'settings.ellipsisTitle': 'Menu placement',
365
+ 'settings.ellipsisHint': 'Each entry can appear in the right-click menu, the "…" menu, or both. At least one side stays enabled. The switches above still decide whether the entry itself is on.',
366
+ 'settings.placement.context': 'Right-click',
367
+ 'settings.placement.ellipsis': '"…" menu',
368
+ 'settings.placement.locked': 'Keep at least one side',
323
369
  'settings.ellipsisWorkspace': 'Workspace rows',
324
370
  'settings.ellipsisSession': 'Session rows',
325
371
  'settings.ellipsis.newSession.label': 'New session',
326
372
  'settings.ellipsis.newSession.hint': 'Create a session for this workspace from the "…" menu.',
373
+ 'settings.ellipsis.permissionDefault.label': 'Default permission for new sessions',
374
+ 'settings.ellipsis.permissionDefault.hint': 'Set the default permission for new sessions of this workspace from the "…" menu; only shown when the host exposes native permission presets.',
327
375
  'settings.ellipsis.showArchived.label': 'Show archived sessions',
328
376
  'settings.ellipsis.showArchived.hint': 'Show or hide this workspace\'s archived sessions from the "…" menu.',
329
377
  'settings.ellipsis.openWith.label': 'Open with',
@@ -701,6 +749,7 @@ window.__ModuleLoader__.load({
701
749
 
702
750
  /* -------------------------------- 「…」菜单附加开关(localStorage) */
703
751
 
752
+ /** 读旧版「同时放进…菜单」布尔表,仅用于一次性迁移。 */
704
753
  function readStoredEllipsis() {
705
754
  const defaults = { ...ELLIPSIS_DEFAULTS };
706
755
  try {
@@ -717,11 +766,32 @@ window.__ModuleLoader__.load({
717
766
  return defaults;
718
767
  }
719
768
 
720
- let ellipsisSettings = readStoredEllipsis();
721
- const ellipsisListeners = new Set();
769
+ function readStoredPlacements() {
770
+ const defaults = {};
771
+ for (const key of ELLIPSIS_KEYS) defaults[key] = 'context';
772
+ const legacy = readStoredEllipsis();
773
+ for (const key of ELLIPSIS_KEYS) {
774
+ if (legacy[key] === true) defaults[key] = 'both';
775
+ }
776
+ try {
777
+ const raw = globalThis.localStorage?.getItem(PLACEMENT_KEY);
778
+ if (typeof raw !== 'string' || raw === '') return defaults;
779
+ const parsed = JSON.parse(raw);
780
+ if (parsed === null || typeof parsed !== 'object') return defaults;
781
+ for (const key of ELLIPSIS_KEYS) {
782
+ if (PLACEMENT_VALUES.includes(parsed[key])) defaults[key] = parsed[key];
783
+ }
784
+ } catch {
785
+ /* 坏数据回退默认 */
786
+ }
787
+ return defaults;
788
+ }
789
+
790
+ let placementSettings = readStoredPlacements();
791
+ const placementListeners = new Set();
722
792
 
723
- function publishEllipsis() {
724
- for (const listener of ellipsisListeners) {
793
+ function publishPlacements() {
794
+ for (const listener of placementListeners) {
725
795
  try {
726
796
  listener();
727
797
  } catch {
@@ -730,43 +800,55 @@ window.__ModuleLoader__.load({
730
800
  }
731
801
  }
732
802
 
733
- function subscribeEllipsis(listener) {
734
- ellipsisListeners.add(listener);
803
+ function subscribePlacements(listener) {
804
+ placementListeners.add(listener);
735
805
  return () => {
736
- ellipsisListeners.delete(listener);
806
+ placementListeners.delete(listener);
737
807
  };
738
808
  }
739
809
 
740
- function ellipsisSnapshot() {
741
- return ellipsisSettings;
810
+ function placementsSnapshot() {
811
+ return placementSettings;
812
+ }
813
+
814
+ function usePlacements() {
815
+ return useSyncExternalStore(subscribePlacements, placementsSnapshot, placementsSnapshot);
816
+ }
817
+
818
+ function placementOf(key) {
819
+ const value = placementSettings[key];
820
+ return PLACEMENT_VALUES.includes(value) ? value : 'context';
821
+ }
822
+
823
+ function showsInContext(key) {
824
+ return placementOf(key) !== 'ellipsis';
742
825
  }
743
826
 
744
- function useEllipsis() {
745
- return useSyncExternalStore(subscribeEllipsis, ellipsisSnapshot, ellipsisSnapshot);
827
+ function showsInEllipsis(key) {
828
+ return placementOf(key) !== 'context';
746
829
  }
747
830
 
748
- function setEllipsis(key, enabled) {
749
- if (!ELLIPSIS_KEYS.includes(key)) return;
750
- ellipsisSettings = { ...ellipsisSettings, [key]: enabled === true };
831
+ /** 三态位置;不允许把条目同时从两边关掉,所以最小值是其中一边。 */
832
+ function setPlacement(key, value) {
833
+ if (!ELLIPSIS_KEYS.includes(key) || !PLACEMENT_VALUES.includes(value)) return;
834
+ placementSettings = { ...placementSettings, [key]: value };
751
835
  try {
752
- globalThis.localStorage?.setItem(ELLIPSIS_KEY, JSON.stringify(ellipsisSettings));
836
+ globalThis.localStorage?.setItem(PLACEMENT_KEY, JSON.stringify(placementSettings));
753
837
  } catch {
754
838
  /* 隐私模式写不进去也照样生效到本次会话 */
755
839
  }
756
- publishEllipsis();
840
+ publishPlacements();
757
841
  }
758
842
 
759
- function ellipsisOn(key) {
760
- return ellipsisSettings[key] === true;
843
+ function placementOn(key, mode) {
844
+ const placement = placementOf(key);
845
+ return mode === 'context' ? placement !== 'ellipsis' : placement !== 'context';
761
846
  }
762
847
 
763
848
  /** 工作区行是否至少有一项要在「…」菜单里显示(否则完全不接管原生菜单)。 */
764
849
  function workspaceEllipsisOn() {
765
- return ellipsisSettings.newSession === true
766
- || ellipsisSettings.openWith === true
767
- || ellipsisSettings.copyPath === true
768
- || ellipsisSettings.openInTab === true
769
- || ellipsisSettings.deleteSessions === true;
850
+ return ['newSession', 'permissionDefault', 'openWith', 'copyPath', 'openInTab', 'deleteSessions']
851
+ .some((key) => showsInEllipsis(key));
770
852
  }
771
853
 
772
854
  /* -------------------------------------- 会话空白处右键菜单(localStorage) */
@@ -1046,7 +1128,175 @@ window.__ModuleLoader__.load({
1046
1128
  );
1047
1129
  }
1048
1130
 
1049
- /* ------------------------------------------------ 与 dsh-session-menu 共存 */
1131
+ /* ---------------------------------------- 工作区新会话默认权限(host 存储) */
1132
+
1133
+ /**
1134
+ * 宿主 permissionPresets 的目录 + 插件自己的「工作区 → preset」映射。
1135
+ * 映射存在宿主侧($DSH_HOME/storages/...),浏览器这边只是缓存;
1136
+ * 选择后先乐观更新,写入失败再回滚并提示。
1137
+ */
1138
+ function emptyPermissionState() {
1139
+ return {
1140
+ status: 'idle',
1141
+ options: FALLBACK_PRESETS.map((value) => ({ value })),
1142
+ defaultPreset: '',
1143
+ defaults: {},
1144
+ at: 0,
1145
+ };
1146
+ }
1147
+
1148
+ let permissionState = emptyPermissionState();
1149
+ const permissionListeners = new Set();
1150
+
1151
+ function publishPermission(next) {
1152
+ permissionState = next;
1153
+ for (const listener of permissionListeners) {
1154
+ try {
1155
+ listener();
1156
+ } catch {
1157
+ /* ignore */
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ function subscribePermission(listener) {
1163
+ permissionListeners.add(listener);
1164
+ return () => {
1165
+ permissionListeners.delete(listener);
1166
+ };
1167
+ }
1168
+
1169
+ function permissionSnapshot() {
1170
+ return permissionState;
1171
+ }
1172
+
1173
+ function usePermission() {
1174
+ return useSyncExternalStore(subscribePermission, permissionSnapshot, permissionSnapshot);
1175
+ }
1176
+
1177
+ const permissionSource = {
1178
+ getSnapshot: permissionSnapshot,
1179
+ subscribe: subscribePermission,
1180
+ };
1181
+
1182
+ function presetLabel(value, t) {
1183
+ const option = permissionState.options.find((candidate) => candidate?.value === value);
1184
+ const key = PRESET_LABEL_KEYS[value];
1185
+ if (typeof key === 'string') {
1186
+ const localized = t(key);
1187
+ if (localized !== key) return localized;
1188
+ }
1189
+ if (typeof option?.name === 'string' && option.name !== '') return option.name;
1190
+ return value;
1191
+ }
1192
+
1193
+ function presetOptions() {
1194
+ return permissionState.options.filter((option) => (
1195
+ option !== null
1196
+ && typeof option === 'object'
1197
+ && typeof option.value === 'string'
1198
+ && option.value !== ''
1199
+ && option.value !== 'custom'
1200
+ ));
1201
+ }
1202
+
1203
+ function workspacePreset(workspaceId) {
1204
+ const value = permissionState.defaults[workspaceId];
1205
+ return typeof value === 'string' ? value : '';
1206
+ }
1207
+
1208
+ function effectivePreset(workspaceId) {
1209
+ return workspacePreset(workspaceId) || permissionState.defaultPreset;
1210
+ }
1211
+
1212
+ function normalizeDefaults(value) {
1213
+ const result = {};
1214
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return result;
1215
+ for (const [workspaceId, preset] of Object.entries(value)) {
1216
+ if (typeof workspaceId !== 'string' || workspaceId === '') continue;
1217
+ if (typeof preset !== 'string' || preset === '') continue;
1218
+ result[workspaceId] = preset;
1219
+ }
1220
+ return result;
1221
+ }
1222
+
1223
+ function ensurePermissionData(ctx) {
1224
+ if (typeof fetch !== 'function') return;
1225
+ const now = Date.now();
1226
+ if (permissionState.status === 'loading') return;
1227
+ if (permissionState.status === 'ready' && now - permissionState.at < 30000) return;
1228
+ if (permissionState.status === 'error' && now - permissionState.at < 15000) return;
1229
+ publishPermission({ ...permissionState, status: 'loading', at: now });
1230
+
1231
+ fetch(PERMISSION_DEFAULTS_ENDPOINT, {
1232
+ headers: { accept: 'application/json' },
1233
+ }).then(
1234
+ (response) => (response.ok ? response.json() : null),
1235
+ () => null,
1236
+ ).then((payload) => {
1237
+ const readDefaults = payload?.ok === true
1238
+ ? normalizeDefaults(payload.defaults)
1239
+ : null;
1240
+ const catalog = payload?.catalog;
1241
+ const rawOptions = Array.isArray(catalog?.options) ? catalog.options : [];
1242
+ const options = rawOptions.filter((option) => (
1243
+ option !== null
1244
+ && typeof option === 'object'
1245
+ && typeof option.value === 'string'
1246
+ && option.value !== ''
1247
+ && option.value !== 'custom'
1248
+ ));
1249
+ const usable = options.length > 0 ? options : permissionState.options;
1250
+ const defaultPreset = typeof catalog?.defaultPreset === 'string'
1251
+ ? catalog.defaultPreset
1252
+ : permissionState.defaultPreset;
1253
+ publishPermission({
1254
+ status: readDefaults === null ? 'error' : 'ready',
1255
+ options: usable,
1256
+ defaultPreset,
1257
+ defaults: readDefaults ?? permissionState.defaults,
1258
+ at: Date.now(),
1259
+ });
1260
+ }, () => {
1261
+ publishPermission({ ...permissionState, status: 'error', at: Date.now() });
1262
+ });
1263
+ }
1264
+
1265
+ function setWorkspacePreset(ctx, workspaceId, preset, t) {
1266
+ if (typeof workspaceId !== 'string' || workspaceId === '') return Promise.resolve(false);
1267
+ const previous = permissionState.defaults;
1268
+ const next = { ...previous };
1269
+ if (preset === null) delete next[workspaceId];
1270
+ else next[workspaceId] = preset;
1271
+ publishPermission({ ...permissionState, status: 'ready', defaults: next, at: Date.now() });
1272
+ if (typeof fetch !== 'function') {
1273
+ publishPermission({ ...permissionState, defaults: previous, at: Date.now() });
1274
+ return Promise.resolve(false);
1275
+ }
1276
+ return fetch(PERMISSION_DEFAULTS_ENDPOINT, {
1277
+ method: 'POST',
1278
+ headers: { 'content-type': 'application/json' },
1279
+ body: JSON.stringify({ workspaceId, preset }),
1280
+ }).then(
1281
+ (response) => response.json().catch(() => null).then((data) => {
1282
+ if (!response.ok || data?.ok !== true) throw new Error(`HTTP ${response.status}`);
1283
+ publishPermission({
1284
+ ...permissionState,
1285
+ status: 'ready',
1286
+ defaults: normalizeDefaults(data.defaults),
1287
+ at: Date.now(),
1288
+ });
1289
+ return true;
1290
+ }),
1291
+ () => false,
1292
+ ).catch(() => {
1293
+ publishPermission({ ...permissionState, defaults: previous, at: Date.now() });
1294
+ toast(t('actionFailed'));
1295
+ return false;
1296
+ });
1297
+ }
1298
+
1299
+
1050
1300
 
1051
1301
  /** 读 slot 注册项 id(不同 DSH 版本的 register 返回形状略有差异)。 */
1052
1302
  function slotItemId(entry) {
@@ -1296,18 +1546,135 @@ window.__ModuleLoader__.load({
1296
1546
 
1297
1547
  /* ---------------------------------------------------------- 打开方式子菜单 */
1298
1548
 
1299
- /** 面板贴边时的位置:右侧放不下就翻到左边,并在视口内夹取。 */
1300
- function submenuGeometry(anchor, size) {
1549
+ /** 视口可用尺寸:滚动条会占掉 innerWidth/innerHeight 的一部分,贴边要贴着可视区算。 */
1550
+ function viewportBounds() {
1551
+ const root = typeof document === 'undefined' ? null : document.documentElement;
1552
+ const width = root !== null && root !== undefined && root.clientWidth > 0 ? root.clientWidth : window.innerWidth;
1553
+ const height = root !== null && root !== undefined && root.clientHeight > 0 ? root.clientHeight : window.innerHeight;
1554
+ return { width: Number(width) || 0, height: Number(height) || 0 };
1555
+ }
1556
+
1557
+ /**
1558
+ * 元素真实占位尺寸。必须用 offsetWidth/offsetHeight:面板入场动画带 transform 缩放,
1559
+ * 动画期间 getBoundingClientRect 量出来偏小(实测 181 vs 198),按它夹取会溢出视口。
1560
+ */
1561
+ function layoutSize(node) {
1562
+ if (node === null || node === undefined) return null;
1563
+ const width = Number(node.offsetWidth) || 0;
1564
+ const height = Number(node.offsetHeight) || 0;
1565
+ if (width > 0 && height > 0) return { width, height };
1566
+ if (typeof node.getBoundingClientRect !== 'function') return null;
1567
+ const rect = node.getBoundingClientRect();
1568
+ if (!(rect.width > 0) || !(rect.height > 0)) return null;
1569
+ return { width: rect.width, height: rect.height };
1570
+ }
1571
+
1572
+ /** 菜单面板定位:按布局尺寸夹进视口;尺寸后续变化(异步条目、字体)时再夹一次。 */
1573
+ function useMenuPos(hostRef, x, y, deps) {
1574
+ const [pos, setPos] = useState(null);
1575
+ const place = () => {
1576
+ const host = hostRef.current;
1577
+ if (host === null || host === undefined) return;
1578
+ const size = layoutSize(host);
1579
+ if (size === null) return;
1580
+ const view = viewportBounds();
1581
+ const margin = 8;
1582
+ const left = Math.max(margin, Math.min(x, view.width - size.width - margin));
1583
+ const top = Math.max(margin, Math.min(y, view.height - size.height - margin));
1584
+ setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
1585
+ };
1586
+ useLayoutEffect(place, [x, y, ...deps]);
1587
+ useEffect(() => {
1588
+ if (typeof ResizeObserver !== 'function') return undefined;
1589
+ const host = hostRef.current;
1590
+ if (host === null || host === undefined) return undefined;
1591
+ const observer = new ResizeObserver(place);
1592
+ observer.observe(host);
1593
+ return () => observer.disconnect();
1594
+ }, [x, y]);
1595
+ return pos;
1596
+ }
1597
+
1598
+ /**
1599
+ * 二级菜单位置:横向贴住菜单面板外沿,纵向和触发它的那一行对齐;
1600
+ * 右侧放不下就翻到左边,仍然紧贴面板,再整体夹进视口。
1601
+ */
1602
+ function submenuGeometry(anchor, size, row = anchor) {
1301
1603
  const margin = 12;
1302
1604
  const gap = 6;
1605
+ const view = viewportBounds();
1303
1606
  const width = Math.max(160, size?.width ?? 180);
1304
1607
  const height = Math.max(0, size?.height ?? 0);
1608
+ const rightRoom = view.width - margin - (anchor.right + gap);
1609
+ const leftRoom = anchor.left - gap - margin;
1610
+ let side = 'right';
1305
1611
  let left = anchor.right + gap;
1306
- if (left + width > window.innerWidth - margin) left = anchor.left - width - gap;
1307
- left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
1308
- let top = anchor.bottom - height - 4;
1309
- top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
1310
- return { left, top };
1612
+ if (rightRoom < width && leftRoom > rightRoom) {
1613
+ side = 'left';
1614
+ left = anchor.left - width - gap;
1615
+ }
1616
+ left = Math.max(margin, Math.min(left, view.width - width - margin));
1617
+ let top = row.top;
1618
+ top = Math.max(margin, Math.min(top, view.height - height - margin));
1619
+ return { left, top, side };
1620
+ }
1621
+
1622
+ /**
1623
+ * 二级菜单定位:面板先以隐藏状态挂载,量到真实尺寸后再贴边,
1624
+ * 因此顶到屏幕右侧时会精确翻到另一侧并紧贴菜单,不再按估算宽度留出空隙。
1625
+ */
1626
+ function useSubmenuPos(open, hostRef, deps) {
1627
+ const [box, setBox] = useState(null);
1628
+ const place = () => {
1629
+ const host = hostRef.current;
1630
+ if (host === null || host === undefined) return;
1631
+ const panel = typeof host.querySelector === 'function' ? host.querySelector('.dsrm-submenu') : null;
1632
+ const size = layoutSize(panel);
1633
+ if (size === null) return;
1634
+ const row = host.getBoundingClientRect();
1635
+ // 横向以菜单面板外沿为锚:自绘菜单是 .dsrm-menu,会话 / 工作区行注入到宿主菜单里,
1636
+ // 那时要找宿主的 [role="menu"] 面板(行本身比面板内缩 4px,贴它会偏进面板里)。
1637
+ const menu = typeof host.closest === 'function'
1638
+ ? (host.closest('.dsrm-menu') ?? host.closest('[role="menu"]'))
1639
+ : null;
1640
+ const anchor = menu !== null && menu !== undefined && menu !== host ? menu.getBoundingClientRect() : row;
1641
+ const next = submenuGeometry(anchor, size, row);
1642
+ setBox((prev) => (prev !== null
1643
+ && prev.left === next.left
1644
+ && prev.top === next.top
1645
+ && prev.side === next.side ? prev : next));
1646
+ };
1647
+ useLayoutEffect(() => {
1648
+ if (!open) {
1649
+ setBox(null);
1650
+ return;
1651
+ }
1652
+ place();
1653
+ }, [open, ...deps]);
1654
+ useEffect(() => {
1655
+ if (!open) return undefined;
1656
+ window.addEventListener('resize', place);
1657
+ const host = hostRef.current;
1658
+ const panel = host !== null && host !== undefined && typeof host.querySelector === 'function'
1659
+ ? host.querySelector('.dsrm-submenu')
1660
+ : null;
1661
+ const observer = typeof ResizeObserver === 'function' && panel !== null && panel !== undefined
1662
+ ? new ResizeObserver(place)
1663
+ : null;
1664
+ if (observer !== null) observer.observe(panel);
1665
+ return () => {
1666
+ window.removeEventListener('resize', place);
1667
+ if (observer !== null) observer.disconnect();
1668
+ };
1669
+ }, [open, ...deps]);
1670
+ return box;
1671
+ }
1672
+
1673
+ /** 面板翻到左侧时箭头跟着翻转,指向菜单所在方向。 */
1674
+ function chevronClass(box) {
1675
+ return box?.side === 'left'
1676
+ ? 'dsrm-submenu-chevron dsrm-submenu-chevron-flipped'
1677
+ : 'dsrm-submenu-chevron';
1311
1678
  }
1312
1679
 
1313
1680
  /** slot 注入的 useMenuOpenState 在不同版本里可能不是数组,宽松取关闭函数。 */
@@ -1326,7 +1693,6 @@ window.__ModuleLoader__.load({
1326
1693
  const apps = useApps();
1327
1694
  usePinned();
1328
1695
  const [open, setOpen] = useState(false);
1329
- const [box, setBox] = useState(null);
1330
1696
  const hostRef = useRef(null);
1331
1697
  const closeTimer = useRef(0);
1332
1698
 
@@ -1352,17 +1718,8 @@ window.__ModuleLoader__.load({
1352
1718
  };
1353
1719
  }, [open]);
1354
1720
 
1355
- // MenuSurface 是函数组件,不保证转发 ref,因此按条目数估算面板高度;
1356
- // 列表本身有 max-height 和滚动,估算只影响贴底时的翻转位置。
1357
- useEffect(() => {
1358
- if (!open) return;
1359
- const host = hostRef.current;
1360
- if (host === null) return;
1361
- const pinnedIds = new Set(effectivePinned(apps.ids));
1362
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1363
- const height = Math.min(380, Math.max(40, count * 34 + 8));
1364
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
1365
- }, [open, apps.status, apps.ids]);
1721
+ // 面板先隐藏挂载再量真实尺寸,所以这里不再按条目数估算宽高。
1722
+ const box = useSubmenuPos(open, hostRef, [apps.status, apps.ids]);
1366
1723
 
1367
1724
  // 会话行菜单的 pointerover/out 在 document 捕获阶段被「移出关闭」守卫截断,
1368
1725
  // React 的 onPointerEnter 收不到。把悬浮展开挂到 window 捕获阶段,抢在守卫前。
@@ -1388,12 +1745,6 @@ window.__ModuleLoader__.load({
1388
1745
 
1389
1746
  const openNow = () => {
1390
1747
  window.clearTimeout(closeTimer.current);
1391
- if (!open && hostRef.current !== null) {
1392
- const pinnedIds = new Set(effectivePinned(apps.ids));
1393
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1394
- const height = Math.min(380, Math.max(40, count * 34 + 8));
1395
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
1396
- }
1397
1748
  setOpen(true);
1398
1749
  ensureApps();
1399
1750
  };
@@ -1459,7 +1810,7 @@ window.__ModuleLoader__.load({
1459
1810
  },
1460
1811
  jsx(IconRightUpOutlineRegular, { size: 14 }),
1461
1812
  h('span', { className: 'dsrm-submenu-label' }, t('openWith')),
1462
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
1813
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
1463
1814
  ),
1464
1815
  open ? h(MenuSurface, {
1465
1816
  role: 'group',
@@ -1485,8 +1836,7 @@ window.__ModuleLoader__.load({
1485
1836
  */
1486
1837
  function SessionOpenWithMenuItem({ sessionId, useMenuOpenState, t }) {
1487
1838
  const rightClick = useRightClickEntry();
1488
- const ellipsis = useEllipsis();
1489
- const visible = rightClick || ellipsis.sessionOpenWith === true;
1839
+ const visible = placementOn('sessionOpenWith', rightClick ? 'context' : 'ellipsis');
1490
1840
  const items = useItems();
1491
1841
  const apps = useApps();
1492
1842
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1508,8 +1858,7 @@ window.__ModuleLoader__.load({
1508
1858
  */
1509
1859
  function SessionOpenWithPinnedMenuItem({ sessionId, useMenuOpenState, t }) {
1510
1860
  const rightClick = useRightClickEntry();
1511
- const ellipsis = useEllipsis();
1512
- const visible = rightClick || ellipsis.sessionOpenWith === true;
1861
+ const visible = placementOn('sessionOpenWith', rightClick ? 'context' : 'ellipsis');
1513
1862
  const items = useItems();
1514
1863
  useChildren();
1515
1864
  const apps = useApps();
@@ -1547,8 +1896,7 @@ window.__ModuleLoader__.load({
1547
1896
  */
1548
1897
  function SessionDeleteMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1549
1898
  const rightClick = useRightClickEntry();
1550
- const ellipsis = useEllipsis();
1551
- const visible = rightClick || ellipsis.sessionDelete === true;
1899
+ const visible = placementOn('sessionDelete', rightClick ? 'context' : 'ellipsis');
1552
1900
  const items = useItems();
1553
1901
  const archived = useArchivedIds();
1554
1902
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1572,8 +1920,7 @@ window.__ModuleLoader__.load({
1572
1920
  */
1573
1921
  function SessionDeleteArchivedMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1574
1922
  const rightClick = useRightClickEntry();
1575
- const ellipsis = useEllipsis();
1576
- const visible = rightClick || ellipsis.sessionDeleteArchived === true;
1923
+ const visible = placementOn('sessionDeleteArchived', rightClick ? 'context' : 'ellipsis');
1577
1924
  const items = useItems();
1578
1925
  const archived = useArchivedIds();
1579
1926
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1603,10 +1950,11 @@ window.__ModuleLoader__.load({
1603
1950
  ['archivedSessions', 'settings.archivedSessions.label', 'settings.archivedSessions.hint'],
1604
1951
  ];
1605
1952
 
1606
- /** 「…」菜单附加开关;按行分组,避免用户分不清工作区项和会话项。 */
1607
- const ELLIPSIS_ROWS = [
1953
+ /** 菜单显示位置;按行分组,避免用户分不清工作区项和会话项。 */
1954
+ const PLACEMENT_ROWS = [
1608
1955
  ['workspace', 'settings.ellipsisWorkspace', [
1609
1956
  ['newSession', 'settings.ellipsis.newSession.label', 'settings.ellipsis.newSession.hint'],
1957
+ ['permissionDefault', 'settings.ellipsis.permissionDefault.label', 'settings.ellipsis.permissionDefault.hint'],
1610
1958
  ['openWith', 'settings.ellipsis.openWith.label', 'settings.ellipsis.openWith.hint'],
1611
1959
  ['copyPath', 'settings.ellipsis.copyPath.label', 'settings.ellipsis.copyPath.hint'],
1612
1960
  ['openInTab', 'settings.ellipsis.openInTab.label', 'settings.ellipsis.openInTab.hint'],
@@ -1656,7 +2004,7 @@ window.__ModuleLoader__.load({
1656
2004
 
1657
2005
  function SettingsSection({ t }) {
1658
2006
  const items = useItems();
1659
- const ellipsis = useEllipsis();
2007
+ const placements = usePlacements();
1660
2008
  const blank = useBlankMenu();
1661
2009
  const children = useChildren();
1662
2010
  const apps = useApps();
@@ -1740,7 +2088,32 @@ window.__ModuleLoader__.load({
1740
2088
  return key === 'openWith' && childRows ? [row, childRows] : [row];
1741
2089
  });
1742
2090
 
1743
- const ellipsisGroups = ELLIPSIS_ROWS.map(([groupKey, titleKey, rows]) => h('div', {
2091
+ const placementSwitch = (key, mode, labelKey) => {
2092
+ const inContext = showsInContext(key);
2093
+ const inEllipsis = showsInEllipsis(key);
2094
+ const checked = mode === 'context' ? inContext : inEllipsis;
2095
+ // 三态里只剩一边时,那个开关锁住:不允许把条目从两边都关掉。
2096
+ const locked = mode === 'context' ? !inEllipsis : !inContext;
2097
+ const onChange = (next) => {
2098
+ if (mode === 'context') {
2099
+ setPlacement(key, next ? (inEllipsis ? 'both' : 'context') : 'ellipsis');
2100
+ } else {
2101
+ setPlacement(key, next ? (inContext ? 'both' : 'ellipsis') : 'context');
2102
+ }
2103
+ };
2104
+ return h('span', { className: 'dsrm-placement-choice' },
2105
+ h('span', { className: 'dsrm-placement-choice-label' }, t(labelKey)),
2106
+ jsx(Switch, {
2107
+ checked,
2108
+ disabled: locked,
2109
+ label: t(labelKey),
2110
+ title: locked ? t('settings.placement.locked') : undefined,
2111
+ onChange,
2112
+ }),
2113
+ );
2114
+ };
2115
+
2116
+ const placementGroups = PLACEMENT_ROWS.map(([groupKey, titleKey, rows]) => h('div', {
1744
2117
  className: 'dsrm-settings-group',
1745
2118
  key: groupKey,
1746
2119
  },
@@ -1753,12 +2126,9 @@ window.__ModuleLoader__.load({
1753
2126
  h('div', { className: 'dsrm-settings-label' }, t(labelKey)),
1754
2127
  h('div', { className: 'dsrm-settings-hint' }, t(hintKey)),
1755
2128
  ),
1756
- h('div', { className: 'dsrm-settings-controls' },
1757
- jsx(Switch, {
1758
- checked: ellipsis[key] === true,
1759
- label: t(labelKey),
1760
- onChange: (next) => setEllipsis(key, next),
1761
- }),
2129
+ h('div', { className: 'dsrm-settings-controls dsrm-placement-controls' },
2130
+ placementSwitch(key, 'context', 'settings.placement.context'),
2131
+ placementSwitch(key, 'ellipsis', 'settings.placement.ellipsis'),
1762
2132
  ),
1763
2133
  ))),
1764
2134
  ));
@@ -1787,7 +2157,7 @@ window.__ModuleLoader__.load({
1787
2157
  h('div', { className: 'dsrm-settings-list' }, rows),
1788
2158
  h('h2', { className: 'dsrm-settings-title dsrm-settings-subtitle' }, t('settings.ellipsisTitle')),
1789
2159
  h('div', { className: 'dsrm-settings-note' }, t('settings.ellipsisHint')),
1790
- h('div', { className: 'dsrm-settings-groups' }, ellipsisGroups),
2160
+ h('div', { className: 'dsrm-settings-groups' }, placementGroups),
1791
2161
  h('h2', { className: 'dsrm-settings-title dsrm-settings-subtitle' }, t('settings.blankMenuTitle')),
1792
2162
  h('div', { className: 'dsrm-settings-note' }, t('settings.blankMenuNote')),
1793
2163
  h('div', { className: 'dsrm-settings-list' }, blankRows),
@@ -1803,16 +2173,16 @@ window.__ModuleLoader__.load({
1803
2173
  function WorkspaceRowMenu({ x, y, props, onClose, mode = 'context' }) {
1804
2174
  const t = localeOf(rootCtx);
1805
2175
  const items = useItems();
1806
- const ellipsis = useEllipsis();
2176
+ usePlacements();
1807
2177
  const apps = useApps();
1808
2178
  usePinned();
1809
2179
  useChildren();
1810
2180
  const hostRef = useRef(null);
1811
- const [pos, setPos] = useState(null);
2181
+ const pos = useMenuPos(hostRef, x, y, [apps.status]);
1812
2182
 
1813
2183
  const ellipsisMode = mode === 'ellipsis';
1814
- /** 右键菜单永远显示;「…」菜单只在对应开关打开时显示(内置项除外)。 */
1815
- const show = (key) => !ellipsisMode || ellipsis[key] === true;
2184
+ /** 右键菜单永远显示;「…」菜单按设置页里选定的位置显示(内置项除外)。 */
2185
+ const show = (key) => placementOn(key, ellipsisMode ? 'ellipsis' : 'context');
1816
2186
  const openWithOn = items.openWith !== false && show('openWith');
1817
2187
 
1818
2188
  useEffect(() => {
@@ -1836,16 +2206,7 @@ window.__ModuleLoader__.load({
1836
2206
  };
1837
2207
  }, [onClose]);
1838
2208
 
1839
- // 置顶应用是异步取到的,行数会变;渲染后量一次面板高度再夹进视口。
1840
- useLayoutEffect(() => {
1841
- const host = hostRef.current;
1842
- if (host === null) return;
1843
- const rect = host.getBoundingClientRect();
1844
- const margin = 8;
1845
- const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
1846
- const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
1847
- setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
1848
- }, [x, y, apps.status]);
2209
+ // 置顶应用是异步取到的,行数会变;尺寸变化由 useMenuPos 里的 ResizeObserver 兜住。
1849
2210
 
1850
2211
  const group = props.group ?? {};
1851
2212
  const workspaceId = typeof group.workspaceId === 'string' ? group.workspaceId : '';
@@ -2357,7 +2718,6 @@ window.__ModuleLoader__.load({
2357
2718
  useChildren();
2358
2719
  usePinned();
2359
2720
  const [open, setOpen] = useState(false);
2360
- const [box, setBox] = useState(null);
2361
2721
  const hostRef = useRef(null);
2362
2722
  const closeTimer = useRef(0);
2363
2723
 
@@ -2379,27 +2739,13 @@ window.__ModuleLoader__.load({
2379
2739
  };
2380
2740
  }, [open]);
2381
2741
 
2382
- // MenuSurface 不保证转发 ref,按条目数估算高度即可(列表本身会滚动)。
2383
- useEffect(() => {
2384
- if (!open) return;
2385
- const host = hostRef.current;
2386
- if (host === null) return;
2387
- const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2388
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2389
- const height = Math.min(380, Math.max(40, count * 34 + 8));
2390
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2391
- }, [open, apps.status, apps.ids]);
2742
+ // 面板先隐藏挂载再量真实尺寸,位置按真实宽高算。
2743
+ const box = useSubmenuPos(open, hostRef, [apps.status, apps.ids]);
2392
2744
 
2393
2745
  useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2394
2746
 
2395
2747
  const openNow = () => {
2396
2748
  window.clearTimeout(closeTimer.current);
2397
- if (!open && hostRef.current !== null) {
2398
- const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2399
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2400
- const height = Math.min(380, Math.max(40, count * 34 + 8));
2401
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2402
- }
2403
2749
  setOpen(true);
2404
2750
  ensureApps();
2405
2751
  };
@@ -2458,7 +2804,7 @@ window.__ModuleLoader__.load({
2458
2804
  icon: jsx(IconRightUpOutlineRegular, { size: 14 }),
2459
2805
  children: h('span', { className: 'dsrm-host-label' },
2460
2806
  h('span', { className: 'dsrm-host-label-text' }, t('openWith')),
2461
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2807
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
2462
2808
  ),
2463
2809
  }),
2464
2810
  open ? h(MenuSurface, {
@@ -2506,7 +2852,6 @@ window.__ModuleLoader__.load({
2506
2852
  */
2507
2853
  function BlankMoreRows({ label, icon, controller, separatorBefore, onClose, children }) {
2508
2854
  const [open, setOpen] = useState(false);
2509
- const [box, setBox] = useState(null);
2510
2855
  const hostRef = useRef(null);
2511
2856
  const closeTimer = useRef(0);
2512
2857
  const entries = orderMoreItems(controller.items);
@@ -2529,22 +2874,13 @@ window.__ModuleLoader__.load({
2529
2874
  };
2530
2875
  }, [open]);
2531
2876
 
2532
- useEffect(() => {
2533
- if (!open) return;
2534
- const host = hostRef.current;
2535
- if (host === null) return;
2536
- const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2537
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2538
- }, [open, controller, entries.length]);
2877
+ // 面板先隐藏挂载再量真实尺寸,位置按真实宽高算。
2878
+ const box = useSubmenuPos(open, hostRef, [entries.length]);
2539
2879
 
2540
2880
  useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2541
2881
 
2542
2882
  const openNow = () => {
2543
2883
  window.clearTimeout(closeTimer.current);
2544
- if (!open && hostRef.current !== null) {
2545
- const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2546
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2547
- }
2548
2884
  setOpen(true);
2549
2885
  };
2550
2886
  const closeSoon = () => {
@@ -2578,7 +2914,7 @@ window.__ModuleLoader__.load({
2578
2914
  icon: headerIconNode(icon),
2579
2915
  children: h('span', { className: 'dsrm-host-label' },
2580
2916
  h('span', { className: 'dsrm-host-label-text' }, children ?? label),
2581
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2917
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
2582
2918
  ),
2583
2919
  }),
2584
2920
  open ? h(MenuSurface, {
@@ -2610,7 +2946,7 @@ window.__ModuleLoader__.load({
2610
2946
  */
2611
2947
  function HeaderToolbarMenu({ x, y, items, t, sessionId, onClose }) {
2612
2948
  const hostRef = useRef(null);
2613
- const [pos, setPos] = useState(null);
2949
+ const pos = useMenuPos(hostRef, x, y, []);
2614
2950
  const itemsConfig = useItems();
2615
2951
  const blankMenu = useBlankMenu();
2616
2952
  const apps = useApps();
@@ -2634,15 +2970,7 @@ window.__ModuleLoader__.load({
2634
2970
  };
2635
2971
  }, [onClose]);
2636
2972
 
2637
- useLayoutEffect(() => {
2638
- const host = hostRef.current;
2639
- if (host === null) return;
2640
- const rect = host.getBoundingClientRect();
2641
- const margin = 8;
2642
- const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
2643
- const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
2644
- setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
2645
- }, [x, y, items]);
2973
+ // 位置由 useMenuPos 统一夹取:菜单行数随工具条内容变化,尺寸变了再夹一次。
2646
2974
 
2647
2975
  const cwd = typeof sessionId === 'string' && sessionId !== '' ? sessionCwd(rootCtx, sessionId) : '';
2648
2976
  const enabledApps = apps.ids.filter((id) => childOn(id));
@@ -3240,6 +3568,37 @@ window.__ModuleLoader__.load({
3240
3568
  * 数据贡献的一批工作区动作;group 字段决定它挂到哪个 order 的 provider,
3241
3569
  * 从而夹在原生 rename(100)/reveal(200)/delete(300) 之间。
3242
3570
  */
3571
+ /** 菜单项里的「当前默认」勾:宿主菜单没有 checked 字段,只能自绘。 */
3572
+ function permissionCheck() {
3573
+ return h('svg', {
3574
+ className: 'dsrm-check',
3575
+ width: 14,
3576
+ height: 14,
3577
+ viewBox: '0 0 16 16',
3578
+ 'aria-hidden': 'true',
3579
+ }, h('path', {
3580
+ d: 'M3.2 8.4 6.3 11.5 12.8 5',
3581
+ fill: 'none',
3582
+ stroke: 'currentColor',
3583
+ 'stroke-width': 1.7,
3584
+ 'stroke-linecap': 'round',
3585
+ 'stroke-linejoin': 'round',
3586
+ }));
3587
+ }
3588
+
3589
+ /** 权限子菜单的图标:一把小锁,避免与「打开方式」的箭头混淆。 */
3590
+ const ContextMenuPermissionIcon = () => h('svg', {
3591
+ width: 16,
3592
+ height: 16,
3593
+ viewBox: '0 0 16 16',
3594
+ 'aria-hidden': 'true',
3595
+ fill: 'none',
3596
+ stroke: 'currentColor',
3597
+ 'stroke-width': 1.4,
3598
+ },
3599
+ h('rect', { x: 3.2, y: 7, width: 9.6, height: 6.6, rx: 1.6 }),
3600
+ h('path', { d: 'M5.4 7V5.4a2.6 2.6 0 0 1 5.2 0V7' }));
3601
+
3243
3602
  function workspaceMenuActions(ctx, value, t) {
3244
3603
  const workspaceId = typeof value?.workspaceId === 'string' ? value.workspaceId : '';
3245
3604
  if (workspaceId === '') return [];
@@ -3350,6 +3709,44 @@ window.__ModuleLoader__.load({
3350
3709
  });
3351
3710
  }
3352
3711
 
3712
+ // 该工作区新建会话的默认权限:走宿主 permissionPresets,插件只记住
3713
+ // 「工作区 → preset」并在 agent/created 时用原生 set() 落盘。
3714
+ ensurePermissionData(ctx);
3715
+ const permissionOptions = presetOptions();
3716
+ if (permissionOptions.length > 0) {
3717
+ const override = workspacePreset(workspaceId);
3718
+ const effective = effectivePreset(workspaceId);
3719
+ const children = permissionOptions.map((option) => ({
3720
+ id: `preset-${option.value}`,
3721
+ label: h('span', { className: 'dsrm-host-label' },
3722
+ h('span', { className: 'dsrm-host-label-text' }, presetLabel(option.value, t)),
3723
+ option.value === effective ? permissionCheck() : null,
3724
+ ),
3725
+ icon: { key: 'sidebar-row-menu.permission' },
3726
+ run: () => { setWorkspacePreset(rootCtx, workspaceId, option.value, t); },
3727
+ }));
3728
+ if (override !== '') {
3729
+ children.push({
3730
+ id: 'permission-follow-global',
3731
+ separatorBefore: true,
3732
+ label: t('permissionFollowGlobal'),
3733
+ icon: { key: 'sidebar-row-menu.permission' },
3734
+ run: () => { setWorkspacePreset(rootCtx, workspaceId, null, t); },
3735
+ });
3736
+ }
3737
+ items.push({
3738
+ id: 'new-session-permission',
3739
+ group: 'permission',
3740
+ separatorBefore: true,
3741
+ label: h('span', { className: 'dsrm-host-label' },
3742
+ h('span', { className: 'dsrm-host-label-text' }, t('newSessionPermission')),
3743
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
3744
+ ),
3745
+ icon: { key: 'sidebar-row-menu.permission' },
3746
+ children,
3747
+ });
3748
+ }
3749
+
3353
3750
  items.push({
3354
3751
  id: 'delete-sessions',
3355
3752
  group: 'delete',
@@ -3379,12 +3776,13 @@ window.__ModuleLoader__.load({
3379
3776
  { key: 'copy', order: 150 },
3380
3777
  { key: 'pinned', order: 210 },
3381
3778
  { key: 'open-with', order: 220 },
3779
+ { key: 'permission', order: 225 },
3382
3780
  { key: 'delete', order: 310 },
3383
3781
  ];
3384
3782
 
3385
3783
  /**
3386
3784
  * 0.2.2 的「…」按钮与右键共用同一份宿主菜单,条目本身无法感知手势,
3387
- * 所以按右键标记决定是否进入这份菜单;打开对应附加开关后,点「…」也显示。
3785
+ * 所以按右键标记决定是否进入这份菜单;设置页可按条目选择只出现在一边。
3388
3786
  */
3389
3787
  const WORKSPACE_GROUP_ELLIPSIS = {
3390
3788
  new: 'newSession',
@@ -3393,13 +3791,14 @@ window.__ModuleLoader__.load({
3393
3791
  copy: 'copyPath',
3394
3792
  tab: 'openInTab',
3395
3793
  archived: 'showArchived',
3794
+ permission: 'permissionDefault',
3396
3795
  delete: 'deleteSessions',
3397
3796
  };
3398
3797
 
3399
3798
  function workspaceGroupVisible(groupKey) {
3400
- if (rightClickMark) return true;
3401
3799
  const key = WORKSPACE_GROUP_ELLIPSIS[groupKey];
3402
- return key !== undefined && ellipsisSettings[key] === true;
3800
+ if (key === undefined) return rightClickMark;
3801
+ return placementOn(key, rightClickMark ? 'context' : 'ellipsis');
3403
3802
  }
3404
3803
 
3405
3804
  /** 注册工作区动作;服务可能晚于插件 apply 出现,带有限重试。 */
@@ -3418,6 +3817,7 @@ window.__ModuleLoader__.load({
3418
3817
  yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.archive' }, IconArchiveOutlineRegular);
3419
3818
  yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.archive-off' }, IconArchiveOffOutlineRegular);
3420
3819
  yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.open-with' }, IconRightUpOutlineRegular);
3820
+ yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.permission' }, ContextMenuPermissionIcon);
3421
3821
  yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.delete' }, IconTrashOutlineRegular);
3422
3822
  yield ctx.slots.register({ name: 'contextMenu.icon', key: 'sidebar-row-menu.app' }, ContextMenuAppIcon);
3423
3823
  });
@@ -3434,6 +3834,7 @@ window.__ModuleLoader__.load({
3434
3834
  return;
3435
3835
  }
3436
3836
  registerIcons();
3837
+ ensurePermissionData(ctx);
3437
3838
  const registered = [];
3438
3839
  for (const group of WORKSPACE_MENU_GROUPS) {
3439
3840
  try {
@@ -3441,7 +3842,7 @@ window.__ModuleLoader__.load({
3441
3842
  id: `sidebar-row-menu.workspace.${group.key}`,
3442
3843
  target: 'workspace',
3443
3844
  order: group.order,
3444
- observe: () => [appsSource],
3845
+ observe: () => [appsSource, permissionSource],
3445
3846
  resolve: (target) => {
3446
3847
  if (!workspaceGroupVisible(group.key)) return [];
3447
3848
  return workspaceMenuActions(ctx, target, localeOf(ctx))
@@ -3695,8 +4096,10 @@ window.__ModuleLoader__.load({
3695
4096
  .dsrm-submenu-trigger:hover:not(:disabled),.dsrm-submenu-trigger:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
3696
4097
  .dsrm-submenu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3697
4098
  .dsrm-submenu-chevron{color:var(--dsw-alias-label-caption)}
4099
+ .dsrm-submenu-chevron-flipped{transform:scaleX(-1)}
3698
4100
  .dsrm-host-label{display:flex;align-items:center;gap:6px;width:100%}
3699
4101
  .dsrm-host-label-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
4102
+ .dsrm-check{margin-left:auto;flex:none;color:var(--dsw-alias-label-primary)}
3700
4103
  .dsrm-submenu{position:fixed;z-index:1101;min-width:180px;max-width:300px;max-height:min(380px,64vh);overflow:auto;padding:4px}
3701
4104
  .dsrm-submenu-empty{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
3702
4105
  .dsrm-menu{position:fixed;z-index:2147483647}
@@ -3723,6 +4126,9 @@ window.__ModuleLoader__.load({
3723
4126
  .dsrm-settings-label{font-size:14px;line-height:20px}
3724
4127
  .dsrm-settings-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
3725
4128
  .dsrm-settings-controls{display:flex;align-items:center;gap:10px}
4129
+ .dsrm-placement-controls{gap:16px}
4130
+ .dsrm-placement-choice{display:flex;align-items:center;gap:6px;white-space:nowrap}
4131
+ .dsrm-placement-choice-label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
3726
4132
  .dsrm-settings-children{margin-left:12px;padding:0 0 12px 18px;border-left:1px solid var(--dsw-alias-border-l2)}
3727
4133
  .dsrm-settings-child{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:6px 14px 6px 0}
3728
4134
  .dsrm-settings-child-label{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}