@dsh-electron/dsh-plugin-multi-root-workspace 0.1.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/scope.js ADDED
@@ -0,0 +1,143 @@
1
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
2
+ import { statSync } from "node:fs";
3
+ import { Service } from "@deepseek-ai/cordis";
4
+ //#region src/scope.ts
5
+ /**
6
+ * The multi-root workspace scope: the plugin's single home for the question
7
+ * "which directories belong to this workspace".
8
+ *
9
+ * The primary root is never stored here — it is the session's immutable cwd as
10
+ * resolved by the upstream `ctx.sandboxPolicy` service. This service owns the
11
+ * additional roots, the canonical key that indexes them, and the one model-facing
12
+ * statement of that topology, so every enforcing provider (fs fence,
13
+ * kernel-sandbox dialects) and the prompt snapshot all resolve the same scope.
14
+ *
15
+ * The root table is still filled by the registry's own API (tests and smoke
16
+ * scripts today; plugin storage in M3). Keeping the empty case on the same code
17
+ * path as the populated one is deliberate — the pass-through safety net must
18
+ * exercise the real provider wiring, not a stub that later gets replaced.
19
+ *
20
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/scope
21
+ */
22
+ /**
23
+ * Canonicalize and sanitize one root list against a primary root: drop the
24
+ * primary itself, duplicates, missing/non-directory paths, and any root whose
25
+ * current resolution no longer matches the directory it was registered for;
26
+ * preserve registry order.
27
+ *
28
+ * The last rule is the security-relevant one. `canonicalPath` is `realpath`, so
29
+ * a registered directory that has since been replaced by a symlink resolves to
30
+ * a different directory — granting that would hand out a directory nobody
31
+ * registered, on nothing more than a local replace. A registration whose
32
+ * resolution moved is therefore withheld here (the registry reports it as
33
+ * `redirected`), and it comes back only when the path resolves to the recorded
34
+ * directory again.
35
+ *
36
+ * @param primaryRoot - the canonical primary root to exclude.
37
+ * @param roots - candidate roots, in registry order.
38
+ * @returns the sanitized additional roots.
39
+ */
40
+ function sanitizeAdditionalRoots(primaryRoot, roots) {
41
+ const seen = /* @__PURE__ */ new Set([primaryRoot]);
42
+ const result = [];
43
+ for (const root of roots) {
44
+ const recorded = root.recordedPath;
45
+ if (typeof recorded !== "string" || recorded === "") continue;
46
+ const canonical = canonicalPath(root.path);
47
+ if (canonical !== recorded) continue;
48
+ try {
49
+ if (!statSync(canonical).isDirectory()) continue;
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (seen.has(canonical)) continue;
54
+ seen.add(canonical);
55
+ result.push(canonical);
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * The name of the runtime-context contribution that tells the model which extra
61
+ * roots belong to this session's workspace.
62
+ */
63
+ const MULTI_ROOT_CONTEXT_NAME = "multi-root:scope";
64
+ /**
65
+ * Render the workspace topology the model is told about. Only roots are listed
66
+ * (never files), and the sentence states the two facts a denied write would
67
+ * otherwise have to teach: that the additional roots are part of the SAME
68
+ * workspace, and that the session cwd is unchanged (requirement §5, §8).
69
+ * @param primaryRoot - the canonical session workspace root.
70
+ * @param additionalRoots - canonical additional roots, in scope order.
71
+ * @returns the stable topology sentence.
72
+ */
73
+ function renderWorkspaceRootsContext(primaryRoot, additionalRoots) {
74
+ return `Current DSH workspace roots: ${JSON.stringify(additionalRoots)} are additional roots of this session's workspace. Under workspace-write they may be modified like the session workspace; the session cwd remains the primary root (${JSON.stringify(primaryRoot)}).`;
75
+ }
76
+ /**
77
+ * The `ctx.multiRootScope` service. Providers ask it — never the filesystem —
78
+ * what the current scope is, which is what keeps the fs fence and every kernel
79
+ * dialect on one permission world (requirement §13).
80
+ */
81
+ var MultiRootScopeService = class extends Service {
82
+ /** Additional roots keyed by canonical primary root; empty until roots are registered. */
83
+ rootsByPrimary = /* @__PURE__ */ new Map();
84
+ constructor(ctx) {
85
+ super(ctx, "multiRootScope");
86
+ ctx.inject(["systemPrompt", "sandboxPolicy"], (scope) => {
87
+ scope.systemPrompt.context({
88
+ name: MULTI_ROOT_CONTEXT_NAME,
89
+ order: scope.systemPrompt.getContextOrder("SANDBOX_POLICY") + 1,
90
+ text: (assembly) => {
91
+ const session = assembly.agent?.session;
92
+ if (session === void 0) return "";
93
+ const policy = scope.sandboxPolicy.resolve({ session });
94
+ if (policy.mode !== "workspace-write") return "";
95
+ const resolved = this.resolve(policy);
96
+ if (resolved.additionalRoots.length === 0) return "";
97
+ return renderWorkspaceRootsContext(resolved.primaryRoot, resolved.additionalRoots);
98
+ }
99
+ });
100
+ });
101
+ }
102
+ /**
103
+ * Resolve the scope of one confined call.
104
+ * @param policy - the upstream policy whose workspace root is the primary root.
105
+ * @returns the canonical scope; `additionalRoots` is empty when none are registered.
106
+ */
107
+ resolve(policy) {
108
+ const primaryRoot = canonicalPath(policy.workspaceRoot);
109
+ return {
110
+ primaryRoot,
111
+ additionalRoots: this.scopeOf(primaryRoot)
112
+ };
113
+ }
114
+ /**
115
+ * The additional roots registered for one canonical primary root.
116
+ * @param primaryRoot - canonical primary root (or any spelling thereof).
117
+ * @returns the canonical additional roots; empty when none are registered.
118
+ */
119
+ scopeOf(primaryRoot) {
120
+ const registered = this.rootsByPrimary.get(canonicalPath(primaryRoot));
121
+ if (registered === void 0) return [];
122
+ return sanitizeAdditionalRoots(canonicalPath(primaryRoot), registered);
123
+ }
124
+ /**
125
+ * Replace the roots registered for one primary root.
126
+ *
127
+ * M1 uses this from tests and smoke scripts only; M3 feeds it from plugin
128
+ * storage. It is a plain setter so the transition swaps the data source
129
+ * without touching any provider.
130
+ * @param primaryRoot - canonical primary root to register roots under.
131
+ * @param roots - the additional roots, in display order.
132
+ */
133
+ setAdditionalRoots(primaryRoot, roots) {
134
+ const key = canonicalPath(primaryRoot);
135
+ if (roots.length === 0) {
136
+ this.rootsByPrimary.delete(key);
137
+ return;
138
+ }
139
+ this.rootsByPrimary.set(key, [...roots]);
140
+ }
141
+ };
142
+ //#endregion
143
+ export { MULTI_ROOT_CONTEXT_NAME, MultiRootScopeService, MultiRootScopeService as default, renderWorkspaceRootsContext, sanitizeAdditionalRoots };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Workspace Folders panel: one sidebar footer action that opens a dialog
3
+ * listing this session's workspace root and the additional roots registered for
4
+ * it, with the actions the milestone requires — Add (through the composed
5
+ * directory picker), Remove, Alias, Copy Path, Reveal, and reordering.
6
+ *
7
+ * Why a footer action and its own dialog, instead of a first-class panel: the
8
+ * slots that can host a full panel (`sidebar.panellist` + the keyed `main`
9
+ * panel) exist only in the pinned 0.1.5 surface, while `sidebar.footer.action`
10
+ * exists in both supported runtimes — and `sidebar.workspaces` is a single slot
11
+ * already owned by the workspace browser, so it cannot be extended in place.
12
+ *
13
+ * Styling is the host's, not this file's: the elements carry `mrfw-*` classes
14
+ * resolved by the stylesheet the client entry injects (`styles.ts`), and every
15
+ * color in that sheet is a host `--dsw-*` token, so the panel inherits the
16
+ * native look — sidebar trigger, modal card, capsule buttons — in both themes
17
+ * without naming a single color here (see ADR-0006).
18
+ *
19
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/client/WorkspaceFoldersAction
20
+ */
21
+ import { type ReactNode } from 'react';
22
+ import { type PanelClient } from './panel-client.ts';
23
+ import type { Key } from './locales.ts';
24
+ /** Translate one key of this plugin's namespace. */
25
+ export type Translate = (key: Key, params?: Record<string, unknown>) => string;
26
+ /** What the registered action receives from the plugin body. */
27
+ export interface WorkspaceFoldersActionProps {
28
+ /** The channel client; absent when this composition has no host Connection. */
29
+ readonly panel: PanelClient | undefined;
30
+ /** Opens the composed directory picker, when one is composed. */
31
+ readonly pickDirectory: (() => Promise<string | null>) | undefined;
32
+ /** Bound translator for this plugin's namespace. */
33
+ readonly t: Translate;
34
+ /**
35
+ * The current session id, read when a request is built. A function rather
36
+ * than a value because the selection changes while the panel stays mounted.
37
+ */
38
+ readonly sessionId?: () => string | undefined;
39
+ /** Whether the sidebar renders wide content (`false` in the icon rail). */
40
+ readonly wide?: boolean;
41
+ }
42
+ /** The sidebar footer action plus the dialog it opens. */
43
+ export declare function WorkspaceFoldersAction(props: WorkspaceFoldersActionProps): ReactNode;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Browser half of the multi-root workspace plugin.
3
+ *
4
+ * One contribution: a sidebar footer action that opens the Workspace Folders
5
+ * dialog. Everything it needs arrives through services the composition already
6
+ * provides — `slots` to contribute, `locale` for its copy, `connection` for the
7
+ * channel to the host half, and (optionally) `uiWorkspace` for the composed
8
+ * directory picker and the client Session Controller for the current session.
9
+ *
10
+ * `slots.inject` is the soft seam: the footer action is registered when a
11
+ * sidebar declares that slot and never otherwise, so a runtime whose sidebar
12
+ * surface differs simply shows no panel instead of failing the client graph.
13
+ *
14
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/client
15
+ */
16
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
17
+ import { type PanelClient } from './panel-client.ts';
18
+ import { type Translate } from './WorkspaceFoldersAction.tsx';
19
+ /** The footer-action cell this plugin owns. */
20
+ export declare const SLOT_ID = "multi-root-folders";
21
+ /** Services the client half needs before it may activate. */
22
+ export declare const inject: string[];
23
+ /**
24
+ * Register the panel.
25
+ * @param ctx - the client root context.
26
+ */
27
+ export declare function apply(ctx: ClientContext): void;
28
+ export type { PanelClient, Translate };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The panel's bilingual copy.
3
+ *
4
+ * Flat `Record<string, string>` dictionaries keyed by a bare key (never
5
+ * `ns.key`), `zh` as the canonical shape and `en` pinned to it by `satisfies`,
6
+ * so a missing translation is a type error and a divergence is caught by
7
+ * `tests/locale-parity.spec.ts` as well.
8
+ *
9
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/client/locales
10
+ */
11
+ /** The locale namespace this plugin owns. */
12
+ export declare const NS = "multiRootWorkspace";
13
+ /** Chinese copy (the canonical shape). */
14
+ export declare const zh: {
15
+ 'action.label': string;
16
+ 'action.title': string;
17
+ 'panel.title': string;
18
+ 'panel.subtitle': string;
19
+ 'panel.primary': string;
20
+ 'panel.primaryNote': string;
21
+ 'panel.additional': string;
22
+ 'panel.empty': string;
23
+ 'panel.emptyHint': string;
24
+ 'panel.loading': string;
25
+ 'panel.retry': string;
26
+ 'panel.close': string;
27
+ 'panel.add': string;
28
+ 'panel.addManual': string;
29
+ 'panel.addConfirm': string;
30
+ 'panel.remove': string;
31
+ 'panel.removeConfirm': string;
32
+ 'panel.more': string;
33
+ 'panel.rename': string;
34
+ 'panel.aliasPlaceholder': string;
35
+ 'panel.aliasSave': string;
36
+ 'panel.aliasCancel': string;
37
+ 'panel.copyPath': string;
38
+ 'panel.copied': string;
39
+ 'panel.reveal': string;
40
+ 'panel.moveUp': string;
41
+ 'panel.moveDown': string;
42
+ 'state.available': string;
43
+ 'state.missing': string;
44
+ 'state.redirected': string;
45
+ 'state.invalid': string;
46
+ 'error.not-absolute': string;
47
+ 'error.missing': string;
48
+ 'error.not-a-directory': string;
49
+ 'error.equals-primary': string;
50
+ 'error.primary-overlap': string;
51
+ 'error.duplicate': string;
52
+ 'error.nested': string;
53
+ 'error.invalid-alias': string;
54
+ 'error.not-found': string;
55
+ 'error.invalid-ref': string;
56
+ 'error.storage-unavailable': string;
57
+ 'error.reveal-unavailable': string;
58
+ 'error.unavailable': string;
59
+ 'error.copy-failed': string;
60
+ 'error.fallback': string;
61
+ };
62
+ /** The canonical key set. */
63
+ export type Key = keyof typeof zh;
64
+ /** English copy, pinned to the Chinese key set. */
65
+ export declare const en: {
66
+ 'action.label': string;
67
+ 'action.title': string;
68
+ 'panel.title': string;
69
+ 'panel.subtitle': string;
70
+ 'panel.primary': string;
71
+ 'panel.primaryNote': string;
72
+ 'panel.additional': string;
73
+ 'panel.empty': string;
74
+ 'panel.emptyHint': string;
75
+ 'panel.loading': string;
76
+ 'panel.retry': string;
77
+ 'panel.close': string;
78
+ 'panel.add': string;
79
+ 'panel.addManual': string;
80
+ 'panel.addConfirm': string;
81
+ 'panel.remove': string;
82
+ 'panel.removeConfirm': string;
83
+ 'panel.more': string;
84
+ 'panel.rename': string;
85
+ 'panel.aliasPlaceholder': string;
86
+ 'panel.aliasSave': string;
87
+ 'panel.aliasCancel': string;
88
+ 'panel.copyPath': string;
89
+ 'panel.copied': string;
90
+ 'panel.reveal': string;
91
+ 'panel.moveUp': string;
92
+ 'panel.moveDown': string;
93
+ 'state.available': string;
94
+ 'state.missing': string;
95
+ 'state.redirected': string;
96
+ 'state.invalid': string;
97
+ 'error.not-absolute': string;
98
+ 'error.missing': string;
99
+ 'error.not-a-directory': string;
100
+ 'error.equals-primary': string;
101
+ 'error.primary-overlap': string;
102
+ 'error.duplicate': string;
103
+ 'error.nested': string;
104
+ 'error.invalid-alias': string;
105
+ 'error.not-found': string;
106
+ 'error.invalid-ref': string;
107
+ 'error.storage-unavailable': string;
108
+ 'error.reveal-unavailable': string;
109
+ 'error.unavailable': string;
110
+ 'error.copy-failed': string;
111
+ 'error.fallback': string;
112
+ };
113
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
114
+ interface LocaleNamespaceMap {
115
+ /** This plugin's panel copy. */
116
+ multiRootWorkspace: Key;
117
+ }
118
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The panel's own client for the Connection RPC channel.
3
+ *
4
+ * The browser half cannot use a Typert Remote namespace: contract generation
5
+ * is workspace-shaped and the client assembly's contribution list belongs to
6
+ * the shipped composition, so an out-of-tree plugin has no supported way to
7
+ * mount one (see docs/decisions/ADR-0005 and the M3 plan). What IS public and
8
+ * present in both supported runtimes is the generic Connection channel the
9
+ * shipped sibling plugin already uses: the host registers
10
+ * `connection.rpc.handle(PANEL_CHANNEL, …)` and this module calls it.
11
+ *
12
+ * The call is typed by {@link PanelResponseMap}: the endpoint decides the
13
+ * request shape AND the response type, so a caller cannot decode one endpoint's
14
+ * answer as another's. The response is additionally parsed at the boundary,
15
+ * because the type is a promise about the host while the value on the wire is
16
+ * whatever the host actually sent.
17
+ *
18
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/client/panel-client
19
+ */
20
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client';
21
+ import { type PanelEndpoint, type PanelRequest, type PanelResponseMap } from '../contract.ts';
22
+ import { type Key } from './locales.ts';
23
+ /** A failure the panel renders from its `code`. */
24
+ export declare class PanelError extends Error {
25
+ readonly code: string;
26
+ /**
27
+ * @param code - stable code from the root vocabulary, or a transport code.
28
+ * @param message - the host's fallback text.
29
+ */
30
+ constructor(code: string, message: string);
31
+ }
32
+ /**
33
+ * The copy key for one failure code, with a safe fallback for codes this build
34
+ * does not know (a newer host, or a transport failure).
35
+ * @param code - the failure code.
36
+ * @returns the locale key to render.
37
+ */
38
+ export declare function errorKeyOf(code: string): Key;
39
+ /** The panel's callable face, injected into its component. */
40
+ export interface PanelClient {
41
+ /**
42
+ * Call one endpoint.
43
+ * @param endpoint - the endpoint name; it also selects the response type.
44
+ * @param payload - the request fields for that endpoint (the endpoint name
45
+ * travels as the RPC method, so it is not repeated in the payload).
46
+ * @returns the host's value, in the shape the endpoint promises.
47
+ * @throws {PanelError} when the host answered with a failure, or with a value
48
+ * that is not what this endpoint promises.
49
+ */
50
+ call<E extends PanelEndpoint>(endpoint: E, payload: PanelRequest): Promise<PanelResponseMap[E]>;
51
+ }
52
+ /**
53
+ * Build the panel client over a Connection handle.
54
+ * @param connection - the client Connection service.
55
+ * @returns the client, with every transport failure normalized to a `PanelError`.
56
+ */
57
+ export declare function createPanelClient(connection: ConnectionHandle): PanelClient;
58
+ /** The current session id, when the composed client can name one. */
59
+ export declare function currentSessionIdOf(sessions: unknown): string | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The plugin's one stylesheet, injected into the document by the client entry.
3
+ *
4
+ * The host ships its design tokens as CSS custom properties on `body`
5
+ * (`--dsw-alias-*` semantics, `--dsw-elevation-*` shadows, `--ds-*` motion and
6
+ * code-font tokens), so this sheet names no color of its own: light and dark
7
+ * theme follow the host automatically. The recipes mirror the native surfaces
8
+ * this UI sits beside — the sidebar footer trigger, the Modal primitive, and
9
+ * the capsule Button (ADR-0006 records the decision).
10
+ *
11
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/client/styles
12
+ */
13
+ /** The stylesheet text; every class is prefixed `mrfw-` to stay collision-free. */
14
+ export declare const STYLES = "\n/* Sidebar footer row, mirroring the host ui-settings-general trigger: the row\n * wrapper overhangs each side by 2px (width calc(100% + 4px) against the\n * shell's 12px inline padding) so the icon's ink line lands at 18px from the\n * column edge \u2014 exactly where the Settings gear below sits. */\n.mrfw-triggerRow {\n flex: none;\n display: flex;\n align-items: center;\n gap: 8px;\n width: calc(100% + 4px);\n margin: 4px -2px;\n}\n\n.mrfw-triggerRow.mrfw-railRow {\n width: 36px;\n margin: 8px 0 10px;\n}\n\n.mrfw-trigger {\n box-sizing: border-box;\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 8px;\n width: auto;\n height: 42px;\n margin: 0;\n padding: 0 10px 0 8px;\n border: none;\n border-radius: 12px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n font-size: 14px;\n line-height: 22px;\n text-align: left;\n cursor: pointer;\n overflow: hidden;\n transition: background-color var(--ds-transition-duration-fast) var(--ds-ease-in-out);\n}\n\n.mrfw-trigger:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mrfw-triggerIcon {\n flex: none;\n display: inline-flex;\n}\n\n.mrfw-triggerLabel {\n overflow: hidden;\n white-space: nowrap;\n}\n\n.mrfw-triggerRail {\n flex: none;\n width: 36px;\n height: 36px;\n margin: 0;\n padding: 0;\n gap: 0;\n border-radius: 50%;\n justify-content: center;\n}\n\n.mrfw-overlay {\n position: fixed;\n inset: 0;\n z-index: 1000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n}\n\n.mrfw-mask {\n position: absolute;\n inset: 0;\n background: var(--dsw-alias-bg-mask-1);\n backdrop-filter: var(--dsw-mask-blur);\n}\n\n.mrfw-dialog {\n position: relative;\n z-index: 1;\n display: flex;\n flex-direction: column;\n width: min(640px, 100%);\n max-height: 100%;\n overflow: hidden;\n border: none;\n border-radius: 24px;\n background: var(--dsw-alias-bg-layer-2);\n box-shadow: var(--dsw-elevation-prominent);\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n outline: none;\n}\n\n.mrfw-header {\n flex: none;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 22px 14px 12px 24px;\n}\n\n.mrfw-title {\n margin: 0;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary);\n}\n\n.mrfw-close {\n flex: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n border-radius: 8px;\n background: transparent;\n cursor: pointer;\n color: var(--dsw-alias-label-secondary);\n transition: background-color var(--ds-transition-duration-fast) var(--ds-ease-in-out);\n}\n\n.mrfw-close:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mrfw-body {\n display: flex;\n flex-direction: column;\n min-height: 0;\n overflow-y: auto;\n padding: 0 24px 24px;\n}\n\n.mrfw-description {\n margin: 0 0 8px;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.mrfw-alert {\n margin: 0 0 8px;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.mrfw-section {\n display: flex;\n flex-direction: column;\n}\n\n.mrfw-section + .mrfw-section {\n margin-top: 8px;\n}\n\n.mrfw-sectionTitleRow {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 8px 0 4px;\n}\n\n.mrfw-sectionTitle {\n font-size: 14px;\n line-height: 22px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary);\n}\n\n.mrfw-sectionNote {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.mrfw-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 12px 0;\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\n}\n\n.mrfw-rowBare {\n border-bottom: none;\n}\n\n/* A root entry's two lines: the display name on top, the absolute path under\n it in the tertiary voice (the workspace rows recipe of the host sidebar). */\n.mrfw-rootText {\n display: flex;\n flex: 1 1 240px;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n.mrfw-rootName {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 14px;\n line-height: 20px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary);\n}\n\n.mrfw-rootPath {\n font-family: var(--ds-font-family-code);\n font-size: 12px;\n line-height: 18px;\n word-break: break-all;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.mrfw-note {\n margin: 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.mrfw-stateWarn {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.mrfw-btn {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n gap: 4px;\n height: 28px;\n padding: 0 10px;\n border: none;\n border-radius: 14px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n font-size: 12px;\n line-height: 18px;\n cursor: pointer;\n transition: background-color var(--ds-transition-duration-fast) var(--ds-ease-in-out);\n}\n\n.mrfw-btn:disabled {\n cursor: not-allowed;\n opacity: 0.4;\n}\n\n.mrfw-btnGhost:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mrfw-btnGhost:active:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.mrfw-btnDanger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n}\n\n.mrfw-btnOutline {\n border: 0.5px solid var(--dsw-alias-border-l3);\n background: transparent;\n}\n\n.mrfw-btnOutline:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mrfw-btnPrimary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.mrfw-btnPrimary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n/* One icon-only action: a square of the capsule family, meaning in title.\n Declared after the .mrfw-btn family so its padding wins the cascade tie:\n the capsule's 0 10px padding would otherwise squeeze the icon glyph. */\n.mrfw-iconBtn {\n width: 28px;\n padding: 0;\n border-radius: 8px;\n}\n\n.mrfw-input {\n flex: 1 1 220px;\n min-width: 0;\n height: 32px;\n padding: 0 8px;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font-family: inherit;\n font-size: 14px;\n line-height: 22px;\n outline: none;\n transition: border-color var(--ds-transition-duration-fast) var(--ds-ease-in-out);\n}\n\n.mrfw-input:focus {\n border-color: var(--dsw-alias-brand-primary);\n}\n\n.mrfw-input::placeholder {\n color: var(--dsw-alias-label-dimmed);\n}\n\n.mrfw-inputInline {\n flex: 1 1 auto;\n}\n\n.mrfw-aliasEditor {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n flex-basis: 100%;\n}\n\n.mrfw-actions {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-top: 16px;\n padding-top: 16px;\n border-top: 0.5px solid var(--dsw-alias-border-l2);\n}\n\n/* One control row, one height: the capsule buttons match the 32px input. */\n.mrfw-actions .mrfw-btn {\n height: 32px;\n border-radius: 16px;\n}\n\n.mrfw-actions .mrfw-iconBtn {\n width: 32px;\n}\n";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The user-facing surface: the `/workspace-folders` command and the panel's
3
+ * RPC channel.
4
+ *
5
+ * They share one module because they are one thing — the operator-facing edge
6
+ * over `ctx.multiRootRegistry`. The command serves the text surfaces (the
7
+ * composer, headless diagnostics) and the channel serves the browser panel;
8
+ * both resolve the same canonical workspace root, call the same registry, and
9
+ * report the same `RootValidationCode` values.
10
+ *
11
+ * Both are also the two places a READ happens, so both go through the same
12
+ * revalidation entry point: `registry.refresh()` re-stats every registered
13
+ * directory, re-resolves it against the directory it was granted for, and
14
+ * republishes the scope BEFORE the list is rendered. Listing is therefore
15
+ * enough to notice that a directory disappeared (and enough to bring a
16
+ * restored one back) — which is what the panel's "Retry" button and the
17
+ * command's `list` both promise.
18
+ *
19
+ * Both halves are SOFT: the command registers only where a command registry is
20
+ * composed, and the channel only where a host Connection and web server exist
21
+ * (web), so a headless profile mounts this row with zero effect on the
22
+ * providers.
23
+ *
24
+ * The command's own text is English: a host-side command handler has no active
25
+ * locale to consult (the browser owns locale state). The panel is bilingual.
26
+ * That split is recorded in the M3 plan and the requirements as a known limit.
27
+ *
28
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/command
29
+ */
30
+ import { Context } from '@deepseek-ai/cordis';
31
+ import { type RootStatus } from './roots.ts';
32
+ /** Services this row needs before it may activate. */
33
+ export declare const inject: string[];
34
+ /** The command name, without the leading slash. */
35
+ export declare const COMMAND_NAME = "workspace-folders";
36
+ /** One parsed command line. */
37
+ export interface FoldersCommand {
38
+ /** The subcommand; `list` when the line named none. */
39
+ readonly verb: 'list' | 'add' | 'remove' | 'alias' | 'reveal' | 'help';
40
+ /** Everything after the verb, verbatim (paths may contain spaces). */
41
+ readonly rest: string;
42
+ }
43
+ /**
44
+ * Parse the text that follows `/workspace-folders`.
45
+ * @param rawInput - the verbatim `rawInput` of the command invocation.
46
+ * @returns the subcommand and its remaining text.
47
+ * @throws {RootValidationError} `invalid-ref` for an unknown subcommand.
48
+ */
49
+ export declare function parseFoldersCommand(rawInput: string): FoldersCommand;
50
+ /**
51
+ * Render the roots report the command returns. Additional roots are numbered
52
+ * from 1, which is the numbering every other subcommand accepts; the workspace
53
+ * root is never numbered because it cannot be removed.
54
+ * @param primaryRoot - the canonical workspace root.
55
+ * @param statuses - the registry's status list.
56
+ * @param unavailable - the store failure, when the registry could not read it.
57
+ * @returns the report text.
58
+ */
59
+ export declare function renderRootsReport(primaryRoot: string, statuses: readonly RootStatus[], unavailable?: string): string;
60
+ /**
61
+ * Register the command and, where a host Connection exists, the panel channel.
62
+ * @param ctx - the host context carrying `commands`, `sandboxPolicy`, and the registry.
63
+ */
64
+ export declare function apply(ctx: Context): void;
65
+ /** The per-platform argv that reveals one directory in the OS file manager. */
66
+ export declare function revealArgv(platform: NodeJS.Platform, path: string): readonly string[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Path-containment mechanics for the multi-root filesystem fence.
3
+ *
4
+ * This is a local implementation of the upstream helper
5
+ * `isPathUnder` from `@deepseek-ai/dsh-fs-sandbox` (source path
6
+ * `packages/fs/fs-sandbox/src/containment.ts`, verified byte-identical between
7
+ * dsh `0.1.2-rc.1` and `0.1.5-rc.2` at master `c291e7961a`).
8
+ *
9
+ * Why a copy instead of an import: the published package ships only
10
+ * `lib/index.js` and `lib/types/**\/*.d.ts`, so `exports["./src/*"]` resolves to
11
+ * files that do not exist in an installed tree (deep imports succeed only where
12
+ * the package directory happens to be a symlink into a source checkout). The
13
+ * coupling policy — entry-point imports only — is recorded in
14
+ * `docs/decisions/ADR-0002-upstream-coupling-policy.md`; behavioral equality
15
+ * with the upstream fence is pinned by the differential suite in
16
+ * `tests/fs-parity.spec.ts`.
17
+ *
18
+ * Canonical spellings take the fast lexical path; filesystem identity supplies
19
+ * the conservative fallback for alias-equivalent roots such as Windows 8.3
20
+ * names and casing.
21
+ *
22
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/containment
23
+ */
24
+ /**
25
+ * Determine whether a canonical target is a writable root or lies beneath it.
26
+ * The lexical fast path handles normal canonical spellings. When spellings
27
+ * differ, walk the target's existing ancestors and compare filesystem identity
28
+ * with the root; this recognizes Windows long-name/8.3 aliases and casing
29
+ * without weakening containment to a textual approximation.
30
+ * @param path - canonical target key, which may end in a missing suffix.
31
+ * @param root - canonical writable root.
32
+ * @param caseSensitive - whether lexical comparison preserves case; defaults
33
+ * to the host filesystem convention used by supported platforms.
34
+ * @returns whether the target is the root or a descendant of it.
35
+ */
36
+ export declare function isPathUnder(path: string, root: string, caseSensitive?: boolean): Promise<boolean>;