@yadsh/dsh-session-scope 0.5.1 → 0.6.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/README.md CHANGED
@@ -48,11 +48,11 @@ Version `0.5.0` implements the specification through Phase 4:
48
48
 
49
49
  - durable `session-scope/set` snapshots with a last-write-wins fold;
50
50
  - canonical root validation, nested-root collapse, navigation ancestors, and stable error codes;
51
- - typed host operations, the `/scope` command, and a `session-scope` projection;
51
+ - projection-backed scope state and capabilities, a dedicated `sessionScope/list` RPC, and a write-only `/scope` command;
52
52
  - filtered directory listings and scoped glob, grep, and search roots;
53
53
  - per-session filesystem enforcement carried with `AsyncLocalStorage`;
54
54
  - a monotonic final guard for known path-aware tools;
55
- - model-facing context that names accessible roots without revealing hidden siblings;
55
+ - model-facing context that names accessible roots without revealing hidden siblings; `full` emits no scope prompt and bypasses the filesystem carrier;
56
56
  - an independent **Scope** chip and tree picker beside Workspace and permission controls;
57
57
  - Linux isolation for one-shot bash and persistent PTY creation;
58
58
  - permission-aware read-only and writable mounts in an empty workspace overlay;
@@ -70,6 +70,7 @@ Version `0.5.0` implements the specification through Phase 4:
70
70
  - Isolated scope requires the supported Linux sandbox path and a recognized bubblewrap profile.
71
71
  - Unknown runner profiles, partial enforcement, and unsupported platforms fail closed.
72
72
  - Scope changes are blocked while foreground jobs, background jobs, or persistent terminals retain the previous mount view.
73
+ - Opening or refreshing the Scope editor is read-only: it uses projections and `sessionScope/list`, so it does not add command rows to session history. Applying an unchanged scope is a no-op.
73
74
 
74
75
  ## Requirements
75
76
 
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "requiredClientFeatures": [
15
15
  "conversation.input.left",
16
+ "remote.$mount",
16
17
  "remote.commands",
17
18
  "session-projections"
18
19
  ]
package/lib/client.js CHANGED
@@ -6,16 +6,16 @@
6
6
  // with the web shell's module loader (window.__ModuleLoader__) and exports a
7
7
  // Cordis client plugin. It requires only `react` and `react-dom` (both are
8
8
  // shell statics); everything else comes from client services (`slots`,
9
- // `connection`, `remote`, `sessions`).
9
+ // `remote`, `sessions`).
10
10
  //
11
11
  // It contributes an independent Scope chip beside the Workspace picker while
12
12
  // a session is blank and beside the permission selector after the first turn.
13
- // The editor consumes the `session-scope` projection and writes
14
- // complete snapshots through `/scope`; permission and scope never share UI
15
- // state.
16
- // - The editor walks the directory tree (breadcrumbs up to the filesystem
17
- // root) and toggles which directories the agent may write to, in addition
18
- // to the session workspace. Changes go through the host's
13
+ // The editor consumes state and host capabilities from the `session-scope`
14
+ // projection and writes complete snapshots through `/scope`; permission and
15
+ // scope never share UI state.
16
+ // - The editor walks the directory tree beneath the session workspace and
17
+ // toggles which directories the agent may write to, in addition to the
18
+ // session workspace. Changes go through the host's
19
19
  // `/scope` command; the `session-scope` session projection
20
20
  // pushes the state back, so the button and the tree stay in sync with the
21
21
  // server.
@@ -24,10 +24,8 @@
24
24
  // z-index, because the composer seat is `position: sticky` inside its own
25
25
  // stacking context — an in-place fixed overlay would be clipped or buried.
26
26
  //
27
- // Directory listings come from the host's `host.listDirectory` RPC (the
28
- // browse capability) when the composition serves it; when the composition
29
- // serves the native picker instead, the tree falls back to the plugin's own
30
- // `/scope list` host command.
27
+ // Directory listings come from the plugin's dedicated `sessionScope/list`
28
+ // RPC. Read-only UI refreshes never execute durable slash commands.
31
29
  window.__ModuleLoader__.load({
32
30
  id: '@yadsh/dsh-session-scope',
33
31
  factory: (require) => {
@@ -60,6 +58,7 @@ window.__ModuleLoader__.load({
60
58
  '.wss-mode { border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); padding: 7px 8px; font: inherit; font-size: 12px; cursor: pointer; }',
61
59
  '.wss-mode:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }',
62
60
  '.wss-modeOn { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-label-primary); background: var(--dsw-alias-fill-tsp-secondary); }',
61
+ '.wss-modeUnavailable { opacity: .55; }',
63
62
  '.wss-crumbs { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; font-size: 12px; line-height: 18px; }',
64
63
  '.wss-crumb { border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; padding: 1px 4px; border-radius: 6px; font: inherit; }',
65
64
  '.wss-crumb:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }',
@@ -106,13 +105,18 @@ window.__ModuleLoader__.load({
106
105
  function sepOf(path) {
107
106
  return path.indexOf('\\') !== -1 ? '\\' : '/';
108
107
  }
108
+ function comparablePath(path) {
109
+ return sepOf(path) === '\\' ? path.toLowerCase() : path;
110
+ }
109
111
  // Whether `path` is `root` or lies beneath it (separator-aware prefix).
110
112
  function isUnder(path, root) {
111
- if (path === root)
113
+ var comparableTarget = comparablePath(path);
114
+ var comparableRoot = comparablePath(root);
115
+ if (comparableTarget === comparableRoot)
112
116
  return true;
113
117
  var sep = sepOf(root);
114
- var prefix = root.endsWith(sep) ? root : root + sep;
115
- return path.indexOf(prefix) === 0;
118
+ var prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
119
+ return comparableTarget.indexOf(prefix) === 0;
116
120
  }
117
121
  // The deepest selected root that covers `path`, or undefined.
118
122
  function coveringRoot(path, roots) {
@@ -129,9 +133,89 @@ window.__ModuleLoader__.load({
129
133
  var parts = path.split(sep).filter(Boolean);
130
134
  return parts.length === 0 ? path : parts[parts.length - 1];
131
135
  }
136
+ // Render paths relative to the session workspace. Absolute paths remain
137
+ // host-side implementation details and never need to appear in the picker.
138
+ function displayPath(path, root) {
139
+ if (typeof path !== 'string' || typeof root !== 'string' || !isUnder(path, root))
140
+ return baseName(path);
141
+ if (comparablePath(path) === comparablePath(root))
142
+ return '.';
143
+ var relative = path.slice(root.length);
144
+ var sep = sepOf(root);
145
+ while (relative.startsWith(sep))
146
+ relative = relative.slice(1);
147
+ return relative.split(sep).join('/');
148
+ }
132
149
  function normalizeDraftRoots(roots) {
133
150
  return Array.isArray(roots) ? roots.filter(function (root) { return typeof root === 'string'; }) : [];
134
151
  }
152
+ function comparableScopeRoots(roots) {
153
+ var ordered = normalizeDraftRoots(roots).slice().sort(function (left, right) {
154
+ return left.length - right.length || comparablePath(left).localeCompare(comparablePath(right));
155
+ });
156
+ var collapsed = [];
157
+ for (var i = 0; i < ordered.length; i++) {
158
+ if (!collapsed.some(function (root) { return isUnder(ordered[i], root); }))
159
+ collapsed.push(ordered[i]);
160
+ }
161
+ return collapsed.sort(function (left, right) { return comparablePath(left).localeCompare(comparablePath(right)); });
162
+ }
163
+ function sameRoots(left, right) {
164
+ var a = comparableScopeRoots(left);
165
+ var b = comparableScopeRoots(right);
166
+ return a.length === b.length && a.every(function (root, index) {
167
+ return comparablePath(root) === comparablePath(b[index]);
168
+ });
169
+ }
170
+ var stringSchema = {
171
+ parse: function (value) {
172
+ if (typeof value !== 'string' || value.length === 0)
173
+ throw new TypeError('expected a non-empty string');
174
+ return value;
175
+ },
176
+ };
177
+ var directoryListingSchema = {
178
+ parse: function (value) {
179
+ if (value === null || typeof value !== 'object')
180
+ throw new TypeError('expected a directory listing');
181
+ if (typeof value.path !== 'string' || typeof value.home !== 'string')
182
+ throw new TypeError('invalid directory listing roots');
183
+ if (!Array.isArray(value.crumbs) || !Array.isArray(value.entries))
184
+ throw new TypeError('invalid directory listing rows');
185
+ return {
186
+ path: value.path,
187
+ home: value.home,
188
+ crumbs: value.crumbs.map(function (crumb) {
189
+ if (crumb === null || typeof crumb !== 'object' || typeof crumb.name !== 'string' || typeof crumb.path !== 'string') {
190
+ throw new TypeError('invalid directory crumb');
191
+ }
192
+ return { name: crumb.name, path: crumb.path };
193
+ }),
194
+ entries: value.entries.map(function (entry) {
195
+ if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || typeof entry.path !== 'string') {
196
+ throw new TypeError('invalid directory entry');
197
+ }
198
+ return { name: entry.name, path: entry.path, hidden: entry.hidden === true };
199
+ }),
200
+ truncated: value.truncated === true,
201
+ };
202
+ },
203
+ };
204
+ var scopeRemoteContribution = {
205
+ package: '@yadsh/dsh-session-scope',
206
+ descriptors: [{
207
+ id: '@yadsh/dsh-session-scope#sessionScope/list',
208
+ service: 'sessionScopeRead',
209
+ namespace: 'sessionScope',
210
+ method: 'list',
211
+ invocation: { kind: 'direct' },
212
+ parameters: [
213
+ { name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'strict', typeSymbol: 'SessionId', schema: stringSchema } },
214
+ { name: 'path', wire: 'path', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: stringSchema } },
215
+ ],
216
+ result: { mode: 'strict', typeSymbol: 'DirectoryListing', schema: directoryListingSchema },
217
+ }],
218
+ };
135
219
  function apply(ctx) {
136
220
  var styleTag = null;
137
221
  try {
@@ -140,18 +224,29 @@ window.__ModuleLoader__.load({
140
224
  document.head.appendChild(styleTag);
141
225
  }
142
226
  catch (err) { /* styling is cosmetic */ }
143
- function connection() {
144
- var value = ctx.get('connection');
145
- return value !== undefined && value !== null ? value : undefined;
146
- }
147
227
  function remote() {
148
228
  var value = ctx.get('remote');
149
229
  return value !== undefined && value !== null ? value : undefined;
150
230
  }
151
- function api() {
152
- var conn = connection();
153
- return conn !== undefined && conn.api !== undefined ? conn.api : undefined;
154
- }
231
+ var scopeRemoteDispose = null;
232
+ var scopeRemoteError = null;
233
+ var scopeRemoteFace = null;
234
+ var rem = remote();
235
+ var scopeRemoteReady = rem !== undefined && typeof rem.$mount === 'function'
236
+ ? rem.$mount(scopeRemoteContribution).then(function (dispose) {
237
+ scopeRemoteDispose = dispose;
238
+ if (typeof ctx.inject !== 'function')
239
+ throw new Error('session-scope: client injection unavailable');
240
+ return ctx.inject(['remote.sessionScope'], function (remoteCtx) {
241
+ var injectedRemote = remoteCtx.get('remote');
242
+ scopeRemoteFace = injectedRemote.sessionScope;
243
+ });
244
+ }).catch(function (err) {
245
+ scopeRemoteError = err instanceof Error ? err.message : String(err);
246
+ })
247
+ : Promise.resolve().then(function () {
248
+ scopeRemoteError = 'session-scope: Remote gateway unavailable';
249
+ });
155
250
  // Execute one slash-command and return { ok, result } where result is
156
251
  // the normalized { kind, text } command result when the host answered.
157
252
  async function runCommand(sessionId, line) {
@@ -179,49 +274,35 @@ window.__ModuleLoader__.load({
179
274
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
180
275
  }
181
276
  }
182
- // List one directory level. Primary: host.listDirectory (browse
183
- // capability). Fallback: the plugin's own /scope list
184
- // command, used when the composition serves the native picker.
277
+ // List one directory level through the dedicated host RPC.
185
278
  async function listLevel(sessionId, path) {
186
- var face = api();
187
- if (face !== undefined && face.host !== undefined && typeof face.host.listDirectory === 'function') {
188
- try {
189
- var response = await face.host.listDirectory({ path: path });
190
- if (response !== undefined && response.result !== undefined && response.result.ok === true) {
191
- return { ok: true, value: response.result.value, source: 'browse' };
192
- }
193
- if (response !== undefined && response.result !== undefined && response.result.error !== undefined) {
194
- var code = response.result.error.code;
195
- if (code === 'directory-picker-unavailable') {
196
- return fallbackList(sessionId, path);
197
- }
198
- return { ok: false, error: response.result.error.message };
199
- }
200
- }
201
- catch (err) {
202
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
203
- }
279
+ await scopeRemoteReady;
280
+ if (scopeRemoteError !== null)
281
+ return { ok: false, error: scopeRemoteError };
282
+ if (scopeRemoteFace === null || typeof scopeRemoteFace.list !== 'function') {
283
+ return { ok: false, error: 'session-scope: read RPC unavailable' };
204
284
  }
205
- return fallbackList(sessionId, path);
206
- }
207
- async function fallbackList(sessionId, path) {
208
- var outcome = await runCommand(sessionId, '/scope list ' + path);
209
- if (!outcome.ok)
210
- return { ok: false, error: outcome.error };
211
285
  try {
212
- return { ok: true, value: JSON.parse(outcome.result.text), source: 'command' };
286
+ var response = await scopeRemoteFace.list(sessionId, path);
287
+ if (response !== undefined && response.ok === true) {
288
+ return { ok: true, value: response.value, source: 'scope-rpc' };
289
+ }
290
+ var message = response !== undefined && response.error !== undefined && response.error.message !== undefined
291
+ ? response.error.message
292
+ : 'session-scope: directory listing failed';
293
+ return { ok: false, error: message };
213
294
  }
214
295
  catch (err) {
215
- return { ok: false, error: 'session-scope: host returned an invalid listing' };
296
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
216
297
  }
217
298
  }
218
299
  // ---------- the scope editor (modal with the directory tree) ----------
219
300
  function ScopeEditor(props) {
220
301
  // props: sessionId, workspaceRoot (injected cwd, may be undefined),
221
- // projectedRoot, scopeMode, scopeRoots, onClose
302
+ // projectedRoot, scopeMode, scopeRoots, capabilities, onClose
222
303
  var state = React.useState({
223
304
  root: null,
224
- rootSource: null, // 'injected' | 'projection' | 'info'
305
+ rootSource: null, // 'injected' | 'projection'
225
306
  path: null,
226
307
  listing: null, // { path, crumbs, entries, truncated }
227
308
  loading: false,
@@ -229,7 +310,7 @@ window.__ModuleLoader__.load({
229
310
  error: null,
230
311
  phase: L('正在解析工作区…', 'Resolving workspace…'),
231
312
  retryToken: 0,
232
- isolatedSupported: null,
313
+ isolatedSupported: props.capabilities !== undefined ? props.capabilities.isolated === true : null,
233
314
  mode: props.scopeMode === 'focused' || props.scopeMode === 'isolated' ? props.scopeMode : 'full',
234
315
  // Local pending content roots. In full mode the workspace root is
235
316
  // inserted after root resolution to keep the checkbox semantics.
@@ -238,27 +319,15 @@ window.__ModuleLoader__.load({
238
319
  var snap = state[0];
239
320
  var setSnap = state[1];
240
321
  var patch = function (part) { setSnap(function (prev) { return Object.assign({}, prev, part); }); };
241
- React.useEffect(function () {
242
- var cancelled = false;
243
- runCommand(props.sessionId, '/scope capabilities').then(function (outcome) {
244
- if (cancelled || !outcome.ok)
245
- return;
246
- try {
247
- var capabilities = JSON.parse(outcome.result.text);
248
- patch({ isolatedSupported: capabilities.isolated === true });
249
- }
250
- catch (err) { /* keep unknown capability state */ }
251
- });
252
- return function () { cancelled = true; };
253
- }, [props.sessionId]);
254
- // Resolve the workspace root through a chain of sources: the injected
255
- // session cwd, the session-scope projection root, then `/scope show`.
322
+ // Resolve the immutable workspace root from the session list or the
323
+ // session-scope projection. No read command is issued from the UI.
256
324
  function applyRoot(root, source, mode, roots) {
257
325
  var nextMode = mode === 'focused' || mode === 'isolated' ? mode : 'full';
258
326
  patch({
259
327
  root: root,
260
328
  rootSource: source,
261
329
  phase: L('正在加载目录…', 'Loading directories…'),
330
+ error: null,
262
331
  mode: nextMode,
263
332
  draft: nextMode === 'full' ? [root] : normalizeDraftRoots(roots),
264
333
  });
@@ -280,34 +349,7 @@ window.__ModuleLoader__.load({
280
349
  applyRoot(projected, 'projection', snap.mode, snap.draft);
281
350
  return;
282
351
  }
283
- patch({ phase: L('正在解析工作区…', 'Resolving workspace') });
284
- timer = setTimeout(function () {
285
- if (cancelled || snap.root !== null)
286
- return;
287
- patch({ loading: false, error: L('解析工作区超时 — 请重试', 'resolving the workspace timed out — please retry'), phase: null });
288
- }, 12000);
289
- var outcome = await runCommand(props.sessionId, '/scope show');
290
- if (cancelled)
291
- return;
292
- if (timer !== null) {
293
- clearTimeout(timer);
294
- timer = null;
295
- }
296
- if (!outcome.ok) {
297
- patch({ loading: false, error: outcome.error, phase: null });
298
- return;
299
- }
300
- var info = null;
301
- try {
302
- info = JSON.parse(outcome.result.text);
303
- }
304
- catch (err) { /* invalid */ }
305
- var root = info !== null && typeof info.workspaceRoot === 'string' && info.workspaceRoot !== '' ? info.workspaceRoot : null;
306
- if (root === null) {
307
- patch({ loading: false, error: L('无法解析工作区根目录', 'could not resolve the workspace root'), phase: null });
308
- return;
309
- }
310
- applyRoot(root, 'info', info.mode, info.roots);
352
+ patch({ loading: false, error: L('无法解析工作区根目录', 'could not resolve the workspace root'), phase: null });
311
353
  }
312
354
  catch (err) {
313
355
  if (cancelled)
@@ -399,6 +441,13 @@ window.__ModuleLoader__.load({
399
441
  // and a rejection handler both settle the flag and surface a visible
400
442
  // error, keeping the modal open with the draft intact.
401
443
  function save(mode, draft) {
444
+ var effectiveMode = snap.root !== null && draft.indexOf(snap.root) !== -1 ? 'full' : mode;
445
+ var effectiveRoots = effectiveMode === 'full' ? [] : normalizeDraftRoots(draft);
446
+ var currentRoots = normalizeDraftRoots(props.scopeRoots);
447
+ if (effectiveMode === props.scopeMode && sameRoots(effectiveRoots, currentRoots)) {
448
+ props.onClose();
449
+ return;
450
+ }
402
451
  patch({ saving: true, error: null });
403
452
  var settled = false;
404
453
  var timer = setTimeout(function () {
@@ -407,7 +456,6 @@ window.__ModuleLoader__.load({
407
456
  settled = true;
408
457
  patch({ saving: false, error: L('保存超时 — 请重试', 'saving timed out — please retry') });
409
458
  }, 12000);
410
- var effectiveMode = snap.root !== null && draft.indexOf(snap.root) !== -1 ? 'full' : mode;
411
459
  var command = effectiveMode === 'full'
412
460
  ? '/scope full'
413
461
  : '/scope ' + effectiveMode + ' ' + JSON.stringify(draft);
@@ -458,8 +506,12 @@ window.__ModuleLoader__.load({
458
506
  patch({ draft: next, error: null });
459
507
  }
460
508
  function selectMode(mode) {
461
- if (mode === 'isolated' && snap.isolatedSupported === false)
509
+ if (mode === 'isolated' && snap.isolatedSupported === false) {
510
+ patch({
511
+ error: L('隔离模式需要支持 bubblewrap 的 Linux 主机。此主机仍可使用聚焦模式。', 'Isolated mode requires a Linux host with supported bubblewrap. Focused mode remains available on this host.'),
512
+ });
462
513
  return;
514
+ }
463
515
  if (mode === 'full') {
464
516
  patch({ mode: 'full', draft: snap.root === null ? [] : [snap.root], error: null });
465
517
  return;
@@ -495,6 +547,7 @@ window.__ModuleLoader__.load({
495
547
  var listing = snap.listing;
496
548
  var crumbs = listing !== null ? listing.crumbs : [];
497
549
  var entries = listing !== null ? listing.entries : [];
550
+ var visibleCrumbs = snap.root === null ? [] : crumbs.filter(function (crumb) { return isUnder(crumb.path, snap.root); });
498
551
  var overlay = React.createElement('div', { className: 'wss-overlay' }, React.createElement('div', { className: 'wss-modal', ref: modalRef, role: 'dialog', 'aria-label': L('会话范围', 'Session scope') }, React.createElement('div', { className: 'wss-head' }, React.createElement('span', { className: 'wss-title' }, L('会话范围', 'Session Scope')), React.createElement('button', { type: 'button', className: 'wss-close', onClick: props.onClose, 'aria-label': L('关闭', 'Close') }, IconClose())), React.createElement('div', { className: 'wss-caption' }, L('范围控制 agent 可以看到的工作区部分,与读写权限无关。Focused 限制 DSH 文件工具;Isolated 还限制受支持的 shell 进程。', 'Scope controls which workspace areas the agent can see, independently from read/write permission. Focused restricts DSH filesystem tools; Isolated also confines supported shell processes.')), React.createElement('div', { className: 'wss-modes', role: 'radiogroup', 'aria-label': L('范围模式', 'Scope mode') }, [
499
552
  { value: 'full', label: L('整个工作区', 'Entire workspace') },
500
553
  { value: 'focused', label: L('聚焦', 'Focused') },
@@ -506,9 +559,10 @@ window.__ModuleLoader__.load({
506
559
  type: 'button',
507
560
  role: 'radio',
508
561
  'aria-checked': snap.mode === option.value,
509
- className: 'wss-mode' + (snap.mode === option.value ? ' wss-modeOn' : ''),
510
- disabled: snap.saving || unavailable,
511
- title: unavailable ? L('此主机不支持隔离模式', 'Isolated mode is unavailable on this host') : option.label,
562
+ className: 'wss-mode' + (snap.mode === option.value ? ' wss-modeOn' : '') + (unavailable ? ' wss-modeUnavailable' : ''),
563
+ disabled: snap.saving,
564
+ 'aria-disabled': unavailable,
565
+ title: unavailable ? L('需要支持 bubblewrap 的 Linux 主机', 'Requires a Linux host with supported bubblewrap') : option.label,
512
566
  onClick: function () { selectMode(option.value); },
513
567
  }, option.label);
514
568
  })), snap.phase !== null && React.createElement('div', { className: 'wss-busy' }, snap.phase), snap.error !== null && React.createElement('div', { className: 'wss-error' }, snap.error, React.createElement('button', {
@@ -516,30 +570,30 @@ window.__ModuleLoader__.load({
516
570
  className: 'wss-btn wss-btnGhost',
517
571
  onClick: retry,
518
572
  style: { marginLeft: 8 },
519
- }, L('重试', 'Retry'))), snap.root !== null && React.createElement('div', { className: 'wss-crumbs' }, crumbs.map(function (crumb, index) {
573
+ }, L('重试', 'Retry'))), snap.root !== null && React.createElement('div', { className: 'wss-crumbs' }, visibleCrumbs.map(function (crumb, index) {
520
574
  return React.createElement(React.Fragment, { key: crumb.path }, index > 0 && React.createElement('span', { className: 'wss-crumbSep' }, '/'), React.createElement('button', {
521
575
  type: 'button',
522
576
  className: 'wss-crumb',
523
- title: crumb.path,
577
+ title: displayPath(crumb.path, snap.root),
524
578
  onClick: function () {
525
579
  if (crumb.path !== snap.path)
526
580
  enter(crumb.path);
527
581
  },
528
- }, crumb.name));
582
+ }, index === 0 ? '.' : crumb.name));
529
583
  })), React.createElement('div', { className: 'wss-tree' }, snap.loading && React.createElement('div', { className: 'wss-busy' }, L('加载中…', 'Loading…')), !snap.loading && snap.path !== null && React.createElement('div', { className: 'wss-row' }, React.createElement('button', {
530
584
  type: 'button',
531
585
  className: 'wss-check' + (coveringRoot(snap.path, snap.draft) !== undefined ? ' wss-checkOn' : ''),
532
586
  disabled: snap.saving || (coveringRoot(snap.path, snap.draft) !== undefined && snap.draft.indexOf(snap.path) === -1),
533
- 'aria-label': L('切换目录', 'Toggle directory') + ' ' + snap.path,
587
+ 'aria-label': L('切换目录', 'Toggle directory') + ' ' + displayPath(snap.path, snap.root),
534
588
  title: coveringRoot(snap.path, snap.draft) !== undefined && snap.draft.indexOf(snap.path) === -1
535
589
  ? L('经父目录包含:取消父目录后整个子树将不可见', 'Included via a parent directory; uncheck the parent to hide its whole subtree')
536
590
  : snap.path === snap.root
537
591
  ? (snap.draft.indexOf(snap.path) !== -1
538
592
  ? L('整个工作区可见 — 取消勾选后选择聚焦范围', 'The entire workspace is visible — uncheck to choose a focused scope')
539
593
  : L('勾选以显示整个工作区', 'Check to expose the entire workspace'))
540
- : snap.path,
594
+ : displayPath(snap.path, snap.root),
541
595
  onClick: function (ev) { ev.stopPropagation(); toggle(snap.path); },
542
- }, coveringRoot(snap.path, snap.draft) !== undefined ? IconCheck() : null), React.createElement('span', { className: 'wss-rowName' }, IconFolder(), React.createElement('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, snap.path)), coveringRoot(snap.path, snap.draft) !== undefined && snap.draft.indexOf(snap.path) === -1 &&
596
+ }, coveringRoot(snap.path, snap.draft) !== undefined ? IconCheck() : null), React.createElement('span', { className: 'wss-rowName' }, IconFolder(), React.createElement('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, displayPath(snap.path, snap.root))), coveringRoot(snap.path, snap.draft) !== undefined && snap.draft.indexOf(snap.path) === -1 &&
543
597
  React.createElement('span', { className: 'wss-hint' }, L('经父目录包含', 'via parent')), snap.path === snap.root && coveringRoot(snap.path, snap.draft) === undefined &&
544
598
  React.createElement('span', { className: 'wss-hint' }, L('聚焦范围', 'focused scope'))), !snap.loading && snap.path !== null && entries.map(function (entry) {
545
599
  var covered = coveringRoot(entry.path, snap.draft);
@@ -553,12 +607,12 @@ window.__ModuleLoader__.load({
553
607
  type: 'button',
554
608
  className: 'wss-check' + (on ? ' wss-checkOn' : ''),
555
609
  disabled: snap.saving || (on && !self),
556
- 'aria-label': L('切换目录', 'Toggle directory') + ' ' + entry.path,
557
- title: on && !self ? L('经父目录包含:取消父目录后整个子树将不可见', 'Included via a parent directory; uncheck the parent to hide its whole subtree') : entry.path,
610
+ 'aria-label': L('切换目录', 'Toggle directory') + ' ' + displayPath(entry.path, snap.root),
611
+ title: on && !self ? L('经父目录包含:取消父目录后整个子树将不可见', 'Included via a parent directory; uncheck the parent to hide its whole subtree') : displayPath(entry.path, snap.root),
558
612
  onClick: function (ev) { ev.stopPropagation(); toggle(entry.path); },
559
613
  }, on ? IconCheck() : null), React.createElement('span', { className: 'wss-rowName' + (entry.hidden ? ' wss-rowNameDim' : '') }, IconFolder(), React.createElement('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.name)), on && !self && React.createElement('span', { className: 'wss-hint' }, L('经父目录', 'via parent')), React.createElement('span', { className: 'wss-chevron' }, IconChevron()));
560
614
  }), !snap.loading && snap.path !== null && entries.length === 0 &&
561
- React.createElement('div', { className: 'wss-empty' }, L('(无子目录)', '(no subdirectories)'))), React.createElement('div', { className: 'wss-foot' }, React.createElement('span', { className: 'wss-footRoots', title: snap.draft.join('\n') }, (snap.mode === 'full'
615
+ React.createElement('div', { className: 'wss-empty' }, L('(无子目录)', '(no subdirectories)'))), React.createElement('div', { className: 'wss-foot' }, React.createElement('span', { className: 'wss-footRoots', title: snap.draft.map(function (root) { return displayPath(root, snap.root); }).join('\n') }, (snap.mode === 'full'
562
616
  ? L('整个工作区 · ', 'entire workspace · ')
563
617
  : (snap.mode === 'isolated' ? L('隔离 · ', 'isolated · ') : L('聚焦 · ', 'focused · '))) +
564
618
  (snap.draft.length === 0
@@ -630,6 +684,7 @@ window.__ModuleLoader__.load({
630
684
  var roots = scope !== undefined && Array.isArray(scope.roots) ? scope.roots : [];
631
685
  var projectedRoot = scope !== undefined && typeof scope.workspaceRoot === 'string' && scope.workspaceRoot !== '' ? scope.workspaceRoot : undefined;
632
686
  var mode = scope !== undefined && (scope.mode === 'focused' || scope.mode === 'isolated') ? scope.mode : 'full';
687
+ var capabilities = scope !== undefined && scope.capabilities !== undefined ? scope.capabilities : undefined;
633
688
  var label = mode === 'full'
634
689
  ? L('范围:全部', 'Scope: All')
635
690
  : roots.length === 0
@@ -658,6 +713,7 @@ window.__ModuleLoader__.load({
658
713
  projectedRoot: projectedRoot,
659
714
  scopeMode: mode,
660
715
  scopeRoots: roots,
716
+ capabilities: capabilities,
661
717
  onClose: function () { setOpen(false); },
662
718
  }));
663
719
  }
@@ -668,8 +724,7 @@ window.__ModuleLoader__.load({
668
724
  function scopeInjection(sessionId) {
669
725
  // The session's workspace root never changes; the sessions list
670
726
  // store (byId, keyed by session id) is the cheapest reliable
671
- // source. The editor falls back to the projection root and then to
672
- // /scope show.
727
+ // source. The editor falls back to the session-scope projection.
673
728
  var root = undefined;
674
729
  try {
675
730
  var sessions = ctx.get('sessions');
@@ -704,12 +759,18 @@ window.__ModuleLoader__.load({
704
759
  }
705
760
  catch (err) { /* best effort */ }
706
761
  }
762
+ if (scopeRemoteDispose !== null) {
763
+ try {
764
+ void scopeRemoteDispose();
765
+ }
766
+ catch (err) { /* best effort */ }
767
+ }
707
768
  if (styleTag !== null && styleTag.parentNode !== null)
708
769
  styleTag.parentNode.removeChild(styleTag);
709
770
  };
710
771
  }
711
772
  exports.apply = apply;
712
- exports.inject = ['slots', 'connection', 'remote', 'remote.commands', 'sessions'];
773
+ exports.inject = ['slots', 'remote', 'remote.commands', 'sessions'];
713
774
  return module.exports;
714
775
  },
715
776
  });