dsh-split-view 0.2.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/LICENSE +21 -0
- package/README.md +62 -0
- package/api.md +292 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +16 -0
- package/package.json +71 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,2817 @@
|
|
|
1
|
+
// dsh-split-view — browser half (client plugin bundle).
|
|
2
|
+
//
|
|
3
|
+
// Loaded by dsh-client-modules at /plugins/dsh-split-view/client.js and
|
|
4
|
+
// executed through the vendored cordis Loader's lazy-CJS module table
|
|
5
|
+
// (window.__ModuleLoader__.load). Same bundle shape as dsh-dream-skin and
|
|
6
|
+
// the shipped ui-* packages: plain CJS factory, require() answered from the
|
|
7
|
+
// shell module table (platform seed words + registered client bundles).
|
|
8
|
+
//
|
|
9
|
+
// Architecture:
|
|
10
|
+
// - Top window (no ?dshPane parameter): shadows the 'root' slot (priority -1,
|
|
11
|
+
// lowest renders) and draws a split container. Each cell is an iframe
|
|
12
|
+
// loading the full DSH client at /?dshPane=<id>[&dshSession=<sid>|&dshNew=1].
|
|
13
|
+
// - Pane window (has ?dshPane): the pane agent pins the client to its assigned
|
|
14
|
+
// session via ctx.sessions.open() at boot (or ctx.sessions.clear() for a
|
|
15
|
+
// fresh pane), reports selection changes and focus to the parent, and
|
|
16
|
+
// forwards the split keyboard shortcuts as postMessage commands.
|
|
17
|
+
// - Preferences (shortcuts, focus highlight color, title-bar visibility) are
|
|
18
|
+
// owned by the top window (it draws everything they control) and persisted
|
|
19
|
+
// in its localStorage; panes hold a mirror synced over postMessage and edit
|
|
20
|
+
// through the plugin's settings section, which the shell only renders inside
|
|
21
|
+
// panes (the top window shadows the AppFrame and has no settings modal).
|
|
22
|
+
//
|
|
23
|
+
// Why iframes: the DSH client runtime is a per-page singleton (module table,
|
|
24
|
+
// cordis tree, renderer BindingContext all single-instance), so N concurrent
|
|
25
|
+
// sessions need N full clients. The server sends no X-Frame-Options/CSP
|
|
26
|
+
// frame-ancestors, the client has no frame-hostile code, and the current
|
|
27
|
+
// session is memory-resident after boot (localStorage is a boot seed only,
|
|
28
|
+
// with no storage-event sync), so same-origin iframe clients stay isolated.
|
|
29
|
+
|
|
30
|
+
// One-time key migration for the dsh-multi-view -> dsh-split-view rename.
|
|
31
|
+
// `dsh-multi:*` was the live namespace before the rename; any `dsh-split:*`
|
|
32
|
+
// values still present predate it, so the migrated values win. Runs at bundle
|
|
33
|
+
// load (top level), before the factory reads any key, and is idempotent: once
|
|
34
|
+
// no `dsh-multi:*` keys remain it does nothing.
|
|
35
|
+
(() => {
|
|
36
|
+
try {
|
|
37
|
+
const moved = [];
|
|
38
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
39
|
+
const key = localStorage.key(i);
|
|
40
|
+
if (key && key.indexOf("dsh-multi:") === 0) moved.push(key);
|
|
41
|
+
}
|
|
42
|
+
for (const key of moved) {
|
|
43
|
+
localStorage.setItem("dsh-split:" + key.slice("dsh-multi:".length), localStorage.getItem(key));
|
|
44
|
+
localStorage.removeItem(key);
|
|
45
|
+
}
|
|
46
|
+
} catch (e) { /* storage unavailable (pre-boot); defaults apply */ }
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
window.__ModuleLoader__.load({
|
|
50
|
+
id: "dsh-split-view",
|
|
51
|
+
factory: (require) => {
|
|
52
|
+
var module = { exports: {} };
|
|
53
|
+
var exports = module.exports;
|
|
54
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
55
|
+
const React = require("react");
|
|
56
|
+
const runtime = require("@deepseek-ai/dsh-client-runtime/client");
|
|
57
|
+
|
|
58
|
+
//#region constants
|
|
59
|
+
const params = new URLSearchParams(window.location.search);
|
|
60
|
+
/** Pane identity: present means this page load is a split pane, not the splitter. */
|
|
61
|
+
const PANE_ID = params.get("dshPane");
|
|
62
|
+
/** Session this pane must show (forced at boot through sessions.open). */
|
|
63
|
+
const SEED_SESSION = params.get("dshSession");
|
|
64
|
+
/** Fresh pane with an inherited workspace: create/connect its workspace session. */
|
|
65
|
+
const INHERIT_WORKSPACE = params.get("dshWorkspace");
|
|
66
|
+
/** Fresh pane with an inherited working directory (no workspace). */
|
|
67
|
+
const INHERIT_CWD = params.get("dshCwd");
|
|
68
|
+
/** Fresh pane: boot into the no-session empty state instead of the persisted seed. */
|
|
69
|
+
const WANT_NEW = params.get("dshNew") === "1";
|
|
70
|
+
/** localStorage key holding the persisted split layout. */
|
|
71
|
+
const LAYOUT_KEY = "dsh-split:layout";
|
|
72
|
+
/** postMessage envelope type shared by pane agents and the splitter. */
|
|
73
|
+
const MSG_TYPE = "dsh-split";
|
|
74
|
+
/** Smallest fraction a divider may leave on either side. */
|
|
75
|
+
const MIN_FRACTION = 0.06;
|
|
76
|
+
/** Veil fallback: show the pane even if its ready message never arrives. */
|
|
77
|
+
const VEIL_TIMEOUT_MS = 4000;
|
|
78
|
+
/** Max concurrently live panes. Each pane is a full DSH client holding 2
|
|
79
|
+
* WebSockets; the browser's per-origin connection budget sustains ~5 live
|
|
80
|
+
* clients, and the 6th boot hangs at "Loading plugins…" until a client is
|
|
81
|
+
* freed (reproduced 100%; freeing one revives it instantly). 127.0.0.1
|
|
82
|
+
* and localhost are DISTINCT origins backed by the same loopback server,
|
|
83
|
+
* and a client on origin B boots fine while origin A is saturated
|
|
84
|
+
* (verified) — so panes alternate origins: 4 on the top window's origin
|
|
85
|
+
* (top itself is a client there) + 5 on the alternate origin = 9 live.
|
|
86
|
+
* Beyond the caps the least-recently-used pane hibernates (iframe
|
|
87
|
+
* unloaded, connections freed) and wakes on click. */
|
|
88
|
+
const MAX_LIVE_PANES_MULTI = 9;
|
|
89
|
+
const MAX_LIVE_PANES_SINGLE = 4;
|
|
90
|
+
/** Per-origin live-pane caps, indexed like the pool: the top window's own
|
|
91
|
+
* origin hosts one extra client (the top window), so it gets one less. */
|
|
92
|
+
const ORIGIN_CAPS_MULTI = [4, 5];
|
|
93
|
+
const ORIGIN_CAPS_SINGLE = [4];
|
|
94
|
+
function originPool() {
|
|
95
|
+
const loc = window.location;
|
|
96
|
+
const port = loc.port ? ":" + loc.port : "";
|
|
97
|
+
if (loc.hostname === "127.0.0.1") return [loc.origin, loc.protocol + "//localhost" + port];
|
|
98
|
+
if (loc.hostname === "localhost") return [loc.origin, loc.protocol + "//127.0.0.1" + port];
|
|
99
|
+
return [loc.origin];
|
|
100
|
+
}
|
|
101
|
+
function pickOrigin(draft, pool, caps) {
|
|
102
|
+
if (pool.length < 2) return 0;
|
|
103
|
+
const counts = new Array(pool.length).fill(0);
|
|
104
|
+
collectLeaves(draft.tree, []).forEach((l) => {
|
|
105
|
+
// Skip the focused leaf — it is the new pane being placed (still
|
|
106
|
+
// without an origin) and must not count against the fill order.
|
|
107
|
+
if (l.id === draft.focused) return;
|
|
108
|
+
counts[typeof l.origin === "number" && l.origin < pool.length ? l.origin : 0]++;
|
|
109
|
+
});
|
|
110
|
+
// Fill the first origin (the top's own) up to its cap before using
|
|
111
|
+
// the alternate one. Same-origin panes share localStorage with the
|
|
112
|
+
// top and skip the cross-origin sync machinery entirely, so the
|
|
113
|
+
// common 2-4 pane case never touches it. Once every origin is at
|
|
114
|
+
// cap, fall back to the least-loaded (the extra pane goes dormant
|
|
115
|
+
// through the LRU live set).
|
|
116
|
+
for (let i = 0; i < pool.length; i++) {
|
|
117
|
+
if (counts[i] < caps[i]) return i;
|
|
118
|
+
}
|
|
119
|
+
let best = 0;
|
|
120
|
+
for (let i = 1; i < pool.length; i++) {
|
|
121
|
+
if (counts[i] < counts[best]) best = i;
|
|
122
|
+
}
|
|
123
|
+
return best;
|
|
124
|
+
}
|
|
125
|
+
/** localStorage keys that must NOT cross the pane↔top boundary: the
|
|
126
|
+
* per-client current session, and the plugin's own keys (they already have
|
|
127
|
+
* dedicated channels). Everything else — dream-skin, trajectory toggles, any
|
|
128
|
+
* third-party plugin's config — syncs generically. */
|
|
129
|
+
const STORAGE_SYNC_DENY = new Set(["dsh.sessions.current"]);
|
|
130
|
+
const STORAGE_SYNC_DENY_PREFIXES = ["dsh-split:", "dsh-multi:"];
|
|
131
|
+
function shouldSyncKey(key) {
|
|
132
|
+
if (!key) return false;
|
|
133
|
+
if (STORAGE_SYNC_DENY.has(key)) return false;
|
|
134
|
+
return !STORAGE_SYNC_DENY_PREFIXES.some((p) => key.startsWith(p));
|
|
135
|
+
}
|
|
136
|
+
function collectSyncedStorage() {
|
|
137
|
+
const values = {};
|
|
138
|
+
try {
|
|
139
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
140
|
+
const key = window.localStorage.key(i);
|
|
141
|
+
if (shouldSyncKey(key)) {
|
|
142
|
+
const v = window.localStorage.getItem(key);
|
|
143
|
+
if (v !== null) values[key] = v;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// storage unavailable — nothing to sync
|
|
148
|
+
}
|
|
149
|
+
return values;
|
|
150
|
+
}
|
|
151
|
+
/** The theme service restores its built-in preference from the host
|
|
152
|
+
* settings document asynchronously; that adoption can land AFTER a
|
|
153
|
+
* skin plugin (dream-skin etc.) restored its custom skin at boot and
|
|
154
|
+
* reset the theme to the default. Re-apply the persisted skin while the
|
|
155
|
+
* boot settles: through the theme service (so other plugins follow
|
|
156
|
+
* theme/change) and directly onto the DOM (the layout presenter can
|
|
157
|
+
* miss boot-time events — its listener registers during its own apply). */
|
|
158
|
+
function reapplyBootSkin(ctx) {
|
|
159
|
+
const applySnapshot = () => {
|
|
160
|
+
try {
|
|
161
|
+
const snapshot = ctx.theme.getTheme();
|
|
162
|
+
const active = snapshot.active;
|
|
163
|
+
if (!active) return;
|
|
164
|
+
const scheme = active.colorScheme;
|
|
165
|
+
document.documentElement.style.colorScheme = scheme;
|
|
166
|
+
const body = document.body;
|
|
167
|
+
if (!body) return;
|
|
168
|
+
if (scheme === "dark") body.setAttribute("data-ds-dark-theme", "");
|
|
169
|
+
else body.removeAttribute("data-ds-dark-theme");
|
|
170
|
+
for (const [name, value] of Object.entries(active.tokens)) body.style.setProperty(name, value);
|
|
171
|
+
} catch {
|
|
172
|
+
// best-effort — never break the boot
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const reconcile = () => {
|
|
176
|
+
try {
|
|
177
|
+
const skinId = window.localStorage.getItem("dsh-dream-skin:skin");
|
|
178
|
+
if (typeof skinId === "string" && skinId.length > 0) {
|
|
179
|
+
if (skinId !== ctx.theme.getTheme().preference) ctx.theme.setTheme(skinId);
|
|
180
|
+
applySnapshot();
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// best-effort
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const deadline = Date.now() + 15000;
|
|
187
|
+
ctx.on("theme/change", () => {
|
|
188
|
+
if (Date.now() > deadline) return;
|
|
189
|
+
reconcile();
|
|
190
|
+
});
|
|
191
|
+
// Deferred passes: events racing ahead of the presenter's listener
|
|
192
|
+
// registration get lost, so reconcile again after the boot settles.
|
|
193
|
+
for (const delay of [2000, 5000, 10000]) setTimeout(reconcile, delay);
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
|
|
197
|
+
const newId = () => "p" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
198
|
+
|
|
199
|
+
/** Panes may live on a different origin than their parent (origin pool),
|
|
200
|
+
* so target the parent's origin derived from the referrer; loopback-only
|
|
201
|
+
* chatter, "*" fallback when the referrer is unavailable. */
|
|
202
|
+
const PARENT_ORIGIN = (() => {
|
|
203
|
+
try {
|
|
204
|
+
if (document.referrer) return new URL(document.referrer).origin;
|
|
205
|
+
} catch {
|
|
206
|
+
// fall through to the wildcard
|
|
207
|
+
}
|
|
208
|
+
return "*";
|
|
209
|
+
})();
|
|
210
|
+
|
|
211
|
+
function postToParent(data) {
|
|
212
|
+
try {
|
|
213
|
+
if (window.parent && window.parent !== window) {
|
|
214
|
+
window.parent.postMessage(Object.assign({ type: MSG_TYPE }, data), PARENT_ORIGIN);
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// pane-to-parent chatter must never break the page
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
//#region layout tree (pure draft mutators for the snapshot store)
|
|
222
|
+
function freshLeaf() {
|
|
223
|
+
return { kind: "leaf", id: newId(), session: null };
|
|
224
|
+
}
|
|
225
|
+
function defaultLayout() {
|
|
226
|
+
const leaf = freshLeaf();
|
|
227
|
+
return { tree: leaf, focused: leaf.id, maximized: null };
|
|
228
|
+
}
|
|
229
|
+
function findNode(node, id) {
|
|
230
|
+
if (node.id === id) return node;
|
|
231
|
+
if (node.kind !== "branch") return null;
|
|
232
|
+
for (const child of node.children) {
|
|
233
|
+
const hit = findNode(child, id);
|
|
234
|
+
if (hit) return hit;
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function findParent(node, id) {
|
|
239
|
+
if (node.kind !== "branch") return null;
|
|
240
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
241
|
+
if (node.children[i].id === id) return { parent: node, index: i };
|
|
242
|
+
const hit = findParent(node.children[i], id);
|
|
243
|
+
if (hit) return hit;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
function firstLeafId(node) {
|
|
248
|
+
if (node.kind === "leaf") return node.id;
|
|
249
|
+
return firstLeafId(node.children[0]);
|
|
250
|
+
}
|
|
251
|
+
function lastLeafId(node) {
|
|
252
|
+
if (node.kind === "leaf") return node.id;
|
|
253
|
+
return lastLeafId(node.children[node.children.length - 1]);
|
|
254
|
+
}
|
|
255
|
+
/** The pane spatially adjacent to a leaf within its own split group — the
|
|
256
|
+
* sibling it was split from / that was split from it. Prefers the NEXT
|
|
257
|
+
* sibling (the one created by splitting this pane), falling back to the
|
|
258
|
+
* previous. When the chosen sibling is itself a nested group, return the
|
|
259
|
+
* leaf nearest the closing pane (first leaf of the next sibling, last
|
|
260
|
+
* leaf of the previous). Returns null for an only child. */
|
|
261
|
+
function neighborLeafId(parent, index) {
|
|
262
|
+
const n = parent.children.length;
|
|
263
|
+
if (index + 1 < n) return firstLeafId(parent.children[index + 1]);
|
|
264
|
+
if (index - 1 >= 0) return lastLeafId(parent.children[index - 1]);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
function replaceNode(draft, id, replacement) {
|
|
268
|
+
const hit = findParent(draft.tree, id);
|
|
269
|
+
if (!hit) return false;
|
|
270
|
+
hit.parent.children[hit.index] = replacement;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
function splitLeaf(draft, leafId, dir, inherit) {
|
|
274
|
+
const leaf = findNode(draft.tree, leafId);
|
|
275
|
+
if (!leaf || leaf.kind !== "leaf") return;
|
|
276
|
+
// The new pane starts a FRESH session but inherits the split pane's
|
|
277
|
+
// workspace (or working directory) — ghostty-style: no manual
|
|
278
|
+
// workspace/mode re-selection, without duplicating the conversation.
|
|
279
|
+
const newLeaf = {
|
|
280
|
+
kind: "leaf",
|
|
281
|
+
id: newId(),
|
|
282
|
+
// A pre-created session (from the public `split()` service) pins the
|
|
283
|
+
// pane straight to it; otherwise the pane creates its own.
|
|
284
|
+
session: inherit && inherit.session ? inherit.session : null,
|
|
285
|
+
...(inherit && inherit.workspace ? { workspace: inherit.workspace } : {}),
|
|
286
|
+
...(inherit && inherit.cwd ? { cwd: inherit.cwd } : {})
|
|
287
|
+
};
|
|
288
|
+
const branch = {
|
|
289
|
+
kind: "branch",
|
|
290
|
+
id: newId(),
|
|
291
|
+
// The layout engine speaks "h"/"v"; normalize caller-friendly names.
|
|
292
|
+
dir: dir === "right" ? "h" : dir === "down" ? "v" : dir,
|
|
293
|
+
fractions: [0.5, 0.5],
|
|
294
|
+
children: [leaf, newLeaf]
|
|
295
|
+
};
|
|
296
|
+
if (draft.tree.id === leafId) draft.tree = branch;
|
|
297
|
+
else if (!replaceNode(draft, leafId, branch)) return;
|
|
298
|
+
draft.focused = branch.children[1].id;
|
|
299
|
+
}
|
|
300
|
+
/** Non-empty string or null — the shared option-field normalizer. */
|
|
301
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : null);
|
|
302
|
+
|
|
303
|
+
/** Normalize the model option: "provider/model" shorthand or
|
|
304
|
+
* {provider, model, reasoningEffort?}. Anything else → null. */
|
|
305
|
+
function normalizeModel(raw) {
|
|
306
|
+
if (raw && typeof raw === "object") {
|
|
307
|
+
const provider = str(raw.provider);
|
|
308
|
+
const modelId = str(raw.model);
|
|
309
|
+
if (provider === null || modelId === null) return null;
|
|
310
|
+
const effort = str(raw.reasoningEffort);
|
|
311
|
+
return { provider, model: modelId, ...(effort !== null ? { reasoningEffort: effort } : {}) };
|
|
312
|
+
}
|
|
313
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
314
|
+
const slash = raw.indexOf("/");
|
|
315
|
+
if (slash > 0) return { provider: raw.slice(0, slash), model: raw.slice(slash + 1) };
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
/** Normalize the public service's session-bearing option payloads
|
|
320
|
+
* (split / newSession) into canonical nullable fields; omitted or
|
|
321
|
+
* unknown options fall back to null (the default). */
|
|
322
|
+
function normalizeSplitOptions(options) {
|
|
323
|
+
const o = options && typeof options === "object" ? options : {};
|
|
324
|
+
const direction = o.direction === "down" ? "down" : "right";
|
|
325
|
+
const workspaceId = str(o.workspaceId);
|
|
326
|
+
const cwd = str(o.cwd);
|
|
327
|
+
const prompt = str(o.prompt);
|
|
328
|
+
const title = str(o.title);
|
|
329
|
+
const mode = str(o.mode);
|
|
330
|
+
const session = str(o.session);
|
|
331
|
+
/** Pane the action anchors to (defaults to the focused pane). */
|
|
332
|
+
const pane = str(o.pane);
|
|
333
|
+
return { direction, workspaceId, cwd, prompt, title, mode, model: normalizeModel(o.model), session, pane };
|
|
334
|
+
}
|
|
335
|
+
function closeLeaf(draft, leafId) {
|
|
336
|
+
if (draft.tree.kind === "leaf") {
|
|
337
|
+
if (draft.tree.id !== leafId) return;
|
|
338
|
+
const leaf = freshLeaf();
|
|
339
|
+
draft.tree = leaf;
|
|
340
|
+
draft.focused = leaf.id;
|
|
341
|
+
draft.maximized = null;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const hit = findParent(draft.tree, leafId);
|
|
345
|
+
if (!hit) return;
|
|
346
|
+
const { parent, index } = hit;
|
|
347
|
+
// Resolve the successor BEFORE splicing: the adjacent sibling in the
|
|
348
|
+
// pane's own split group (the pane it was split from / that was split
|
|
349
|
+
// from it), never the layout's first pane.
|
|
350
|
+
const successor = neighborLeafId(parent, index);
|
|
351
|
+
parent.children.splice(index, 1);
|
|
352
|
+
parent.fractions.splice(index, 1);
|
|
353
|
+
if (parent.children.length === 1) {
|
|
354
|
+
// pull the surviving sibling up into the vacated branch slot
|
|
355
|
+
const only = parent.children[0];
|
|
356
|
+
if (draft.tree.id === parent.id) draft.tree = only;
|
|
357
|
+
else replaceNode(draft, parent.id, only);
|
|
358
|
+
} else {
|
|
359
|
+
const sum = parent.fractions.reduce((a, b) => a + b, 0) || 1;
|
|
360
|
+
parent.fractions = parent.fractions.map((f) => f / sum);
|
|
361
|
+
}
|
|
362
|
+
if (draft.focused === leafId) draft.focused = successor ?? firstLeafId(draft.tree);
|
|
363
|
+
if (draft.maximized === leafId) draft.maximized = null;
|
|
364
|
+
}
|
|
365
|
+
function setLeafSession(draft, leafId, sessionId) {
|
|
366
|
+
const leaf = findNode(draft.tree, leafId);
|
|
367
|
+
if (leaf && leaf.kind === "leaf") leaf.session = sessionId;
|
|
368
|
+
}
|
|
369
|
+
function nudgeFractions(draft, branchId, index, delta) {
|
|
370
|
+
const branch = findNode(draft.tree, branchId);
|
|
371
|
+
if (!branch || branch.kind !== "branch") return;
|
|
372
|
+
if (index < 0 || index + 1 >= branch.fractions.length) return;
|
|
373
|
+
const a = branch.fractions[index];
|
|
374
|
+
const b = branch.fractions[index + 1];
|
|
375
|
+
const clamped = Math.max(MIN_FRACTION - a, Math.min(delta, b - MIN_FRACTION));
|
|
376
|
+
branch.fractions[index] = a + clamped;
|
|
377
|
+
branch.fractions[index + 1] = b - clamped;
|
|
378
|
+
}
|
|
379
|
+
/** Validate + normalize an externally supplied layout tree (the public
|
|
380
|
+
* applyLayout escape hatch): leaves and branches only, unique ids
|
|
381
|
+
* (regenerated when absent or colliding), branch dir folded to h/v,
|
|
382
|
+
* fractions positive and renormalized (even split when missing or
|
|
383
|
+
* malformed). Throws a dsh-split-view error on any malformed node. */
|
|
384
|
+
function normalizeLayoutTree(raw) {
|
|
385
|
+
const seen = new Set();
|
|
386
|
+
const takeId = (node) => {
|
|
387
|
+
let id = typeof node.id === "string" && node.id.length > 0 ? node.id : newId();
|
|
388
|
+
if (seen.has(id)) id = newId();
|
|
389
|
+
seen.add(id);
|
|
390
|
+
return id;
|
|
391
|
+
};
|
|
392
|
+
const norm = (node) => {
|
|
393
|
+
if (!node || typeof node !== "object") throw new Error("dsh-split-view: layout nodes must be objects");
|
|
394
|
+
if (node.kind === "leaf" || node.kind === "pane") {
|
|
395
|
+
const leaf = { kind: "leaf", id: takeId(node), session: str(node.session) };
|
|
396
|
+
if (typeof node.origin === "number" && isFinite(node.origin) && node.origin >= 0) leaf.origin = Math.floor(node.origin);
|
|
397
|
+
return leaf;
|
|
398
|
+
}
|
|
399
|
+
if (node.kind === "branch" || node.kind === "split" || node.kind === "group") {
|
|
400
|
+
if (!Array.isArray(node.children) || node.children.length < 2) throw new Error("dsh-split-view: a branch needs at least 2 children");
|
|
401
|
+
const children = node.children.map(norm);
|
|
402
|
+
const dir = node.dir === "v" || node.dir === "down" || node.dir === "up" ? "v" : "h";
|
|
403
|
+
let fractions = Array.isArray(node.fractions) && node.fractions.length === children.length && node.fractions.every((f) => typeof f === "number" && isFinite(f) && f > 0)
|
|
404
|
+
? node.fractions.slice()
|
|
405
|
+
: children.map(() => 1 / children.length);
|
|
406
|
+
const sum = fractions.reduce((a, b) => a + b, 0) || 1;
|
|
407
|
+
fractions = fractions.map((f) => f / sum);
|
|
408
|
+
return { kind: "branch", id: takeId(node), dir, fractions, children };
|
|
409
|
+
}
|
|
410
|
+
throw new Error("dsh-split-view: unknown layout node kind: " + String(node.kind));
|
|
411
|
+
};
|
|
412
|
+
return norm(raw);
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
|
|
416
|
+
//#region preferences (shortcuts, focus color, title bars)
|
|
417
|
+
// User preferences. The TOP WINDOW is the source of truth — it draws the
|
|
418
|
+
// title bars, the focus highlight and the splitter the shortcuts drive —
|
|
419
|
+
// and persists them in its own localStorage (PREFS_KEY). The settings UI
|
|
420
|
+
// only ever opens inside a pane (the top shadows the AppFrame, so its
|
|
421
|
+
// settings modal is unreachable), so panes hold a mirror synced over
|
|
422
|
+
// postMessage: prefs-pull at boot, prefs-push broadcast on every change,
|
|
423
|
+
// prefs-set carries edits back to the top.
|
|
424
|
+
//
|
|
425
|
+
// Shortcut defaults are the browser-safe mnemonic bindings picked in
|
|
426
|
+
// earlier rounds: ⌘/Ctrl alone collides with browser chrome (⌘W close
|
|
427
|
+
// tab, ⌘D bookmark, ⌘V paste…); ⌘⇧Q locked the browser and ⌘⇧W is
|
|
428
|
+
// taken by Feishu's global hotkey — X reads as ✕. Mod+Shift+letter is
|
|
429
|
+
// the space a page can actually capture. ⌘⇧Enter keeps maximize/restore.
|
|
430
|
+
/** localStorage key holding user preferences (persisted by the top window). */
|
|
431
|
+
const PREFS_KEY = "dsh-split:prefs";
|
|
432
|
+
/** Locale namespace of the settings section and its rows. */
|
|
433
|
+
const SETTINGS_NS = "settings.splitView";
|
|
434
|
+
const ACTION_IDS = ["split-right", "split-down", "close", "maximize"];
|
|
435
|
+
const DEFAULT_SHORTCUTS = Object.freeze({
|
|
436
|
+
"split-right": "Mod+Shift+H",
|
|
437
|
+
"split-down": "Mod+Shift+V",
|
|
438
|
+
"close": "Mod+Shift+X",
|
|
439
|
+
"maximize": "Mod+Shift+Enter"
|
|
440
|
+
});
|
|
441
|
+
const DEFAULT_PREFS = Object.freeze({
|
|
442
|
+
shortcuts: DEFAULT_SHORTCUTS,
|
|
443
|
+
/** Hex color (always stored lowercase), or "theme" to follow the
|
|
444
|
+
* current skin's brand color. */
|
|
445
|
+
focusColor: "#4d6bfe",
|
|
446
|
+
showTitleBar: true
|
|
447
|
+
});
|
|
448
|
+
const FOCUS_COLOR_PRESETS = ["#4D6BFE", "#4098ff", "#2dd4bf", "#34d399", "#a78bfa", "#e879f9", "#fb923c", "#f87171"];
|
|
449
|
+
const IS_MAC = typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/.test(((typeof navigator.platform === "string" ? navigator.platform : "") + " " + navigator.userAgent));
|
|
450
|
+
/** Key values that never end a combo — pressing them means "still chording". */
|
|
451
|
+
const MODIFIER_KEYS = new Set(["meta", "control", "shift", "alt", "capslock", "fn", "fnlock", "numlock", "scrolllock"]);
|
|
452
|
+
|
|
453
|
+
function normEventKey(event) {
|
|
454
|
+
if (typeof event.key !== "string" || event.key.length === 0) return null;
|
|
455
|
+
if (event.key === " ") return "space";
|
|
456
|
+
return event.key.toLowerCase();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Parse a serialized combo ("Mod+Shift+H"). Mod (matched as ⌘ OR Ctrl on
|
|
460
|
+
* every platform — the same unification the original fixed map used) is
|
|
461
|
+
* mandatory; returns {shift, alt, key} or null. */
|
|
462
|
+
function parseCombo(text) {
|
|
463
|
+
if (typeof text !== "string") return null;
|
|
464
|
+
const parts = text.split("+").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
465
|
+
let mod = false;
|
|
466
|
+
const parsed = { shift: false, alt: false, key: null };
|
|
467
|
+
for (const part of parts) {
|
|
468
|
+
const token = part.toLowerCase();
|
|
469
|
+
if (token === "mod" || token === "cmd" || token === "ctrl" || token === "cmdorctrl") mod = true;
|
|
470
|
+
else if (token === "shift") parsed.shift = true;
|
|
471
|
+
else if (token === "alt" || token === "option") parsed.alt = true;
|
|
472
|
+
else if (parsed.key === null && !MODIFIER_KEYS.has(token)) parsed.key = token;
|
|
473
|
+
else return null;
|
|
474
|
+
}
|
|
475
|
+
return mod && parsed.key !== null ? parsed : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function comboToText(parsed) {
|
|
479
|
+
const parts = ["Mod"];
|
|
480
|
+
if (parsed.shift) parts.push("Shift");
|
|
481
|
+
if (parsed.alt) parts.push("Alt");
|
|
482
|
+
const key = parsed.key;
|
|
483
|
+
parts.push(key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1));
|
|
484
|
+
return parts.join("+");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function combosEqual(a, b) {
|
|
488
|
+
return a !== null && b !== null && a.shift === b.shift && a.alt === b.alt && a.key === b.key;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Which split command a key event carries under the given shortcut map
|
|
492
|
+
* (null when it matches no binding). */
|
|
493
|
+
function commandOfEvent(event, shortcuts) {
|
|
494
|
+
if (!(event.metaKey || event.ctrlKey)) return null;
|
|
495
|
+
const key = normEventKey(event);
|
|
496
|
+
if (key === null || MODIFIER_KEYS.has(key)) return null;
|
|
497
|
+
const map = shortcuts || DEFAULT_SHORTCUTS;
|
|
498
|
+
for (const cmd of ACTION_IDS) {
|
|
499
|
+
const combo = parseCombo(map[cmd]);
|
|
500
|
+
if (combo && combo.shift === event.shiftKey && combo.alt === event.altKey && combo.key === key) return cmd;
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Platform-aware label for tooltips and the settings row (⌘⇧H / Ctrl+Shift+H). */
|
|
506
|
+
function comboLabel(text) {
|
|
507
|
+
const parsed = parseCombo(text);
|
|
508
|
+
if (!parsed) return "—";
|
|
509
|
+
const key = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
|
|
510
|
+
if (IS_MAC) return "⌘" + (parsed.shift ? "⇧" : "") + (parsed.alt ? "⌥" : "") + key;
|
|
511
|
+
return "Ctrl+" + (parsed.shift ? "Shift+" : "") + (parsed.alt ? "Alt+" : "") + key;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Recording-time validation. Returns a locale error key (resolved under
|
|
515
|
+
* "shortcuts.*" by the caller) or null when the combo is safe to bind. */
|
|
516
|
+
function validateRecorded(parsed, map, actionId) {
|
|
517
|
+
// Bare ⌘/Ctrl+letter is browser-reserved territory (⌘W closes the
|
|
518
|
+
// tab, ⌘D bookmarks…) — the page never sees those events.
|
|
519
|
+
if (parsed.key.length === 1 && !parsed.shift && !parsed.alt) return "err.letter";
|
|
520
|
+
// Combinations with a painful history or a browser/system reservation
|
|
521
|
+
// (⌘⇧Q locked the browser, ⌘⇧W closes all windows / is Feishu's
|
|
522
|
+
// global hotkey, ⌘⇧T reopens tabs, ⌘⇧N opens private windows).
|
|
523
|
+
if (parsed.shift && !parsed.alt && (parsed.key === "w" || parsed.key === "q" || parsed.key === "t" || parsed.key === "n")) return "err.danger";
|
|
524
|
+
for (const other of ACTION_IDS) {
|
|
525
|
+
if (other === actionId) continue;
|
|
526
|
+
if (combosEqual(parseCombo(map[other]), parsed)) return "err.dup";
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Merge anything storage (or a message) produced back into a guaranteed
|
|
532
|
+
* full, internally consistent prefs object. */
|
|
533
|
+
function normalizePrefs(raw) {
|
|
534
|
+
const source = raw && typeof raw === "object" ? raw : {};
|
|
535
|
+
const rawShortcuts = source.shortcuts && typeof source.shortcuts === "object" ? source.shortcuts : {};
|
|
536
|
+
const shortcuts = {};
|
|
537
|
+
const claimed = [];
|
|
538
|
+
for (const cmd of ACTION_IDS) {
|
|
539
|
+
let parsed = parseCombo(rawShortcuts[cmd]);
|
|
540
|
+
if (!parsed || claimed.some((c) => combosEqual(c, parsed))) parsed = parseCombo(DEFAULT_SHORTCUTS[cmd]);
|
|
541
|
+
shortcuts[cmd] = comboToText(parsed);
|
|
542
|
+
claimed.push(parsed);
|
|
543
|
+
}
|
|
544
|
+
let focusColor = DEFAULT_PREFS.focusColor;
|
|
545
|
+
if (source.focusColor === "theme") focusColor = "theme";
|
|
546
|
+
else if (typeof source.focusColor === "string" && /^#[0-9a-fA-F]{6}$/.test(source.focusColor)) focusColor = source.focusColor.toLowerCase();
|
|
547
|
+
return {
|
|
548
|
+
shortcuts,
|
|
549
|
+
focusColor,
|
|
550
|
+
showTitleBar: source.showTitleBar === undefined ? DEFAULT_PREFS.showTitleBar : !!source.showTitleBar
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Apply a partial settings edit. Returns the merged prefs or null when
|
|
555
|
+
* nothing in the patch was valid — the top window is authoritative, so
|
|
556
|
+
* an all-invalid patch silently keeps the current state. */
|
|
557
|
+
function patchPrefs(current, patch) {
|
|
558
|
+
if (!patch || typeof patch !== "object") return null;
|
|
559
|
+
const next = {
|
|
560
|
+
shortcuts: Object.assign({}, current.shortcuts),
|
|
561
|
+
focusColor: current.focusColor,
|
|
562
|
+
showTitleBar: current.showTitleBar
|
|
563
|
+
};
|
|
564
|
+
let changed = false;
|
|
565
|
+
if (patch.shortcuts && typeof patch.shortcuts === "object") {
|
|
566
|
+
for (const cmd of ACTION_IDS) {
|
|
567
|
+
const text = patch.shortcuts[cmd];
|
|
568
|
+
if (text === undefined) continue;
|
|
569
|
+
const parsed = parseCombo(text);
|
|
570
|
+
if (!parsed) continue;
|
|
571
|
+
const collides = ACTION_IDS.some((other) => other !== cmd && combosEqual(parseCombo(next.shortcuts[other]), parsed));
|
|
572
|
+
if (collides) continue;
|
|
573
|
+
const nextText = comboToText(parsed);
|
|
574
|
+
if (next.shortcuts[cmd] !== nextText) {
|
|
575
|
+
next.shortcuts[cmd] = nextText;
|
|
576
|
+
changed = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (patch.focusColor !== undefined) {
|
|
581
|
+
const value = patch.focusColor;
|
|
582
|
+
const valid = value === "theme" || (typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value));
|
|
583
|
+
if (valid) {
|
|
584
|
+
const norm = value === "theme" ? "theme" : value.toLowerCase();
|
|
585
|
+
if (norm !== next.focusColor) {
|
|
586
|
+
next.focusColor = norm;
|
|
587
|
+
changed = true;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (patch.showTitleBar !== undefined) {
|
|
592
|
+
const on = !!patch.showTitleBar;
|
|
593
|
+
if (on !== next.showTitleBar) {
|
|
594
|
+
next.showTitleBar = on;
|
|
595
|
+
changed = true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return changed ? next : null;
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
|
|
602
|
+
//#region settings locale
|
|
603
|
+
const zh = {
|
|
604
|
+
"nav": "分屏",
|
|
605
|
+
"shortcuts.title": "快捷键",
|
|
606
|
+
"shortcuts.split-right": "向右分屏",
|
|
607
|
+
"shortcuts.split-down": "向下分屏",
|
|
608
|
+
"shortcuts.close": "关闭面板",
|
|
609
|
+
"shortcuts.maximize": "最大化 / 还原",
|
|
610
|
+
"shortcuts.record": "按新的组合键…",
|
|
611
|
+
"shortcuts.recordHint": "按 Esc 取消",
|
|
612
|
+
"shortcuts.reset": "恢复默认",
|
|
613
|
+
"shortcuts.hint": "组合键需包含 ⌘/Ctrl;单个字母或数字需搭配 Shift 或 Alt,否则会被浏览器保留。个别组合可能仍被浏览器或系统全局热键占用,遇到无效换一个即可。",
|
|
614
|
+
"shortcuts.err.noMod": "组合键需包含 ⌘ 或 Ctrl",
|
|
615
|
+
"shortcuts.err.letter": "字母/数字键需搭配 Shift 或 Alt,否则会被浏览器保留",
|
|
616
|
+
"shortcuts.err.danger": "该组合被浏览器或系统保留(例如会关掉所有窗口),请换一个",
|
|
617
|
+
"shortcuts.err.dup": "该组合已被其他动作使用",
|
|
618
|
+
"color.title": "选中高亮色",
|
|
619
|
+
"color.theme": "跟随主题",
|
|
620
|
+
"color.pick": "自定义…",
|
|
621
|
+
"color.hint": "聚焦面板的描边和标题栏染色。「跟随主题」随当前皮肤的品牌色变化,换皮肤后自动跟着变。",
|
|
622
|
+
"titlebar.title": "标题栏",
|
|
623
|
+
"titlebar.show": "显示面板标题栏",
|
|
624
|
+
"titlebar.hint": "标题栏承载会话标题(点击标题文字可重命名;新会话显示文件夹名、不可重命名)、复制会话信息按钮和分屏、最大化、刷新、关闭五个按钮,双击标题栏空白处可放大/还原;隐藏后请用快捷键操作,点面板任意位置仍可聚焦。",
|
|
625
|
+
"reset.title": "恢复默认",
|
|
626
|
+
"reset.button": "恢复默认设置",
|
|
627
|
+
"reset.hint": "把快捷键、选中高亮色、标题栏开关全部还原为默认值。",
|
|
628
|
+
"reset.confirm": "确定把分屏设置全部恢复为默认值吗?"
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
632
|
+
const en = {
|
|
633
|
+
"nav": "Split View",
|
|
634
|
+
"shortcuts.title": "Keyboard Shortcuts",
|
|
635
|
+
"shortcuts.split-right": "Split right",
|
|
636
|
+
"shortcuts.split-down": "Split down",
|
|
637
|
+
"shortcuts.close": "Close pane",
|
|
638
|
+
"shortcuts.maximize": "Maximize / restore",
|
|
639
|
+
"shortcuts.record": "Press a new combo…",
|
|
640
|
+
"shortcuts.recordHint": "Esc to cancel",
|
|
641
|
+
"shortcuts.reset": "Reset to defaults",
|
|
642
|
+
"shortcuts.hint": "Combos need ⌘/Ctrl; a bare letter or digit also needs Shift or Alt, otherwise the browser keeps it. A few combos may still be taken by the browser or global hotkeys — pick another if one does nothing.",
|
|
643
|
+
"shortcuts.err.noMod": "The combo needs ⌘ or Ctrl",
|
|
644
|
+
"shortcuts.err.letter": "Letters/digits need Shift or Alt, otherwise the browser keeps them",
|
|
645
|
+
"shortcuts.err.danger": "That combo is reserved by the browser or the OS (it would e.g. close all windows) — pick another",
|
|
646
|
+
"shortcuts.err.dup": "That combo is already bound to another action",
|
|
647
|
+
"color.title": "Focus Highlight Color",
|
|
648
|
+
"color.theme": "Follow theme",
|
|
649
|
+
"color.pick": "Custom…",
|
|
650
|
+
"color.hint": "The outline of the focused pane and its title-bar tint. “Follow theme” tracks the current skin's brand color.",
|
|
651
|
+
"titlebar.title": "Title Bar",
|
|
652
|
+
"titlebar.show": "Show pane title bars",
|
|
653
|
+
"titlebar.hint": "The title bar carries the session title (click the title text to rename; blank new sessions show the folder name and cannot be renamed), a copy-session-info button and the split / maximize / reload / close buttons. Double-click the title bar to maximize/restore. With it hidden, use the keyboard shortcuts instead; clicking a pane still focuses it.",
|
|
654
|
+
"reset.title": "Restore Defaults",
|
|
655
|
+
"reset.button": "Restore default settings",
|
|
656
|
+
"reset.hint": "Reset shortcuts, focus highlight color and the title-bar switch back to their defaults.",
|
|
657
|
+
"reset.confirm": "Restore all split-view settings to their defaults?"
|
|
658
|
+
};
|
|
659
|
+
//#endregion
|
|
660
|
+
|
|
661
|
+
//#region pane agent (runs inside ?dshPane loads)
|
|
662
|
+
function applyPane(ctx) {
|
|
663
|
+
// Prefs mirror of the top window (the source of truth — see the
|
|
664
|
+
// preferences region header). Same-origin panes share the top's
|
|
665
|
+
// localStorage and can seed directly; cross-origin panes stay on
|
|
666
|
+
// defaults until the prefs-pull below is answered.
|
|
667
|
+
const prefs = runtime.createSnapshotStore(normalizePrefs({}), {});
|
|
668
|
+
try {
|
|
669
|
+
const raw = window.localStorage.getItem(PREFS_KEY);
|
|
670
|
+
if (raw !== null) prefs.set(normalizePrefs(JSON.parse(raw)));
|
|
671
|
+
} catch {
|
|
672
|
+
// storage unavailable or corrupt — defaults apply
|
|
673
|
+
}
|
|
674
|
+
const prefsChannel = {
|
|
675
|
+
getPrefs: () => prefs.getSnapshot(),
|
|
676
|
+
subscribe: (fn) => prefs.subscribe(fn),
|
|
677
|
+
savePrefs: (patch) => {
|
|
678
|
+
// Optimistic local echo keeps the row snappy; the top window
|
|
679
|
+
// stays authoritative and broadcasts the canonical state back.
|
|
680
|
+
const next = patchPrefs(prefs.getSnapshot(), patch);
|
|
681
|
+
if (next) prefs.set(next);
|
|
682
|
+
postToParent({ kind: "prefs-set", pane: PANE_ID, patch });
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
applySettings(ctx, prefsChannel);
|
|
686
|
+
ctx.effect(() => {
|
|
687
|
+
const sessions = ctx.sessions;
|
|
688
|
+
// Session seeding is gated on the list baseline: SessionManager.select
|
|
689
|
+
// validates the id against the listed summaries and throws "unknown
|
|
690
|
+
// session" while the session.list RPC is still in flight (observed in
|
|
691
|
+
// the wild). Try once the list resolves; a still-unknown id then means
|
|
692
|
+
// the session is genuinely gone (deleted/archived elsewhere) — give up
|
|
693
|
+
// gracefully and let the pane fall back to adoption/empty state.
|
|
694
|
+
let seeded = false;
|
|
695
|
+
const seedInherited = () => {
|
|
696
|
+
const opts = INHERIT_WORKSPACE ? { workspaceId: INHERIT_WORKSPACE } : { cwd: INHERIT_CWD };
|
|
697
|
+
const fallback = () => {
|
|
698
|
+
try {
|
|
699
|
+
sessions.clear();
|
|
700
|
+
} catch {
|
|
701
|
+
// empty-state fallback is best-effort
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
try {
|
|
705
|
+
Promise.resolve(sessions.create(opts)).then((result) => {
|
|
706
|
+
let target = result && result.ok && result.value ? result.value.sessionId : void 0;
|
|
707
|
+
if (target === void 0 && INHERIT_WORKSPACE) {
|
|
708
|
+
// create may report "workspace already attached" and the
|
|
709
|
+
// runtime upserts that workspace's blank session — find it.
|
|
710
|
+
const listSnap = sessions.list.getSnapshot();
|
|
711
|
+
const blank = listSnap.ids.map((id) => listSnap.byId[id]).find((s) => s && s.blank);
|
|
712
|
+
if (blank) target = blank.id;
|
|
713
|
+
}
|
|
714
|
+
if (target !== void 0) {
|
|
715
|
+
try {
|
|
716
|
+
sessions.open(target);
|
|
717
|
+
return;
|
|
718
|
+
} catch {
|
|
719
|
+
// fall through to the empty state
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
fallback();
|
|
723
|
+
}).catch(fallback);
|
|
724
|
+
} catch {
|
|
725
|
+
fallback();
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
const trySeed = () => {
|
|
729
|
+
if (seeded) return;
|
|
730
|
+
if (sessions.list.getSnapshot().phase === "pending") return;
|
|
731
|
+
seeded = true;
|
|
732
|
+
if (SEED_SESSION) {
|
|
733
|
+
try {
|
|
734
|
+
sessions.open(SEED_SESSION);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
console.warn("dsh-split-view: pane session seed unavailable, pane falls back", error);
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (INHERIT_WORKSPACE || INHERIT_CWD) {
|
|
741
|
+
seedInherited();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (WANT_NEW) {
|
|
745
|
+
try {
|
|
746
|
+
sessions.clear();
|
|
747
|
+
} catch {
|
|
748
|
+
// empty-state fallback is best-effort
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
trySeed();
|
|
753
|
+
const offSeed = seeded ? () => {} : sessions.list.subscribe(trySeed);
|
|
754
|
+
const report = () => {
|
|
755
|
+
const listSnap = sessions.list.getSnapshot();
|
|
756
|
+
const current = listSnap.current;
|
|
757
|
+
const summary = current === null || current === void 0 ? void 0 : listSnap.byId[current];
|
|
758
|
+
postToParent({
|
|
759
|
+
kind: "session",
|
|
760
|
+
pane: PANE_ID,
|
|
761
|
+
sessionId: current ?? null,
|
|
762
|
+
title: summary ? (summary.displayTitle ?? summary.title ?? null) : null,
|
|
763
|
+
// Blank sessions (empty log) display the workspace folder name
|
|
764
|
+
// as their title and must NOT be renamable from the title bar.
|
|
765
|
+
blank: summary ? !!summary.blank : true,
|
|
766
|
+
// Durable title (absent while the display title is the derived
|
|
767
|
+
// folder-name fallback) — seeds the rename editor.
|
|
768
|
+
realTitle: summary && typeof summary.title === "string" ? summary.title : null
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
report();
|
|
772
|
+
const offList = sessions.list.subscribe(report);
|
|
773
|
+
const onActivity = () => postToParent({ kind: "focus", pane: PANE_ID });
|
|
774
|
+
const onKey = (event) => {
|
|
775
|
+
const cmd = commandOfEvent(event, prefs.getSnapshot().shortcuts);
|
|
776
|
+
if (cmd === null) return;
|
|
777
|
+
event.preventDefault();
|
|
778
|
+
event.stopPropagation();
|
|
779
|
+
postToParent({ kind: "cmd", cmd, pane: PANE_ID });
|
|
780
|
+
};
|
|
781
|
+
document.addEventListener("mousedown", onActivity, true);
|
|
782
|
+
window.addEventListener("focus", onActivity);
|
|
783
|
+
document.addEventListener("keydown", onKey, true);
|
|
784
|
+
return () => {
|
|
785
|
+
offSeed();
|
|
786
|
+
offList();
|
|
787
|
+
document.removeEventListener("mousedown", onActivity, true);
|
|
788
|
+
window.removeEventListener("focus", onActivity);
|
|
789
|
+
document.removeEventListener("keydown", onKey, true);
|
|
790
|
+
};
|
|
791
|
+
}, "split-view: pane agent");
|
|
792
|
+
reapplyBootSkin(ctx);
|
|
793
|
+
ctx.effect(() => {
|
|
794
|
+
// Cross-origin storage sync: a pane on the alternate loopback origin
|
|
795
|
+
// has its own storage-partitioned localStorage, so client-side
|
|
796
|
+
// preferences (dream-skin skin/wallpaper, trajectory toggles, any
|
|
797
|
+
// plugin's config) do not carry over. Pull the top's snapshot at
|
|
798
|
+
// boot, then forward every later localStorage write back to the top
|
|
799
|
+
// so it stays the source of truth. Same-origin panes share storage
|
|
800
|
+
// and skip this entirely.
|
|
801
|
+
let parentOrigin = null;
|
|
802
|
+
try {
|
|
803
|
+
if (document.referrer) parentOrigin = new URL(document.referrer).origin;
|
|
804
|
+
} catch {
|
|
805
|
+
// unparseable referrer — nothing to sync against
|
|
806
|
+
}
|
|
807
|
+
if (parentOrigin === null || parentOrigin === window.location.origin) return;
|
|
808
|
+
// True once the boot pull has landed. Writes before that are boot
|
|
809
|
+
// restores, not user intent, and must not overwrite the top.
|
|
810
|
+
let synced = false;
|
|
811
|
+
// Retry the boot pull a few times: if the first pull is lost (the top
|
|
812
|
+
// side booting slow or a transient message drop), synced would stay
|
|
813
|
+
// false forever and every later write would silently never forward.
|
|
814
|
+
let pullTries = 0;
|
|
815
|
+
let pullTimer = null;
|
|
816
|
+
const sendPull = () => {
|
|
817
|
+
if (synced || pullTries >= 5) return;
|
|
818
|
+
pullTries += 1;
|
|
819
|
+
postToParent({ kind: "storage-pull", pane: PANE_ID });
|
|
820
|
+
pullTimer = setTimeout(sendPull, 2000);
|
|
821
|
+
};
|
|
822
|
+
const onStoragePush = (event) => {
|
|
823
|
+
if (event.origin !== parentOrigin) return;
|
|
824
|
+
const data = event.data;
|
|
825
|
+
if (!data || data.type !== MSG_TYPE || data.kind !== "storage-push" || !data.values) return;
|
|
826
|
+
window.removeEventListener("message", onStoragePush);
|
|
827
|
+
try {
|
|
828
|
+
for (const key of Object.keys(data.values)) {
|
|
829
|
+
if (!shouldSyncKey(key)) continue;
|
|
830
|
+
try {
|
|
831
|
+
window.localStorage.setItem(key, data.values[key]);
|
|
832
|
+
} catch {
|
|
833
|
+
// quota — skip this key
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Drop synced keys the top no longer has.
|
|
837
|
+
const drop = [];
|
|
838
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
839
|
+
const key = window.localStorage.key(i);
|
|
840
|
+
if (shouldSyncKey(key) && !(key in data.values)) drop.push(key);
|
|
841
|
+
}
|
|
842
|
+
for (const key of drop) {
|
|
843
|
+
try {
|
|
844
|
+
window.localStorage.removeItem(key);
|
|
845
|
+
} catch {
|
|
846
|
+
// best-effort
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// storage unavailable — the values cannot apply
|
|
851
|
+
}
|
|
852
|
+
// Re-apply the theme preference so theme-listening plugins
|
|
853
|
+
// (dream-skin etc.) pick up the freshly written keys.
|
|
854
|
+
try {
|
|
855
|
+
const skinId = data.values["dsh-dream-skin:skin"];
|
|
856
|
+
if (typeof skinId === "string" && skinId.length > 0 && skinId !== ctx.theme.getTheme().preference) ctx.theme.setTheme(skinId);
|
|
857
|
+
const dispose = ctx.theme.overrideTokens("dsh-split-view:storage-sync", {});
|
|
858
|
+
dispose();
|
|
859
|
+
} catch {
|
|
860
|
+
// theme re-apply is best-effort
|
|
861
|
+
}
|
|
862
|
+
if (pullTimer !== null) clearTimeout(pullTimer);
|
|
863
|
+
synced = true;
|
|
864
|
+
};
|
|
865
|
+
window.addEventListener("message", onStoragePush);
|
|
866
|
+
sendPull();
|
|
867
|
+
// Intercept this pane's own localStorage writes and forward them to
|
|
868
|
+
// the top (which mirrors them and serves them back on the next
|
|
869
|
+
// reload). Only window.localStorage writes cross the boundary.
|
|
870
|
+
const forwardWrite = (key, value) => {
|
|
871
|
+
if (!synced || !shouldSyncKey(key)) return;
|
|
872
|
+
postToParent({ kind: "storage-set", pane: PANE_ID, key, value });
|
|
873
|
+
};
|
|
874
|
+
const origSet = Storage.prototype.setItem;
|
|
875
|
+
const origRemove = Storage.prototype.removeItem;
|
|
876
|
+
const origClear = Storage.prototype.clear;
|
|
877
|
+
const patchedSet = function (key, value) {
|
|
878
|
+
origSet.call(this, key, value);
|
|
879
|
+
if (this === window.localStorage) forwardWrite(key, String(value));
|
|
880
|
+
};
|
|
881
|
+
const patchedRemove = function (key) {
|
|
882
|
+
origRemove.call(this, key);
|
|
883
|
+
if (this === window.localStorage) forwardWrite(key, null);
|
|
884
|
+
};
|
|
885
|
+
const patchedClear = function () {
|
|
886
|
+
const keys = [];
|
|
887
|
+
for (let i = 0; i < this.length; i++) {
|
|
888
|
+
const k = this.key(i);
|
|
889
|
+
if (shouldSyncKey(k)) keys.push(k);
|
|
890
|
+
}
|
|
891
|
+
origClear.call(this);
|
|
892
|
+
if (this === window.localStorage) {
|
|
893
|
+
for (const k of keys) forwardWrite(k, null);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
Storage.prototype.setItem = patchedSet;
|
|
897
|
+
Storage.prototype.removeItem = patchedRemove;
|
|
898
|
+
Storage.prototype.clear = patchedClear;
|
|
899
|
+
return () => {
|
|
900
|
+
if (pullTimer !== null) clearTimeout(pullTimer);
|
|
901
|
+
window.removeEventListener("message", onStoragePush);
|
|
902
|
+
if (Storage.prototype.setItem === patchedSet) Storage.prototype.setItem = origSet;
|
|
903
|
+
if (Storage.prototype.removeItem === patchedRemove) Storage.prototype.removeItem = origRemove;
|
|
904
|
+
if (Storage.prototype.clear === patchedClear) Storage.prototype.clear = origClear;
|
|
905
|
+
};
|
|
906
|
+
}, "split-view: cross-origin storage sync");
|
|
907
|
+
ctx.effect(() => {
|
|
908
|
+
// Prefs sync with the top window: pull once at boot, then follow
|
|
909
|
+
// prefs-push broadcasts (every top-side change, including edits
|
|
910
|
+
// made in another pane's settings modal). Works for same- and
|
|
911
|
+
// cross-origin panes alike — the channel is postMessage, not
|
|
912
|
+
// storage events.
|
|
913
|
+
let parentOrigin = null;
|
|
914
|
+
try {
|
|
915
|
+
if (document.referrer) parentOrigin = new URL(document.referrer).origin;
|
|
916
|
+
} catch {
|
|
917
|
+
// unparseable referrer — accept any origin (loopback-only server)
|
|
918
|
+
}
|
|
919
|
+
const onPrefs = (event) => {
|
|
920
|
+
if (parentOrigin !== null && event.origin !== parentOrigin) return;
|
|
921
|
+
const data = event.data;
|
|
922
|
+
if (!data || data.type !== MSG_TYPE || data.kind !== "prefs-push" || !data.prefs) return;
|
|
923
|
+
prefs.set(normalizePrefs(data.prefs));
|
|
924
|
+
};
|
|
925
|
+
window.addEventListener("message", onPrefs);
|
|
926
|
+
postToParent({ kind: "prefs-pull", pane: PANE_ID });
|
|
927
|
+
return () => window.removeEventListener("message", onPrefs);
|
|
928
|
+
}, "split-view: prefs sync");
|
|
929
|
+
ctx.effect(() => {
|
|
930
|
+
// Top→pane control channel (public service `open` / `reload`):
|
|
931
|
+
// swap the shown session or reload the pane live, without
|
|
932
|
+
// touching the boot URL. Origin-gated like the other top
|
|
933
|
+
// broadcasts (referrer-derived parent origin).
|
|
934
|
+
let parentOrigin = null;
|
|
935
|
+
try {
|
|
936
|
+
if (document.referrer) parentOrigin = new URL(document.referrer).origin;
|
|
937
|
+
} catch {
|
|
938
|
+
// unparseable referrer — accept any origin (loopback-only server)
|
|
939
|
+
}
|
|
940
|
+
const onControl = (event) => {
|
|
941
|
+
if (parentOrigin !== null && event.origin !== parentOrigin) return;
|
|
942
|
+
const data = event.data;
|
|
943
|
+
if (!data || data.type !== MSG_TYPE) return;
|
|
944
|
+
if (data.kind === "open-session" && typeof data.sessionId === "string") {
|
|
945
|
+
const target = data.sessionId;
|
|
946
|
+
const openNow = () => {
|
|
947
|
+
try {
|
|
948
|
+
ctx.sessions.open(target);
|
|
949
|
+
return true;
|
|
950
|
+
} catch {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
if (openNow()) return;
|
|
955
|
+
// select validates against the list baseline; while the
|
|
956
|
+
// list RPC is in flight every id fails, so retry once it
|
|
957
|
+
// resolves (same gate as the boot seeding).
|
|
958
|
+
if (ctx.sessions.list.getSnapshot().phase !== "pending") return;
|
|
959
|
+
const off = ctx.sessions.list.subscribe(() => {
|
|
960
|
+
if (ctx.sessions.list.getSnapshot().phase === "pending") return;
|
|
961
|
+
off();
|
|
962
|
+
try {
|
|
963
|
+
ctx.sessions.open(target);
|
|
964
|
+
} catch {
|
|
965
|
+
// genuinely gone (deleted elsewhere) — keep the current session
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
} else if (data.kind === "reload") {
|
|
969
|
+
window.location.reload();
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
window.addEventListener("message", onControl);
|
|
973
|
+
return () => window.removeEventListener("message", onControl);
|
|
974
|
+
}, "split-view: top control channel");
|
|
975
|
+
}
|
|
976
|
+
//#endregion
|
|
977
|
+
|
|
978
|
+
//#region settings UI (settings.section + rows)
|
|
979
|
+
// Follows the dsh-dream-skin pattern: one settings.section registration
|
|
980
|
+
// (a category in the settings left nav) hosting rows through its own child
|
|
981
|
+
// slot. The section only ever becomes visible inside panes — the top
|
|
982
|
+
// window shadows the AppFrame, so its settings modal never opens — but it
|
|
983
|
+
// registers in both modes; the row API object decides where edits land
|
|
984
|
+
// (top: the authoritative persisted store; pane: the synced mirror plus a
|
|
985
|
+
// prefs-set message to the top).
|
|
986
|
+
const settingsStyles = {
|
|
987
|
+
section: { display: "flex", flexDirection: "column", width: "100%" },
|
|
988
|
+
group: {
|
|
989
|
+
borderBottom: "1px solid var(--dsw-alias-border-l2)",
|
|
990
|
+
display: "flex",
|
|
991
|
+
flexDirection: "column",
|
|
992
|
+
gap: "10px",
|
|
993
|
+
padding: "16px 0"
|
|
994
|
+
},
|
|
995
|
+
title: { color: "var(--dsw-alias-label-primary)", fontSize: "14px", fontWeight: 400, lineHeight: "22px" },
|
|
996
|
+
hint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "12px", lineHeight: "18px" },
|
|
997
|
+
error: { color: "var(--dsw-alias-state-error-primary,#e5484d)", fontSize: "12px", lineHeight: "18px" },
|
|
998
|
+
actionRow: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
|
|
999
|
+
kvRow: { display: "flex", alignItems: "center", gap: "10px" },
|
|
1000
|
+
kvLabel: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", lineHeight: "20px" },
|
|
1001
|
+
button: {
|
|
1002
|
+
height: "32px",
|
|
1003
|
+
padding: "0 14px",
|
|
1004
|
+
borderRadius: "8px",
|
|
1005
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1006
|
+
background: "var(--dsw-alias-button-elevated-fill)",
|
|
1007
|
+
color: "var(--dsw-alias-label-primary)",
|
|
1008
|
+
cursor: "pointer",
|
|
1009
|
+
fontSize: "13px",
|
|
1010
|
+
font: "inherit",
|
|
1011
|
+
boxSizing: "border-box"
|
|
1012
|
+
},
|
|
1013
|
+
buttonDanger: { color: "var(--dsw-alias-state-error-primary,#e5484d)" },
|
|
1014
|
+
comboChip: {
|
|
1015
|
+
height: "28px",
|
|
1016
|
+
minWidth: "110px",
|
|
1017
|
+
padding: "0 10px",
|
|
1018
|
+
borderRadius: "7px",
|
|
1019
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1020
|
+
background: "var(--dsw-alias-bg-layer-1)",
|
|
1021
|
+
color: "var(--dsw-alias-label-primary)",
|
|
1022
|
+
cursor: "pointer",
|
|
1023
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
1024
|
+
fontSize: "12px",
|
|
1025
|
+
display: "inline-flex",
|
|
1026
|
+
alignItems: "center",
|
|
1027
|
+
justifyContent: "center",
|
|
1028
|
+
boxSizing: "border-box"
|
|
1029
|
+
},
|
|
1030
|
+
comboChipRecording: {
|
|
1031
|
+
borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)",
|
|
1032
|
+
color: "var(--dsw-alias-brand-primary,#4D6BFE)",
|
|
1033
|
+
background: "color-mix(in srgb, var(--dsw-alias-brand-primary,#4D6BFE) 10%, transparent)"
|
|
1034
|
+
},
|
|
1035
|
+
chipBtn: {
|
|
1036
|
+
height: "28px",
|
|
1037
|
+
padding: "0 12px",
|
|
1038
|
+
borderRadius: "7px",
|
|
1039
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1040
|
+
background: "transparent",
|
|
1041
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
1042
|
+
cursor: "pointer",
|
|
1043
|
+
fontSize: "12px",
|
|
1044
|
+
font: "inherit",
|
|
1045
|
+
boxSizing: "border-box"
|
|
1046
|
+
},
|
|
1047
|
+
chipBtnActive: {
|
|
1048
|
+
color: "var(--dsw-alias-brand-primary,#4D6BFE)",
|
|
1049
|
+
borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)",
|
|
1050
|
+
background: "color-mix(in srgb, var(--dsw-alias-brand-primary,#4D6BFE) 10%, transparent)"
|
|
1051
|
+
},
|
|
1052
|
+
mono: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" },
|
|
1053
|
+
swatch: {
|
|
1054
|
+
width: "24px",
|
|
1055
|
+
height: "24px",
|
|
1056
|
+
borderRadius: "50%",
|
|
1057
|
+
border: "1px solid rgba(128,128,128,0.4)",
|
|
1058
|
+
cursor: "pointer",
|
|
1059
|
+
padding: 0,
|
|
1060
|
+
boxSizing: "border-box",
|
|
1061
|
+
flex: "none"
|
|
1062
|
+
},
|
|
1063
|
+
swatchActive: { outline: "2px solid var(--dsw-alias-label-primary)", outlineOffset: "1px" },
|
|
1064
|
+
switchTrack: {
|
|
1065
|
+
width: "36px",
|
|
1066
|
+
height: "22px",
|
|
1067
|
+
borderRadius: "11px",
|
|
1068
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1069
|
+
background: "var(--dsw-alias-bg-layer-1)",
|
|
1070
|
+
cursor: "pointer",
|
|
1071
|
+
position: "relative",
|
|
1072
|
+
padding: 0,
|
|
1073
|
+
flex: "none",
|
|
1074
|
+
transition: "background .15s ease, border-color .15s ease"
|
|
1075
|
+
},
|
|
1076
|
+
switchTrackOn: { background: "var(--dsw-alias-brand-primary,#4D6BFE)", borderColor: "var(--dsw-alias-brand-primary,#4D6BFE)" },
|
|
1077
|
+
switchKnob: {
|
|
1078
|
+
position: "absolute",
|
|
1079
|
+
top: "2px",
|
|
1080
|
+
left: "2px",
|
|
1081
|
+
width: "16px",
|
|
1082
|
+
height: "16px",
|
|
1083
|
+
borderRadius: "50%",
|
|
1084
|
+
background: "#fff",
|
|
1085
|
+
boxShadow: "0 1px 2px rgba(0,0,0,.25)",
|
|
1086
|
+
transition: "left .15s ease",
|
|
1087
|
+
display: "block"
|
|
1088
|
+
},
|
|
1089
|
+
switchKnobOn: { left: "16px" }
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/** Row store: one shared prefs snapshot for every row (the handle's
|
|
1093
|
+
* identity keys the instance, so all rows read the same state). */
|
|
1094
|
+
function createPrefsRowStore() {
|
|
1095
|
+
return runtime.defineStore({
|
|
1096
|
+
init: () => ({ prefs: normalizePrefs({}), revision: -1 }),
|
|
1097
|
+
actions: {
|
|
1098
|
+
sync: (d, prefs, revision) => {
|
|
1099
|
+
if (revision <= d.revision) return;
|
|
1100
|
+
d.prefs = prefs;
|
|
1101
|
+
d.revision = revision;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function SplitViewSection({ renderSlot }) {
|
|
1108
|
+
return React.createElement("div", { style: settingsStyles.section }, renderSlot("settings.splitView.item", {}));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Shortcut row: one recording chip per action. Click a chip, press the
|
|
1112
|
+
* new combination (Esc cancels); reserved/duplicate combos are rejected
|
|
1113
|
+
* with an inline error instead of binding. */
|
|
1114
|
+
function ShortcutsRow({ t, useStore, setShortcut }) {
|
|
1115
|
+
const prefs = useStore((s) => s.prefs);
|
|
1116
|
+
const [recording, setRecording] = React.useState(null);
|
|
1117
|
+
const [error, setError] = React.useState(null);
|
|
1118
|
+
const prefsRef = React.useRef(prefs);
|
|
1119
|
+
prefsRef.current = prefs;
|
|
1120
|
+
React.useEffect(() => {
|
|
1121
|
+
if (recording === null) return;
|
|
1122
|
+
const onKey = (event) => {
|
|
1123
|
+
event.preventDefault();
|
|
1124
|
+
event.stopPropagation();
|
|
1125
|
+
const key = normEventKey(event);
|
|
1126
|
+
if (key === "escape") {
|
|
1127
|
+
setRecording(null);
|
|
1128
|
+
setError(null);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (key === null || MODIFIER_KEYS.has(key)) return;
|
|
1132
|
+
if (!(event.metaKey || event.ctrlKey)) {
|
|
1133
|
+
setError(t("shortcuts.err.noMod"));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const parsed = { shift: event.shiftKey, alt: event.altKey, key };
|
|
1137
|
+
const errKey = validateRecorded(parsed, prefsRef.current.shortcuts, recording);
|
|
1138
|
+
if (errKey !== null) {
|
|
1139
|
+
setError(t("shortcuts." + errKey));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
setShortcut(recording, comboToText(parsed));
|
|
1143
|
+
setRecording(null);
|
|
1144
|
+
setError(null);
|
|
1145
|
+
};
|
|
1146
|
+
window.addEventListener("keydown", onKey, true);
|
|
1147
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
1148
|
+
}, [recording]);
|
|
1149
|
+
return React.createElement("div", { style: settingsStyles.group },
|
|
1150
|
+
React.createElement("div", { style: settingsStyles.title }, t("shortcuts.title")),
|
|
1151
|
+
ACTION_IDS.map((cmd) => React.createElement("div", { style: settingsStyles.kvRow, key: cmd },
|
|
1152
|
+
React.createElement("span", { style: Object.assign({}, settingsStyles.kvLabel, { width: "110px", flex: "none" }) }, t("shortcuts." + cmd)),
|
|
1153
|
+
React.createElement("button", {
|
|
1154
|
+
type: "button",
|
|
1155
|
+
style: recording === cmd
|
|
1156
|
+
? Object.assign({}, settingsStyles.comboChip, settingsStyles.comboChipRecording)
|
|
1157
|
+
: settingsStyles.comboChip,
|
|
1158
|
+
title: recording === cmd ? t("shortcuts.recordHint") : undefined,
|
|
1159
|
+
"aria-pressed": recording === cmd ? "true" : undefined,
|
|
1160
|
+
onClick: () => {
|
|
1161
|
+
setError(null);
|
|
1162
|
+
setRecording(recording === cmd ? null : cmd);
|
|
1163
|
+
}
|
|
1164
|
+
}, recording === cmd ? t("shortcuts.record") : comboLabel(prefs.shortcuts[cmd]))
|
|
1165
|
+
)),
|
|
1166
|
+
React.createElement("div", { style: settingsStyles.actionRow },
|
|
1167
|
+
error !== null ? React.createElement("span", { style: settingsStyles.error }, error) : null,
|
|
1168
|
+
recording !== null ? React.createElement("span", { style: settingsStyles.hint }, t("shortcuts.recordHint")) : null
|
|
1169
|
+
),
|
|
1170
|
+
React.createElement("div", { style: settingsStyles.hint }, t("shortcuts.hint"))
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Focus highlight color row: "follow theme", preset swatches, custom picker. */
|
|
1175
|
+
function FocusColorRow({ t, useStore, setFocusColor }) {
|
|
1176
|
+
const prefs = useStore((s) => s.prefs);
|
|
1177
|
+
const color = prefs.focusColor;
|
|
1178
|
+
const pickerRef = React.useRef(null);
|
|
1179
|
+
const active = color === "theme" ? null : color;
|
|
1180
|
+
return React.createElement("div", { style: settingsStyles.group },
|
|
1181
|
+
React.createElement("div", { style: settingsStyles.title }, t("color.title")),
|
|
1182
|
+
React.createElement("div", { style: settingsStyles.actionRow },
|
|
1183
|
+
React.createElement("button", {
|
|
1184
|
+
type: "button",
|
|
1185
|
+
style: color === "theme"
|
|
1186
|
+
? Object.assign({}, settingsStyles.chipBtn, settingsStyles.chipBtnActive)
|
|
1187
|
+
: settingsStyles.chipBtn,
|
|
1188
|
+
"aria-pressed": color === "theme" ? "true" : "false",
|
|
1189
|
+
onClick: () => setFocusColor("theme")
|
|
1190
|
+
}, t("color.theme")),
|
|
1191
|
+
FOCUS_COLOR_PRESETS.map((hex) => React.createElement("button", {
|
|
1192
|
+
key: hex,
|
|
1193
|
+
type: "button",
|
|
1194
|
+
title: hex,
|
|
1195
|
+
"aria-pressed": color === hex.toLowerCase() ? "true" : "false",
|
|
1196
|
+
style: Object.assign({}, settingsStyles.swatch, { background: hex }, color === hex.toLowerCase() ? settingsStyles.swatchActive : null),
|
|
1197
|
+
onClick: () => setFocusColor(hex)
|
|
1198
|
+
})),
|
|
1199
|
+
React.createElement("button", {
|
|
1200
|
+
type: "button",
|
|
1201
|
+
style: settingsStyles.button,
|
|
1202
|
+
onClick: () => { if (pickerRef.current) pickerRef.current.click(); }
|
|
1203
|
+
}, t("color.pick")),
|
|
1204
|
+
React.createElement("input", {
|
|
1205
|
+
ref: pickerRef,
|
|
1206
|
+
type: "color",
|
|
1207
|
+
value: active || "#4d6bfe",
|
|
1208
|
+
style: { display: "none" },
|
|
1209
|
+
onChange: (event) => setFocusColor(event.target.value)
|
|
1210
|
+
}),
|
|
1211
|
+
React.createElement("span", { style: settingsStyles.mono }, color === "theme" ? t("color.theme") : color)
|
|
1212
|
+
),
|
|
1213
|
+
React.createElement("div", { style: settingsStyles.hint }, t("color.hint"))
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Title bar visibility row. */
|
|
1218
|
+
function TitleBarRow({ t, useStore, setShowTitleBar }) {
|
|
1219
|
+
const prefs = useStore((s) => s.prefs);
|
|
1220
|
+
const show = prefs.showTitleBar;
|
|
1221
|
+
return React.createElement("div", { style: settingsStyles.group },
|
|
1222
|
+
React.createElement("div", { style: settingsStyles.title }, t("titlebar.title")),
|
|
1223
|
+
React.createElement("div", { style: settingsStyles.actionRow },
|
|
1224
|
+
React.createElement("button", {
|
|
1225
|
+
type: "button",
|
|
1226
|
+
role: "switch",
|
|
1227
|
+
"aria-checked": show ? "true" : "false",
|
|
1228
|
+
style: Object.assign({}, settingsStyles.switchTrack, show ? settingsStyles.switchTrackOn : null),
|
|
1229
|
+
onClick: () => setShowTitleBar(!show)
|
|
1230
|
+
}, React.createElement("span", {
|
|
1231
|
+
style: Object.assign({}, settingsStyles.switchKnob, show ? settingsStyles.switchKnobOn : null)
|
|
1232
|
+
})),
|
|
1233
|
+
React.createElement("span", { style: settingsStyles.kvLabel }, t("titlebar.show"))
|
|
1234
|
+
),
|
|
1235
|
+
React.createElement("div", { style: settingsStyles.hint }, t("titlebar.hint"))
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Restore-defaults row: one click (with confirm) back to factory state. */
|
|
1240
|
+
function ResetRow({ t, resetAll, useStore }) {
|
|
1241
|
+
const prefs = useStore((s) => s.prefs);
|
|
1242
|
+
const dirty = JSON.stringify(prefs) !== JSON.stringify(normalizePrefs({}));
|
|
1243
|
+
return React.createElement("div", { style: settingsStyles.group },
|
|
1244
|
+
React.createElement("div", { style: settingsStyles.title }, t("reset.title")),
|
|
1245
|
+
React.createElement("div", { style: settingsStyles.actionRow },
|
|
1246
|
+
React.createElement("button", {
|
|
1247
|
+
type: "button",
|
|
1248
|
+
style: Object.assign({}, settingsStyles.button, dirty ? settingsStyles.buttonDanger : { opacity: 0.55 }),
|
|
1249
|
+
onClick: () => {
|
|
1250
|
+
if (!dirty) return;
|
|
1251
|
+
let ok = true;
|
|
1252
|
+
try {
|
|
1253
|
+
ok = window.confirm(t("reset.confirm"));
|
|
1254
|
+
} catch {
|
|
1255
|
+
// confirm unavailable — reset directly
|
|
1256
|
+
}
|
|
1257
|
+
if (ok) resetAll();
|
|
1258
|
+
}
|
|
1259
|
+
}, t("reset.button"))
|
|
1260
|
+
),
|
|
1261
|
+
React.createElement("div", { style: settingsStyles.hint }, t("reset.hint"))
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Register the settings section + rows.
|
|
1267
|
+
* @param ctx - client cordis context.
|
|
1268
|
+
* @param api - prefs channel: getPrefs(), subscribe(fn), savePrefs(patch).
|
|
1269
|
+
*/
|
|
1270
|
+
function applySettings(ctx, api) {
|
|
1271
|
+
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), "dsh-split-view: settings dictionaries");
|
|
1272
|
+
|
|
1273
|
+
const rowStore = createPrefsRowStore();
|
|
1274
|
+
const rowActions = new Set();
|
|
1275
|
+
let revision = 0;
|
|
1276
|
+
const syncRows = () => {
|
|
1277
|
+
revision += 1;
|
|
1278
|
+
const prefs = api.getPrefs();
|
|
1279
|
+
for (const actions of rowActions) actions.sync(prefs, revision);
|
|
1280
|
+
};
|
|
1281
|
+
ctx.effect(() => api.subscribe(syncRows), "dsh-split-view: settings row sync");
|
|
1282
|
+
const bindRow = (actions) => {
|
|
1283
|
+
rowActions.add(actions);
|
|
1284
|
+
syncRows();
|
|
1285
|
+
return {
|
|
1286
|
+
setShortcut: (cmd, combo) => {
|
|
1287
|
+
const patch = { shortcuts: {} };
|
|
1288
|
+
patch.shortcuts[cmd] = combo;
|
|
1289
|
+
api.savePrefs(patch);
|
|
1290
|
+
},
|
|
1291
|
+
setFocusColor: (value) => api.savePrefs({ focusColor: value }),
|
|
1292
|
+
setShowTitleBar: (on) => api.savePrefs({ showTitleBar: !!on }),
|
|
1293
|
+
resetAll: () => api.savePrefs({
|
|
1294
|
+
shortcuts: Object.assign({}, DEFAULT_SHORTCUTS),
|
|
1295
|
+
focusColor: DEFAULT_PREFS.focusColor,
|
|
1296
|
+
showTitleBar: DEFAULT_PREFS.showTitleBar
|
|
1297
|
+
})
|
|
1298
|
+
};
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1302
|
+
name: "settings.section",
|
|
1303
|
+
id: "split-view",
|
|
1304
|
+
order: 30,
|
|
1305
|
+
label: () => ctx.locale.bind(SETTINGS_NS)("nav"),
|
|
1306
|
+
locale: SETTINGS_NS,
|
|
1307
|
+
children: { "settings.splitView.item": {
|
|
1308
|
+
kind: "list",
|
|
1309
|
+
scope: "root"
|
|
1310
|
+
} }
|
|
1311
|
+
}, SplitViewSection));
|
|
1312
|
+
|
|
1313
|
+
ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
|
|
1314
|
+
name: "settings.splitView.item",
|
|
1315
|
+
id: "split-view-shortcuts",
|
|
1316
|
+
order: 10,
|
|
1317
|
+
store: rowStore,
|
|
1318
|
+
locale: SETTINGS_NS,
|
|
1319
|
+
inject: bindRow
|
|
1320
|
+
}, ShortcutsRow));
|
|
1321
|
+
|
|
1322
|
+
ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
|
|
1323
|
+
name: "settings.splitView.item",
|
|
1324
|
+
id: "split-view-focus-color",
|
|
1325
|
+
order: 20,
|
|
1326
|
+
store: rowStore,
|
|
1327
|
+
locale: SETTINGS_NS,
|
|
1328
|
+
inject: bindRow
|
|
1329
|
+
}, FocusColorRow));
|
|
1330
|
+
|
|
1331
|
+
ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
|
|
1332
|
+
name: "settings.splitView.item",
|
|
1333
|
+
id: "split-view-title-bar",
|
|
1334
|
+
order: 30,
|
|
1335
|
+
store: rowStore,
|
|
1336
|
+
locale: SETTINGS_NS,
|
|
1337
|
+
inject: bindRow
|
|
1338
|
+
}, TitleBarRow));
|
|
1339
|
+
|
|
1340
|
+
ctx.slots.inject("settings.splitView.item", () => ctx.slots.register({
|
|
1341
|
+
name: "settings.splitView.item",
|
|
1342
|
+
id: "split-view-reset",
|
|
1343
|
+
order: 90,
|
|
1344
|
+
store: rowStore,
|
|
1345
|
+
locale: SETTINGS_NS,
|
|
1346
|
+
inject: bindRow
|
|
1347
|
+
}, ResetRow));
|
|
1348
|
+
|
|
1349
|
+
// The shell's settings nav hard-codes the per-section icon by entry id
|
|
1350
|
+
// (models / agent-presets / plugins get distinct icons, everything else
|
|
1351
|
+
// falls back to the generic gear) and the settings.section contract
|
|
1352
|
+
// carries no icon option — so this section would draw the gear like any
|
|
1353
|
+
// unknown page. Patch the rendered nav instead: hide the gear on our
|
|
1354
|
+
// label-matched cell and append a split-view icon (additive only — the
|
|
1355
|
+
// React-managed nodes stay in place, so reconciliation never trips).
|
|
1356
|
+
// Runs per pane window: the top window shadows the AppFrame, so the
|
|
1357
|
+
// settings dialog only ever mounts here.
|
|
1358
|
+
ctx.effect(() => {
|
|
1359
|
+
const NAV_ICON = '<svg class="dsh-split-nav-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="1.75"></rect><line x1="8" y1="2.75" x2="8" y2="13.25"></line><line x1="8" y1="8" x2="14.25" y2="8"></line></svg>';
|
|
1360
|
+
const labels = new Set([zh.nav, en.nav]);
|
|
1361
|
+
const style = document.createElement("style");
|
|
1362
|
+
style.setAttribute("data-plugin", "dsh-split-view-settings-icon");
|
|
1363
|
+
style.textContent = ".dsh-split-nav-icon{order:-1;flex:none;}";
|
|
1364
|
+
document.head.append(style);
|
|
1365
|
+
let queued = false;
|
|
1366
|
+
const patch = () => {
|
|
1367
|
+
queued = false;
|
|
1368
|
+
const cells = document.querySelectorAll('div[role="dialog"] nav button[type="button"]');
|
|
1369
|
+
for (const cell of cells) {
|
|
1370
|
+
const span = cell.querySelector("span");
|
|
1371
|
+
if (!span || !labels.has(span.textContent.trim())) continue;
|
|
1372
|
+
for (const gear of cell.querySelectorAll("svg:not(.dsh-split-nav-icon)")) gear.style.display = "none";
|
|
1373
|
+
if (cell.querySelector(".dsh-split-nav-icon") === null) cell.insertAdjacentHTML("beforeend", NAV_ICON);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
const schedule = () => {
|
|
1377
|
+
if (queued) return;
|
|
1378
|
+
queued = true;
|
|
1379
|
+
requestAnimationFrame(patch);
|
|
1380
|
+
};
|
|
1381
|
+
schedule();
|
|
1382
|
+
const observer = new MutationObserver(schedule);
|
|
1383
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1384
|
+
return () => {
|
|
1385
|
+
observer.disconnect();
|
|
1386
|
+
style.remove();
|
|
1387
|
+
};
|
|
1388
|
+
}, "dsh-split-view: settings nav icon");
|
|
1389
|
+
}
|
|
1390
|
+
//#endregion
|
|
1391
|
+
|
|
1392
|
+
//#region top window (the splitter)
|
|
1393
|
+
function useSnap(store, selector) {
|
|
1394
|
+
const sel = selector ?? ((value) => value);
|
|
1395
|
+
return React.useSyncExternalStore(store.subscribe, () => sel(store.getSnapshot()));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function Divider({ dir, rect, usable, hidden, onNudge }) {
|
|
1399
|
+
const onPointerDown = (event) => {
|
|
1400
|
+
event.preventDefault();
|
|
1401
|
+
const el = event.currentTarget;
|
|
1402
|
+
if (usable <= 0) return;
|
|
1403
|
+
let last = dir === "h" ? event.clientX : event.clientY;
|
|
1404
|
+
// Direct tracking while dragging: kill the layout transitions so
|
|
1405
|
+
// panes follow the pointer 1:1 instead of lagging behind it.
|
|
1406
|
+
document.body.classList.add("dsh-split-dragging");
|
|
1407
|
+
try {
|
|
1408
|
+
el.setPointerCapture(event.pointerId);
|
|
1409
|
+
} catch {
|
|
1410
|
+
// capture is a nicety; dragging still works without it
|
|
1411
|
+
}
|
|
1412
|
+
const move = (ev) => {
|
|
1413
|
+
const pos = dir === "h" ? ev.clientX : ev.clientY;
|
|
1414
|
+
const delta = (pos - last) / usable;
|
|
1415
|
+
last = pos;
|
|
1416
|
+
if (delta !== 0) onNudge(delta);
|
|
1417
|
+
};
|
|
1418
|
+
const up = () => {
|
|
1419
|
+
el.removeEventListener("pointermove", move);
|
|
1420
|
+
el.removeEventListener("pointerup", up);
|
|
1421
|
+
el.removeEventListener("pointercancel", up);
|
|
1422
|
+
document.body.classList.remove("dsh-split-dragging");
|
|
1423
|
+
};
|
|
1424
|
+
el.addEventListener("pointermove", move);
|
|
1425
|
+
el.addEventListener("pointerup", up);
|
|
1426
|
+
el.addEventListener("pointercancel", up);
|
|
1427
|
+
};
|
|
1428
|
+
return React.createElement("div", {
|
|
1429
|
+
className: "dsh-split-divider " + (dir === "h" ? "dsh-split-divider-v" : "dsh-split-divider-h") + (hidden ? " dsh-split-divider-hidden" : ""),
|
|
1430
|
+
style: { left: rect.x, top: rect.y, width: rect.w, height: rect.h },
|
|
1431
|
+
onPointerDown
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Boot gate: each pane boot bursts ~40 bundle fetches plus 2 WebSocket
|
|
1436
|
+
// handshakes at the origin. Simultaneous pane boots stampede shared
|
|
1437
|
+
// connection resources and intermittently hang boots in constrained
|
|
1438
|
+
// environments (observed: random stuck panes in a busy Chrome profile,
|
|
1439
|
+
// never reproducible in a clean Chromium). At most MAX_CONCURRENT_BOOT
|
|
1440
|
+
// iframes may load at once; a Pane acquires a slot before mounting its
|
|
1441
|
+
// iframe src and releases it when the agent reports ready, the fallback
|
|
1442
|
+
// fires, or the pane unmounts.
|
|
1443
|
+
const MAX_CONCURRENT_BOOT = 2;
|
|
1444
|
+
const bootGate = (() => {
|
|
1445
|
+
let active = 0;
|
|
1446
|
+
const waiters = [];
|
|
1447
|
+
const grant = () => {
|
|
1448
|
+
while (active < MAX_CONCURRENT_BOOT && waiters.length > 0) {
|
|
1449
|
+
active++;
|
|
1450
|
+
waiters.shift()();
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
return {
|
|
1454
|
+
acquire(cb) {
|
|
1455
|
+
if (active < MAX_CONCURRENT_BOOT) {
|
|
1456
|
+
active++;
|
|
1457
|
+
cb();
|
|
1458
|
+
} else waiters.push(cb);
|
|
1459
|
+
},
|
|
1460
|
+
release() {
|
|
1461
|
+
if (active > 0) active--;
|
|
1462
|
+
grant();
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
})();
|
|
1466
|
+
|
|
1467
|
+
function Pane({ leaf, focused, ready, rect, hidden, dormant, onWake, base, title, info, maximized, actions, labels }) {
|
|
1468
|
+
const iframeRef = React.useRef(null);
|
|
1469
|
+
// The iframe URL is frozen per boot cycle: rebuilt when a boot starts
|
|
1470
|
+
// (so a woken pane re-enters the session it reported before
|
|
1471
|
+
// hibernation instead of re-running its birth URL), and never touched
|
|
1472
|
+
// while the iframe is live — a changed src would reload the pane.
|
|
1473
|
+
const srcRef = React.useRef(null);
|
|
1474
|
+
const srcCycleRef = React.useRef(-1);
|
|
1475
|
+
const [bootCycle, setBootCycle] = React.useState(0);
|
|
1476
|
+
const [booting, setBooting] = React.useState(false);
|
|
1477
|
+
const [fallback, setFallback] = React.useState(false);
|
|
1478
|
+
// Token-based gate release: each mount cycle owns one token, so an
|
|
1479
|
+
// unmount/remount cycle (React StrictMode double-invoke, key churn)
|
|
1480
|
+
// can neither leak a boot slot nor double-free one — the released
|
|
1481
|
+
// flag lives on the per-cycle token, and cleanup only frees a slot
|
|
1482
|
+
// the cycle actually consumed.
|
|
1483
|
+
const tokenRef = React.useRef(null);
|
|
1484
|
+
const releaseGate = () => {
|
|
1485
|
+
const token = tokenRef.current;
|
|
1486
|
+
if (token && token.acquired && !token.released) {
|
|
1487
|
+
token.released = true;
|
|
1488
|
+
bootGate.release();
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
React.useEffect(() => {
|
|
1492
|
+
// Dormant panes hold no iframe: the effect's cleanup already freed
|
|
1493
|
+
// the boot slot, and unmounting the iframe closed its WebSockets —
|
|
1494
|
+
// that is exactly what hibernation is for.
|
|
1495
|
+
if (dormant) {
|
|
1496
|
+
setBooting(false);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
setFallback(false);
|
|
1500
|
+
const token = { acquired: false, released: false };
|
|
1501
|
+
tokenRef.current = token;
|
|
1502
|
+
let cancelled = false;
|
|
1503
|
+
bootGate.acquire(() => {
|
|
1504
|
+
if (cancelled || token.released) {
|
|
1505
|
+
bootGate.release();
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
token.acquired = true;
|
|
1509
|
+
setBootCycle((c) => c + 1);
|
|
1510
|
+
setBooting(true);
|
|
1511
|
+
});
|
|
1512
|
+
return () => {
|
|
1513
|
+
cancelled = true;
|
|
1514
|
+
if (token.acquired && !token.released) {
|
|
1515
|
+
token.released = true;
|
|
1516
|
+
bootGate.release();
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
}, [dormant]);
|
|
1520
|
+
// Ready report frees the gate immediately; otherwise the fallback
|
|
1521
|
+
// (started when the iframe actually begins loading) frees it and shows
|
|
1522
|
+
// the stale chip so a hung boot is visible and reloadable instead of
|
|
1523
|
+
// an unexplained spinner.
|
|
1524
|
+
React.useEffect(() => {
|
|
1525
|
+
if (ready) releaseGate();
|
|
1526
|
+
}, [ready]);
|
|
1527
|
+
React.useEffect(() => {
|
|
1528
|
+
if (!booting) return;
|
|
1529
|
+
const t = setTimeout(() => {
|
|
1530
|
+
setFallback(true);
|
|
1531
|
+
releaseGate();
|
|
1532
|
+
}, VEIL_TIMEOUT_MS);
|
|
1533
|
+
return () => clearTimeout(t);
|
|
1534
|
+
}, [booting]);
|
|
1535
|
+
// Pull the browser focus into the pane on boot/ready edges only.
|
|
1536
|
+
// Deliberately NOT keyed on `focused`: when the user clicks into a
|
|
1537
|
+
// not-yet-focused pane, the browser hands focus to the clicked
|
|
1538
|
+
// element (an input, say) by itself — a parent-side frame.focus()
|
|
1539
|
+
// fired on the same focused flip steals it back to the iframe
|
|
1540
|
+
// document body, so the first click never reaches the input and a
|
|
1541
|
+
// second click is needed. Layout actions that still need a focus
|
|
1542
|
+
// transfer (split/wake boots land here via booting/ready; the
|
|
1543
|
+
// close-survivor is handled explicitly by actions.close).
|
|
1544
|
+
React.useEffect(() => {
|
|
1545
|
+
if (!focused || dormant) return;
|
|
1546
|
+
const frame = iframeRef.current;
|
|
1547
|
+
if (!frame) return;
|
|
1548
|
+
try {
|
|
1549
|
+
frame.focus();
|
|
1550
|
+
if (frame.contentWindow) frame.contentWindow.focus();
|
|
1551
|
+
} catch {
|
|
1552
|
+
// focus is best-effort
|
|
1553
|
+
}
|
|
1554
|
+
}, [booting, ready]);
|
|
1555
|
+
const stale = fallback && !ready;
|
|
1556
|
+
const reloadPane = () => {
|
|
1557
|
+
const frame = iframeRef.current;
|
|
1558
|
+
if (!frame) return;
|
|
1559
|
+
setFallback(false);
|
|
1560
|
+
try {
|
|
1561
|
+
frame.contentWindow.location.reload();
|
|
1562
|
+
} catch {
|
|
1563
|
+
frame.src = srcRef.current;
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
//#region title-bar interactions (maximize on dblclick, session-id copy, inline rename)
|
|
1567
|
+
const sessionId = typeof leaf.session === "string" && leaf.session.length > 0 ? leaf.session : null;
|
|
1568
|
+
// Renamable only once the session has content: blank sessions display
|
|
1569
|
+
// the workspace folder name as a placeholder title, which must not be
|
|
1570
|
+
// edited (it re-derives from the workspace).
|
|
1571
|
+
const canEdit = sessionId !== null && info !== null && info.blank === false;
|
|
1572
|
+
const realTitle = info !== null && typeof info.realTitle === "string" ? info.realTitle : null;
|
|
1573
|
+
const [editing, setEditing] = React.useState(false);
|
|
1574
|
+
const [draft, setDraft] = React.useState("");
|
|
1575
|
+
const [copied, setCopied] = React.useState(false);
|
|
1576
|
+
// Single-click opens the rename editor only after the double-click
|
|
1577
|
+
// window passes — a double-click on the title bar maximizes instead
|
|
1578
|
+
// and cancels the pending editor. Any follow-up mousedown anywhere
|
|
1579
|
+
// cancels it too, so "click title, then click into the pane" never
|
|
1580
|
+
// pops the editor 250ms later.
|
|
1581
|
+
const editTimer = React.useRef(null);
|
|
1582
|
+
const editCancelRef = React.useRef(null);
|
|
1583
|
+
const clearEditTimer = () => {
|
|
1584
|
+
if (editTimer.current !== null) {
|
|
1585
|
+
clearTimeout(editTimer.current);
|
|
1586
|
+
editTimer.current = null;
|
|
1587
|
+
}
|
|
1588
|
+
if (editCancelRef.current !== null) {
|
|
1589
|
+
window.removeEventListener("mousedown", editCancelRef.current, true);
|
|
1590
|
+
editCancelRef.current = null;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
React.useEffect(() => clearEditTimer, []);
|
|
1594
|
+
React.useEffect(() => {
|
|
1595
|
+
if (!copied) return;
|
|
1596
|
+
const t = setTimeout(() => setCopied(false), 1200);
|
|
1597
|
+
return () => clearTimeout(t);
|
|
1598
|
+
}, [copied]);
|
|
1599
|
+
// A pane can change identity (session replaced) or become un-editable
|
|
1600
|
+
// while the editor is open — drop the editor instead of renaming the
|
|
1601
|
+
// wrong session.
|
|
1602
|
+
React.useEffect(() => {
|
|
1603
|
+
if (editing && !canEdit) {
|
|
1604
|
+
clearEditTimer();
|
|
1605
|
+
setEditing(false);
|
|
1606
|
+
}
|
|
1607
|
+
}, [editing, canEdit]);
|
|
1608
|
+
const onTitleClick = () => {
|
|
1609
|
+
if (!canEdit || editing) return;
|
|
1610
|
+
clearEditTimer();
|
|
1611
|
+
editTimer.current = setTimeout(() => {
|
|
1612
|
+
editTimer.current = null;
|
|
1613
|
+
// Focus already moved into a pane iframe while waiting — the
|
|
1614
|
+
// original click was not an edit intent.
|
|
1615
|
+
const active = document.activeElement;
|
|
1616
|
+
if (active && active.tagName === "IFRAME") {
|
|
1617
|
+
clearEditTimer();
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
setDraft(realTitle ?? "");
|
|
1621
|
+
setEditing(true);
|
|
1622
|
+
}, 250);
|
|
1623
|
+
editCancelRef.current = () => clearEditTimer();
|
|
1624
|
+
window.addEventListener("mousedown", editCancelRef.current, true);
|
|
1625
|
+
};
|
|
1626
|
+
const exitEdit = () => {
|
|
1627
|
+
clearEditTimer();
|
|
1628
|
+
setEditing(false);
|
|
1629
|
+
};
|
|
1630
|
+
const commitEdit = () => {
|
|
1631
|
+
clearEditTimer();
|
|
1632
|
+
setEditing(false);
|
|
1633
|
+
const next = draft.trim();
|
|
1634
|
+
if (sessionId === null || next.length === 0) return;
|
|
1635
|
+
if (next === (realTitle ?? "")) return;
|
|
1636
|
+
actions.rename(leaf.id, next);
|
|
1637
|
+
};
|
|
1638
|
+
const onTitleBarDoubleClick = (event) => {
|
|
1639
|
+
clearEditTimer();
|
|
1640
|
+
const target = event.target;
|
|
1641
|
+
// Interactive islands keep their own double-click semantics: the
|
|
1642
|
+
// action buttons and the rename editor (word-selection in the input).
|
|
1643
|
+
if (target && target.closest && target.closest(".dsh-split-tb-actions, .dsh-split-title-edit")) return;
|
|
1644
|
+
actions.maximize(leaf.id);
|
|
1645
|
+
};
|
|
1646
|
+
const copySessionId = () => {
|
|
1647
|
+
if (sessionId === null) return;
|
|
1648
|
+
const text = "会话 id: " + sessionId + "\n会话标题: " + (typeof title === "string" ? title : "");
|
|
1649
|
+
const done = () => setCopied(true);
|
|
1650
|
+
const fallback = () => {
|
|
1651
|
+
try {
|
|
1652
|
+
const ta = document.createElement("textarea");
|
|
1653
|
+
ta.value = text;
|
|
1654
|
+
ta.setAttribute("readonly", "");
|
|
1655
|
+
ta.style.position = "fixed";
|
|
1656
|
+
ta.style.left = "-9999px";
|
|
1657
|
+
document.body.appendChild(ta);
|
|
1658
|
+
ta.select();
|
|
1659
|
+
document.execCommand("copy");
|
|
1660
|
+
ta.remove();
|
|
1661
|
+
done();
|
|
1662
|
+
} catch {
|
|
1663
|
+
// clipboard unavailable — no feedback
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
try {
|
|
1667
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1668
|
+
navigator.clipboard.writeText(text).then(done, fallback);
|
|
1669
|
+
} else fallback();
|
|
1670
|
+
} catch {
|
|
1671
|
+
fallback();
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
//#endregion
|
|
1675
|
+
// Geometry comes from the layout engine; hidden panes (maximized
|
|
1676
|
+
// away) keep their home rect and fade out, so restore is instant,
|
|
1677
|
+
// never reloads, and animates smoothly.
|
|
1678
|
+
const paneStyle = rect
|
|
1679
|
+
? { left: rect.x, top: rect.y, width: rect.w, height: rect.h }
|
|
1680
|
+
: { display: "none" };
|
|
1681
|
+
if (booting && srcCycleRef.current !== bootCycle) {
|
|
1682
|
+
srcCycleRef.current = bootCycle;
|
|
1683
|
+
const q = new URLSearchParams();
|
|
1684
|
+
q.set("dshPane", leaf.id);
|
|
1685
|
+
if (leaf.session) q.set("dshSession", leaf.session);
|
|
1686
|
+
else if (leaf.workspace) q.set("dshWorkspace", leaf.workspace);
|
|
1687
|
+
else if (leaf.cwd) q.set("dshCwd", leaf.cwd);
|
|
1688
|
+
else q.set("dshNew", "1");
|
|
1689
|
+
srcRef.current = base + "/?" + q.toString();
|
|
1690
|
+
}
|
|
1691
|
+
return React.createElement("div", {
|
|
1692
|
+
className: "dsh-split-pane" + (focused ? " dsh-split-pane-focused" : "") + (hidden ? " dsh-split-pane-hidden" : ""),
|
|
1693
|
+
style: paneStyle,
|
|
1694
|
+
"data-leaf": leaf.id
|
|
1695
|
+
},
|
|
1696
|
+
React.createElement("div", {
|
|
1697
|
+
className: "dsh-split-titlebar" + (focused ? " dsh-split-titlebar-focused" : ""),
|
|
1698
|
+
onMouseDown: () => actions.focus(leaf.id),
|
|
1699
|
+
onDoubleClick: onTitleBarDoubleClick
|
|
1700
|
+
},
|
|
1701
|
+
editing
|
|
1702
|
+
? React.createElement("span", { className: "dsh-split-title-edit" },
|
|
1703
|
+
React.createElement("input", {
|
|
1704
|
+
className: "dsh-split-title-input",
|
|
1705
|
+
value: draft,
|
|
1706
|
+
autoFocus: true,
|
|
1707
|
+
placeholder: "输入会话名称…",
|
|
1708
|
+
onFocus: (e) => e.target.select(),
|
|
1709
|
+
onChange: (e) => setDraft(e.target.value),
|
|
1710
|
+
onKeyDown: (e) => {
|
|
1711
|
+
if (e.key === "Enter") {
|
|
1712
|
+
e.preventDefault();
|
|
1713
|
+
commitEdit();
|
|
1714
|
+
} else if (e.key === "Escape") {
|
|
1715
|
+
e.preventDefault();
|
|
1716
|
+
exitEdit();
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
onBlur: () => exitEdit()
|
|
1720
|
+
}),
|
|
1721
|
+
React.createElement("button", {
|
|
1722
|
+
className: "dsh-split-tb-btn dsh-split-title-confirm",
|
|
1723
|
+
title: "确认重命名(Enter)",
|
|
1724
|
+
// keep the input focused so its blur (cancel) never races the click
|
|
1725
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1726
|
+
onClick: (e) => {
|
|
1727
|
+
e.stopPropagation();
|
|
1728
|
+
commitEdit();
|
|
1729
|
+
}
|
|
1730
|
+
}, iconCheck()))
|
|
1731
|
+
: React.createElement("span", {
|
|
1732
|
+
className: "dsh-split-title" + (canEdit ? " dsh-split-title-editable" : ""),
|
|
1733
|
+
onClick: onTitleClick,
|
|
1734
|
+
title: canEdit ? (typeof title === "string" && title.length > 0 ? title + " · 点击重命名" : "点击重命名") : undefined
|
|
1735
|
+
}, title || (dormant ? "已休眠" : "…")),
|
|
1736
|
+
!editing && sessionId !== null ? React.createElement("button", {
|
|
1737
|
+
className: "dsh-split-tb-btn" + (copied ? " dsh-split-tb-btn-ok" : ""),
|
|
1738
|
+
title: copied ? "已复制会话信息" : "复制会话信息",
|
|
1739
|
+
onClick: (e) => {
|
|
1740
|
+
e.stopPropagation();
|
|
1741
|
+
copySessionId();
|
|
1742
|
+
}
|
|
1743
|
+
}, copied ? iconCheck() : iconCopy()) : null,
|
|
1744
|
+
React.createElement("span", { className: "dsh-split-tb-actions" },
|
|
1745
|
+
tbButton(iconSplitRight(), "向右分屏(" + labels.splitRight + ")", () => actions.split(leaf.id, "right")),
|
|
1746
|
+
tbButton(iconSplitDown(), "向下分屏(" + labels.splitDown + ")", () => actions.split(leaf.id, "down")),
|
|
1747
|
+
tbButton(maximized ? iconShrink() : iconExpand(), maximized ? "还原(" + labels.maximize + ")" : "放大(" + labels.maximize + ")", () => actions.maximize(leaf.id)),
|
|
1748
|
+
tbButton(iconRefresh(), dormant ? "唤醒该面板" : "刷新该面板", dormant ? onWake : reloadPane),
|
|
1749
|
+
tbButton(iconClose(), "关闭面板(" + labels.close + ")", () => actions.close(leaf.id), true))),
|
|
1750
|
+
React.createElement("div", { className: "dsh-split-pane-body" },
|
|
1751
|
+
dormant ? React.createElement("button", {
|
|
1752
|
+
className: "dsh-split-dormant",
|
|
1753
|
+
title: "该面板已休眠以释放连接资源,点击唤醒",
|
|
1754
|
+
onClick: onWake
|
|
1755
|
+
},
|
|
1756
|
+
React.createElement("div", { className: "dsh-split-dormant-icon" }, "😴"),
|
|
1757
|
+
React.createElement("div", null, "已休眠 · 点击唤醒")) : React.createElement(React.Fragment, null,
|
|
1758
|
+
!ready ? React.createElement("div", { className: "dsh-split-veil" },
|
|
1759
|
+
React.createElement("div", { className: "dsh-split-spinner" }),
|
|
1760
|
+
React.createElement("div", { className: "dsh-split-veil-hint" }, booting ? "启动中…" : "等待启动…")
|
|
1761
|
+
) : null,
|
|
1762
|
+
stale ? React.createElement("button", {
|
|
1763
|
+
className: "dsh-split-stale",
|
|
1764
|
+
title: "该面板未在预期时间内就绪,点击重载",
|
|
1765
|
+
onClick: reloadPane
|
|
1766
|
+
}, "⚠ 未就绪 · 重载") : null,
|
|
1767
|
+
booting ? React.createElement("iframe", {
|
|
1768
|
+
ref: iframeRef,
|
|
1769
|
+
className: "dsh-split-iframe",
|
|
1770
|
+
src: srcRef.current,
|
|
1771
|
+
title: "session pane " + leaf.id
|
|
1772
|
+
}) : null)));
|
|
1773
|
+
}
|
|
1774
|
+
/** Small icon button for the per-pane title bar. */
|
|
1775
|
+
function tbButton(icon, title, onClick, danger) {
|
|
1776
|
+
return React.createElement("button", {
|
|
1777
|
+
className: "dsh-split-tb-btn" + (danger ? " dsh-split-tb-btn-danger" : ""),
|
|
1778
|
+
title,
|
|
1779
|
+
onClick
|
|
1780
|
+
}, icon);
|
|
1781
|
+
}
|
|
1782
|
+
/** Crisp stroke icons on a 16-grid, inheriting currentColor. */
|
|
1783
|
+
function svgIcon() {
|
|
1784
|
+
const children = Array.prototype.slice.call(arguments);
|
|
1785
|
+
return React.createElement.apply(React, ["svg", {
|
|
1786
|
+
width: "13",
|
|
1787
|
+
height: "13",
|
|
1788
|
+
viewBox: "0 0 16 16",
|
|
1789
|
+
fill: "none",
|
|
1790
|
+
stroke: "currentColor",
|
|
1791
|
+
strokeWidth: "1.4",
|
|
1792
|
+
strokeLinecap: "round",
|
|
1793
|
+
strokeLinejoin: "round",
|
|
1794
|
+
"aria-hidden": "true"
|
|
1795
|
+
}].concat(children));
|
|
1796
|
+
}
|
|
1797
|
+
const iconSplitRight = () => svgIcon(
|
|
1798
|
+
React.createElement("rect", { x: "1.75", y: "3", width: "12.5", height: "10", rx: "1.5" }),
|
|
1799
|
+
React.createElement("path", { d: "M8 3 V13" }),
|
|
1800
|
+
React.createElement("path", { d: "M9.9 8 H12.3" }),
|
|
1801
|
+
React.createElement("path", { d: "M11.2 6.9 L12.3 8 L11.2 9.1" })
|
|
1802
|
+
);
|
|
1803
|
+
const iconSplitDown = () => svgIcon(
|
|
1804
|
+
React.createElement("rect", { x: "1.75", y: "3", width: "12.5", height: "10", rx: "1.5" }),
|
|
1805
|
+
React.createElement("path", { d: "M1.75 8 H14.25" }),
|
|
1806
|
+
React.createElement("path", { d: "M8 9.3 V11.7" }),
|
|
1807
|
+
React.createElement("path", { d: "M6.9 10.6 L8 11.7 L9.1 10.6" })
|
|
1808
|
+
);
|
|
1809
|
+
/** Maximize = video-player fullscreen mark: diagonal two-way corner arrows. */
|
|
1810
|
+
const iconExpand = () => svgIcon(
|
|
1811
|
+
React.createElement("polyline", { points: "10 2 14 2 14 6" }),
|
|
1812
|
+
React.createElement("polyline", { points: "6 14 2 14 2 10" }),
|
|
1813
|
+
React.createElement("line", { x1: "14", y1: "2", x2: "9.3", y2: "6.7" }),
|
|
1814
|
+
React.createElement("line", { x1: "2", y1: "14", x2: "6.7", y2: "9.3" })
|
|
1815
|
+
);
|
|
1816
|
+
const iconShrink = () => svgIcon(
|
|
1817
|
+
React.createElement("polyline", { points: "2.7 9.3 6.7 9.3 6.7 13.3" }),
|
|
1818
|
+
React.createElement("polyline", { points: "13.3 6.7 9.3 6.7 9.3 2.7" }),
|
|
1819
|
+
React.createElement("line", { x1: "9.3", y1: "6.7", x2: "14", y2: "2" }),
|
|
1820
|
+
React.createElement("line", { x1: "2", y1: "14", x2: "6.7", y2: "9.3" })
|
|
1821
|
+
);
|
|
1822
|
+
const iconRefresh = () => svgIcon(
|
|
1823
|
+
React.createElement("polyline", { points: "15.3 2.7 15.3 6.7 11.3 6.7" }),
|
|
1824
|
+
React.createElement("path", { d: "M13.66 10a6 6 0 1 1-1.41-6.24L15.33 6.67" })
|
|
1825
|
+
);
|
|
1826
|
+
const iconClose = () => svgIcon(
|
|
1827
|
+
React.createElement("line", { x1: "4.5", y1: "4.5", x2: "11.5", y2: "11.5" }),
|
|
1828
|
+
React.createElement("line", { x1: "11.5", y1: "4.5", x2: "4.5", y2: "11.5" })
|
|
1829
|
+
);
|
|
1830
|
+
const iconCopy = () => svgIcon(
|
|
1831
|
+
React.createElement("rect", { x: "5.5", y: "5.5", width: "8.5", height: "8.5", rx: "1.5" }),
|
|
1832
|
+
React.createElement("path", { d: "M10.5 3.25 V3 A1.25 1.25 0 0 0 9.25 1.75 H3 A1.25 1.25 0 0 0 1.75 3 V9.25 A1.25 1.25 0 0 0 3 10.5 H3.25" })
|
|
1833
|
+
);
|
|
1834
|
+
const iconCheck = () => svgIcon(
|
|
1835
|
+
React.createElement("polyline", { points: "2.75 8.5 6.25 12 13.25 4.25" })
|
|
1836
|
+
);
|
|
1837
|
+
|
|
1838
|
+
//#region layout geometry
|
|
1839
|
+
const DIVIDER_PX = 5;
|
|
1840
|
+
|
|
1841
|
+
function collectLeaves(node, out) {
|
|
1842
|
+
if (node.kind === "leaf") {
|
|
1843
|
+
out.push(node);
|
|
1844
|
+
return out;
|
|
1845
|
+
}
|
|
1846
|
+
node.children.forEach((child) => collectLeaves(child, out));
|
|
1847
|
+
return out;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** The LRU live set with PER-ORIGIN caps, shared by the renderer and
|
|
1851
|
+
* the public service (both must agree on which panes are dormant).
|
|
1852
|
+
* Focused/maximized lead the candidate order so they win a slot first;
|
|
1853
|
+
* the rest follow focus recency, then tree order. A candidate whose
|
|
1854
|
+
* origin is already at cap is skipped (it hibernates), so no origin
|
|
1855
|
+
* ever exceeds the browser's live-client budget. */
|
|
1856
|
+
function computeLiveSet(snap, order, pool, caps, maxLive) {
|
|
1857
|
+
const allLeaves = collectLeaves(snap.tree, []);
|
|
1858
|
+
const leafById = new Map(allLeaves.map((l) => [l.id, l]));
|
|
1859
|
+
const candidates = [];
|
|
1860
|
+
if (snap.focused) candidates.push(snap.focused);
|
|
1861
|
+
if (snap.maximized) candidates.push(snap.maximized);
|
|
1862
|
+
for (let i = order.length - 1; i >= 0; i--) candidates.push(order[i]);
|
|
1863
|
+
for (const l of allLeaves) candidates.push(l.id);
|
|
1864
|
+
const set = new Set();
|
|
1865
|
+
const perOrigin = new Array(pool.length).fill(0);
|
|
1866
|
+
for (const id of candidates) {
|
|
1867
|
+
if (set.size >= maxLive) break;
|
|
1868
|
+
if (set.has(id)) continue;
|
|
1869
|
+
const leaf = leafById.get(id);
|
|
1870
|
+
if (!leaf) continue;
|
|
1871
|
+
const o = typeof leaf.origin === "number" && leaf.origin < pool.length ? leaf.origin : 0;
|
|
1872
|
+
if (perOrigin[o] >= caps[o]) continue;
|
|
1873
|
+
set.add(id);
|
|
1874
|
+
perOrigin[o]++;
|
|
1875
|
+
}
|
|
1876
|
+
return set;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Geometry engine: pixel rects for every leaf and divider from the layout
|
|
1880
|
+
// tree. Panes and dividers render as absolutely-positioned siblings under
|
|
1881
|
+
// one stable container, keyed by leaf id at a constant tree position, so
|
|
1882
|
+
// structural changes (split/close/maximize) only move rects — React never
|
|
1883
|
+
// remounts a Pane and its iframe survives: split/close resize instead of
|
|
1884
|
+
// reloading, and maximize restore is instant.
|
|
1885
|
+
function computeLayout(node, x, y, w, h, out) {
|
|
1886
|
+
if (w <= 0 || h <= 0) return;
|
|
1887
|
+
if (node.kind === "leaf") {
|
|
1888
|
+
out.panes.push({ id: node.id, x, y, w, h });
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const horizontal = node.dir === "h";
|
|
1892
|
+
const span = horizontal ? w : h;
|
|
1893
|
+
const usable = Math.max(0, span - (node.children.length - 1) * DIVIDER_PX);
|
|
1894
|
+
let offset = 0;
|
|
1895
|
+
node.children.forEach((child, i) => {
|
|
1896
|
+
if (i > 0) {
|
|
1897
|
+
const pos = offset - DIVIDER_PX;
|
|
1898
|
+
out.dividers.push(horizontal
|
|
1899
|
+
? { key: node.id + "-d" + i, dir: node.dir, branchId: node.id, index: i - 1, usable, x: x + pos, y, w: DIVIDER_PX, h }
|
|
1900
|
+
: { key: node.id + "-d" + i, dir: node.dir, branchId: node.id, index: i - 1, usable, x, y: y + pos, w, h: DIVIDER_PX });
|
|
1901
|
+
}
|
|
1902
|
+
const size = Math.max(0, usable * node.fractions[i]);
|
|
1903
|
+
if (horizontal) computeLayout(child, x + offset, y, size, h, out);
|
|
1904
|
+
else computeLayout(child, x, y + offset, w, size, out);
|
|
1905
|
+
offset += size + DIVIDER_PX;
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
//#endregion
|
|
1909
|
+
|
|
1910
|
+
function SplitterRoot({ layout, panesReady, liveOrder, actions, handleCommand, pool, caps, maxLive, paneTitles, paneInfo, prefs }) {
|
|
1911
|
+
const snap = useSnap(layout);
|
|
1912
|
+
const readyMap = useSnap(panesReady);
|
|
1913
|
+
const order = useSnap(liveOrder);
|
|
1914
|
+
const titles = useSnap(paneTitles);
|
|
1915
|
+
const infoMap = useSnap(paneInfo);
|
|
1916
|
+
const prefsSnap = useSnap(prefs);
|
|
1917
|
+
// Preferences → render state: the focus color rides one CSS custom
|
|
1918
|
+
// property (the ::after outline and the title-bar tint read it);
|
|
1919
|
+
// "theme" delegates to the skin's brand token.
|
|
1920
|
+
const focusVar = prefsSnap.focusColor === "theme" ? "var(--dsw-alias-brand-primary,#4D6BFE)" : prefsSnap.focusColor;
|
|
1921
|
+
const labels = {
|
|
1922
|
+
splitRight: comboLabel(prefsSnap.shortcuts["split-right"]),
|
|
1923
|
+
splitDown: comboLabel(prefsSnap.shortcuts["split-down"]),
|
|
1924
|
+
close: comboLabel(prefsSnap.shortcuts["close"]),
|
|
1925
|
+
maximize: comboLabel(prefsSnap.shortcuts["maximize"])
|
|
1926
|
+
};
|
|
1927
|
+
const bodyRef = React.useRef(null);
|
|
1928
|
+
const [size, setSize] = React.useState(null);
|
|
1929
|
+
React.useEffect(() => {
|
|
1930
|
+
const el = bodyRef.current;
|
|
1931
|
+
if (!el) return;
|
|
1932
|
+
const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
|
|
1933
|
+
measure();
|
|
1934
|
+
const observer = new ResizeObserver(measure);
|
|
1935
|
+
observer.observe(el);
|
|
1936
|
+
return () => observer.disconnect();
|
|
1937
|
+
}, []);
|
|
1938
|
+
const maximizedTarget = snap.maximized ? findNode(snap.tree, snap.maximized) : null;
|
|
1939
|
+
// Geometry: home rects come from the full tree so panes hidden by
|
|
1940
|
+
// maximize keep their place (fade out/in instead of popping); the
|
|
1941
|
+
// maximized pane gets the whole body rect. Everything animates via
|
|
1942
|
+
// CSS transitions; divider drags disable them for direct tracking.
|
|
1943
|
+
const home = { panes: [], dividers: [] };
|
|
1944
|
+
if (size) computeLayout(snap.tree, 0, 0, size.w, size.h, home);
|
|
1945
|
+
const rectById = {};
|
|
1946
|
+
home.panes.forEach((p) => {
|
|
1947
|
+
rectById[p.id] = p;
|
|
1948
|
+
});
|
|
1949
|
+
const maximizedRect = size && maximizedTarget ? { x: 0, y: 0, w: size.w, h: size.h } : null;
|
|
1950
|
+
const allLeaves = collectLeaves(snap.tree, []);
|
|
1951
|
+
// Which panes are live vs hibernating (shared with the public
|
|
1952
|
+
// service — one computation, no drift).
|
|
1953
|
+
const liveSet = computeLiveSet(snap, order, pool, caps, maxLive);
|
|
1954
|
+
return React.createElement("div", {
|
|
1955
|
+
className: "dsh-split-root" + (prefsSnap.showTitleBar ? "" : " dsh-split-no-titlebar"),
|
|
1956
|
+
style: { "--dsh-split-focus": focusVar }
|
|
1957
|
+
},
|
|
1958
|
+
React.createElement("div", { className: "dsh-split-body", ref: bodyRef },
|
|
1959
|
+
allLeaves.map((leaf) => React.createElement(Pane, {
|
|
1960
|
+
key: leaf.id,
|
|
1961
|
+
leaf: leaf,
|
|
1962
|
+
focused: snap.focused === leaf.id,
|
|
1963
|
+
ready: readyMap[leaf.id] === true,
|
|
1964
|
+
rect: maximizedRect && snap.maximized === leaf.id ? maximizedRect : rectById[leaf.id] ?? null,
|
|
1965
|
+
hidden: maximizedTarget !== null && snap.maximized !== leaf.id,
|
|
1966
|
+
dormant: !liveSet.has(leaf.id),
|
|
1967
|
+
onWake: () => actions.wake(leaf.id),
|
|
1968
|
+
base: pool[typeof leaf.origin === "number" && leaf.origin < pool.length ? leaf.origin : 0],
|
|
1969
|
+
title: titles[leaf.id] ?? null,
|
|
1970
|
+
info: infoMap[leaf.id] ?? null,
|
|
1971
|
+
maximized: snap.maximized === leaf.id,
|
|
1972
|
+
actions: actions,
|
|
1973
|
+
labels: labels
|
|
1974
|
+
})),
|
|
1975
|
+
home.dividers.map((d) => React.createElement(Divider, {
|
|
1976
|
+
key: d.key,
|
|
1977
|
+
dir: d.dir,
|
|
1978
|
+
rect: d,
|
|
1979
|
+
usable: d.usable,
|
|
1980
|
+
hidden: maximizedTarget !== null,
|
|
1981
|
+
onNudge: (delta) => actions.nudge(d.branchId, d.index, delta)
|
|
1982
|
+
}))));
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const CSS = `
|
|
1986
|
+
.dsh-split-root{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base,#fff);}
|
|
1987
|
+
.dsh-split-body{flex:1 1 auto;min-height:0;position:relative;overflow:hidden;}
|
|
1988
|
+
.dsh-split-pane{position:absolute;display:flex;flex-direction:column;transition:left .18s cubic-bezier(.4,0,.2,1),top .18s cubic-bezier(.4,0,.2,1),width .18s cubic-bezier(.4,0,.2,1),height .18s cubic-bezier(.4,0,.2,1),opacity .16s ease;}
|
|
1989
|
+
.dsh-split-pane-hidden{opacity:0;pointer-events:none;}
|
|
1990
|
+
.dsh-split-pane-focused::after{content:"";position:absolute;inset:0;border:2px solid var(--dsh-split-focus,#4D6BFE);pointer-events:none;z-index:12;}
|
|
1991
|
+
.dsh-split-no-titlebar .dsh-split-titlebar{display:none;}
|
|
1992
|
+
.dsh-split-titlebar{flex:0 0 34px;display:flex;align-items:center;gap:8px;padding:0 6px 0 10px;background:linear-gradient(180deg,var(--dsw-alias-bg-layer-2,#f7f7f9),var(--dsw-alias-bg-layer-1,#f1f1f4));border-bottom:1px solid var(--dsw-alias-border-l1,#e2e2e8);user-select:none;cursor:default;min-width:0;}
|
|
1993
|
+
.dsh-split-titlebar-focused{background:linear-gradient(180deg,color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 13%,var(--dsw-alias-bg-layer-2,#f7f7f9)),color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 8%,var(--dsw-alias-bg-layer-1,#f1f1f4)));border-bottom-color:color-mix(in srgb,var(--dsh-split-focus,#4D6BFE) 35%,var(--dsw-alias-border-l1,#e2e2e8));}
|
|
1994
|
+
.dsh-split-title{flex:0 1 auto;min-width:88px;max-width:60%;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.01em;color:var(--dsw-alias-label-secondary,#6b6b72);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
1995
|
+
.dsh-split-titlebar-focused .dsh-split-title{color:var(--dsh-split-focus,#4D6BFE);font-weight:600;}
|
|
1996
|
+
.dsh-split-title-editable{cursor:text;}
|
|
1997
|
+
.dsh-split-title-editable:hover{color:var(--dsw-alias-label-primary,#3c3c43);}
|
|
1998
|
+
.dsh-split-titlebar-focused .dsh-split-title-editable:hover{color:var(--dsh-split-focus,#4D6BFE);}
|
|
1999
|
+
.dsh-split-title-edit{flex:1 1 auto;min-width:0;position:relative;display:inline-flex;align-items:center;}
|
|
2000
|
+
.dsh-split-title-input{flex:1 1 auto;min-width:0;width:100%;height:22px;padding:0 26px 0 7px;border-radius:6px;border:1px solid var(--dsh-split-focus,#4D6BFE);background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#2b2b31);font:500 12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;box-sizing:border-box;}
|
|
2001
|
+
.dsh-split-tb-btn.dsh-split-title-confirm{position:absolute;right:2px;top:50%;transform:translateY(-50%);width:20px;height:20px;color:var(--dsh-split-focus,#4D6BFE);}
|
|
2002
|
+
.dsh-split-tb-btn.dsh-split-tb-btn-ok,.dsh-split-tb-btn.dsh-split-tb-btn-ok:hover{color:#2fa36b;background:color-mix(in srgb,#2fa36b 12%,transparent);}
|
|
2003
|
+
.dsh-split-tb-actions{flex:0 0 auto;margin-left:auto;display:flex;align-items:center;gap:1px;padding-left:6px;border-left:1px solid var(--dsw-alias-border-l1,#e5e5ea);}
|
|
2004
|
+
.dsh-split-tb-btn{border:0;background:transparent;width:24px;height:24px;border-radius:6px;color:var(--dsw-alias-label-secondary,#6b6b72);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;flex:0 0 auto;transition:background .12s ease,color .12s ease,transform .05s ease;}
|
|
2005
|
+
.dsh-split-tb-btn svg{display:block;}
|
|
2006
|
+
.dsh-split-tb-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4D6BFE) 12%,transparent);color:var(--dsw-alias-brand-primary,#4D6BFE);}
|
|
2007
|
+
.dsh-split-tb-btn:active{transform:scale(.9);}
|
|
2008
|
+
.dsh-split-tb-btn-danger:hover{background:color-mix(in srgb,#e5484d 13%,transparent);color:#e5484d;}
|
|
2009
|
+
.dsh-split-pane-body{flex:1 1 auto;position:relative;min-height:0;min-width:0;}
|
|
2010
|
+
.dsh-split-iframe{position:absolute;inset:0;border:0;width:100%;height:100%;display:block;background:var(--dsw-alias-bg-base,#fff);}
|
|
2011
|
+
.dsh-split-divider{position:absolute;background:transparent;z-index:5;transition:left .18s cubic-bezier(.4,0,.2,1),top .18s cubic-bezier(.4,0,.2,1),width .18s cubic-bezier(.4,0,.2,1),height .18s cubic-bezier(.4,0,.2,1),opacity .16s ease,background .12s;}
|
|
2012
|
+
.dsh-split-divider-hidden{opacity:0;pointer-events:none;}
|
|
2013
|
+
.dsh-split-dragging .dsh-split-pane,.dsh-split-dragging .dsh-split-divider{transition:none;}
|
|
2014
|
+
.dsh-split-divider::before{content:"";position:absolute;pointer-events:none;}
|
|
2015
|
+
.dsh-split-divider-v{cursor:col-resize;}
|
|
2016
|
+
.dsh-split-divider-v::before{top:0;bottom:0;left:2px;width:1px;background:var(--dsw-alias-border-l1,#e5e5e5);}
|
|
2017
|
+
.dsh-split-divider-h{cursor:row-resize;}
|
|
2018
|
+
.dsh-split-divider-h::before{left:0;right:0;top:2px;height:1px;background:var(--dsw-alias-border-l1,#e5e5e5);}
|
|
2019
|
+
.dsh-split-divider:hover,.dsh-split-divider:active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4098ff) 18%,transparent);}
|
|
2020
|
+
.dsh-split-divider:hover::before,.dsh-split-divider:active::before{background:var(--dsw-alias-brand-primary,#4098ff);}
|
|
2021
|
+
.dsh-split-stale{position:absolute;top:6px;right:6px;z-index:3;font:11px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:var(--dsw-alias-label-primary,#333);background:var(--dsw-alias-bg-layer-1,#fafafa);border:1px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;padding:3px 8px;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.15);}
|
|
2022
|
+
.dsh-split-stale:hover{border-color:var(--dsw-alias-brand-primary,#4098ff);color:var(--dsw-alias-brand-primary,#4098ff);}
|
|
2023
|
+
.dsh-split-dormant{position:absolute;inset:0;border:0;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;background:var(--dsw-alias-bg-layer-1,#fafafa);color:var(--dsw-alias-label-secondary,#888);font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;transition:background .15s,color .15s;}
|
|
2024
|
+
.dsh-split-dormant:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4D6BFE) 7%,var(--dsw-alias-bg-layer-1,#fafafa));color:var(--dsw-alias-brand-primary,#4D6BFE);}
|
|
2025
|
+
.dsh-split-dormant-icon{font-size:22px;line-height:1;}
|
|
2026
|
+
.dsh-split-veil{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;background:var(--dsw-alias-bg-base,#fff);z-index:2;}
|
|
2027
|
+
.dsh-split-veil-hint{font-size:12px;color:var(--dsw-alias-text-l3,#999);user-select:none;}
|
|
2028
|
+
.dsh-split-spinner{width:22px;height:22px;border-radius:50%;border:3px solid var(--dsw-alias-border-l2,#ddd);border-top-color:var(--dsw-alias-brand-primary,#4098ff);animation:dsh-split-spin .9s linear infinite;}
|
|
2029
|
+
@keyframes dsh-split-spin{to{transform:rotate(360deg)}}
|
|
2030
|
+
`;
|
|
2031
|
+
|
|
2032
|
+
function applyTop(ctx) {
|
|
2033
|
+
const layout = runtime.createSnapshotStore(defaultLayout(), { persist: { name: LAYOUT_KEY } });
|
|
2034
|
+
// User preferences — the top window is the source of truth (it draws
|
|
2035
|
+
// everything the prefs control). The store rehydrates from
|
|
2036
|
+
// localStorage; normalize immediately so a partial/corrupt shape from
|
|
2037
|
+
// an older version can never reach the renderer or the key handler.
|
|
2038
|
+
const prefs = runtime.createSnapshotStore(normalizePrefs({}), { persist: { name: PREFS_KEY } });
|
|
2039
|
+
prefs.set(normalizePrefs(prefs.getSnapshot()));
|
|
2040
|
+
// Every pane WindowProxy ever heard from (any message registers it)
|
|
2041
|
+
// receives prefs broadcasts; dead frames no-op inside the try/catch.
|
|
2042
|
+
// Entries accumulate across pane reload cycles — cheap, and a stale
|
|
2043
|
+
// WindowProxy just swallows its postMessage.
|
|
2044
|
+
const panePeers = new Map();
|
|
2045
|
+
// Pane address book for the public service's top→pane control
|
|
2046
|
+
// messages (open-session / reload): leafId -> {source, origin},
|
|
2047
|
+
// refreshed by every well-formed pane message the bus receives.
|
|
2048
|
+
const paneWindows = new Map();
|
|
2049
|
+
const postToPane = (leafId, data) => {
|
|
2050
|
+
const win = paneWindows.get(leafId);
|
|
2051
|
+
if (!win) return false;
|
|
2052
|
+
try {
|
|
2053
|
+
win.source.postMessage(Object.assign({ type: MSG_TYPE }, data), win.origin);
|
|
2054
|
+
return true;
|
|
2055
|
+
} catch {
|
|
2056
|
+
// pane went away mid-flight — the layout-side update stands
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
prefs.subscribe(() => {
|
|
2061
|
+
const snap = prefs.getSnapshot();
|
|
2062
|
+
for (const peer of panePeers) {
|
|
2063
|
+
try {
|
|
2064
|
+
peer[0].postMessage({ type: MSG_TYPE, kind: "prefs-push", prefs: snap }, peer[1]);
|
|
2065
|
+
} catch {
|
|
2066
|
+
// pane is gone — nothing to sync
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
// Settings rows registered at the top talk straight to this store
|
|
2071
|
+
// (the top's settings modal is unreachable today — no AppFrame — but
|
|
2072
|
+
// keep the path honest in case the root shadow ever changes).
|
|
2073
|
+
applySettings(ctx, {
|
|
2074
|
+
getPrefs: () => prefs.getSnapshot(),
|
|
2075
|
+
subscribe: (fn) => prefs.subscribe(fn),
|
|
2076
|
+
savePrefs: (patch) => {
|
|
2077
|
+
const next = patchPrefs(prefs.getSnapshot(), patch);
|
|
2078
|
+
if (next) prefs.set(next);
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
const panesReady = runtime.createSnapshotStore({});
|
|
2082
|
+
// Origin pool: panes alternate between the two loopback origins so
|
|
2083
|
+
// more clients fit inside the browser's per-origin budget.
|
|
2084
|
+
const pool = originPool();
|
|
2085
|
+
const caps = pool.length > 1 ? ORIGIN_CAPS_MULTI : ORIGIN_CAPS_SINGLE;
|
|
2086
|
+
const maxLive = pool.length > 1 ? MAX_LIVE_PANES_MULTI : MAX_LIVE_PANES_SINGLE;
|
|
2087
|
+
const allowedOrigins = new Set(pool);
|
|
2088
|
+
// Per-pane session titles reported by the pane agents (transient UI
|
|
2089
|
+
// state — never persisted with the layout).
|
|
2090
|
+
const paneTitles = runtime.createSnapshotStore({});
|
|
2091
|
+
// Per-pane session facts reported alongside the title: blank (empty-log
|
|
2092
|
+
// sessions show the folder name and are not renamable) and realTitle
|
|
2093
|
+
// (the durable title that seeds the rename editor).
|
|
2094
|
+
const paneInfo = runtime.createSnapshotStore({});
|
|
2095
|
+
// leafId -> {until, title}. While set, inbound pane title reports are
|
|
2096
|
+
// ignored so a stale in-flight report cannot overwrite a rename the top
|
|
2097
|
+
// window just applied; a report matching the rename (or the deadline)
|
|
2098
|
+
// lifts the suppression.
|
|
2099
|
+
const titleSuppress = new Map();
|
|
2100
|
+
// Focus-recency order for the LRU live set (most recent last).
|
|
2101
|
+
const liveOrder = runtime.createSnapshotStore([]);
|
|
2102
|
+
const touchLive = (leafId) => liveOrder.update((d) => {
|
|
2103
|
+
const i = d.indexOf(leafId);
|
|
2104
|
+
if (i >= 0) d.splice(i, 1);
|
|
2105
|
+
d.push(leafId);
|
|
2106
|
+
});
|
|
2107
|
+
/** Transfer the browser focus to a pane's iframe (DOM lookup). Only
|
|
2108
|
+
* for layout actions the top window itself initiates — never for user
|
|
2109
|
+
* clicks, which the browser focuses naturally. */
|
|
2110
|
+
const focusPane = (leafId) => {
|
|
2111
|
+
if (!leafId) return;
|
|
2112
|
+
const frame = document.querySelector('.dsh-split-pane[data-leaf="' + leafId + '"] iframe');
|
|
2113
|
+
if (!frame) return;
|
|
2114
|
+
try {
|
|
2115
|
+
frame.focus();
|
|
2116
|
+
if (frame.contentWindow) frame.contentWindow.focus();
|
|
2117
|
+
} catch {
|
|
2118
|
+
// focus is best-effort
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
const actions = {
|
|
2122
|
+
split: (leafId, dir) => {
|
|
2123
|
+
// The new pane gets a FRESH session in the split pane's workspace
|
|
2124
|
+
// (or cwd): resolve the source's binding from the live list.
|
|
2125
|
+
const snapNow = layout.getSnapshot();
|
|
2126
|
+
const leaf = findNode(snapNow.tree, leafId);
|
|
2127
|
+
let inherit = null;
|
|
2128
|
+
if (leaf && leaf.kind === "leaf" && leaf.session) {
|
|
2129
|
+
const summary = ctx.sessions.list.getSnapshot().byId[leaf.session];
|
|
2130
|
+
if (summary && summary.cwd) inherit = { cwd: summary.cwd };
|
|
2131
|
+
const workspaces = ctx.get("workspaces");
|
|
2132
|
+
if (workspaces) {
|
|
2133
|
+
try {
|
|
2134
|
+
const items = workspaces.list.getSnapshot().items;
|
|
2135
|
+
const ws = items.find((w) => Array.isArray(w.sessionIds) && w.sessionIds.includes(leaf.session));
|
|
2136
|
+
if (ws && ws.workspaceId) inherit = { workspace: ws.workspaceId };
|
|
2137
|
+
} catch {
|
|
2138
|
+
// cwd inheritance is the fallback anyway
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
layout.update((d) => {
|
|
2143
|
+
splitLeaf(d, leafId, dir, inherit);
|
|
2144
|
+
// Give the new pane the least-loaded origin so the live
|
|
2145
|
+
// clients spread across the pool's per-origin budgets.
|
|
2146
|
+
const newLeaf = findNode(d.tree, d.focused);
|
|
2147
|
+
if (newLeaf && newLeaf.kind === "leaf") newLeaf.origin = pickOrigin(d, pool, caps);
|
|
2148
|
+
});
|
|
2149
|
+
touchLive(layout.getSnapshot().focused);
|
|
2150
|
+
},
|
|
2151
|
+
close: (leafId) => {
|
|
2152
|
+
// The survivor only needs a programmatic focus transfer when
|
|
2153
|
+
// the closed pane was the focused one (its iframe just went
|
|
2154
|
+
// away, taking the browser focus with it).
|
|
2155
|
+
const wasFocused = layout.getSnapshot().focused === leafId;
|
|
2156
|
+
layout.update((d) => closeLeaf(d, leafId));
|
|
2157
|
+
liveOrder.update((d) => {
|
|
2158
|
+
const i = d.indexOf(leafId);
|
|
2159
|
+
if (i >= 0) d.splice(i, 1);
|
|
2160
|
+
});
|
|
2161
|
+
if (wasFocused) focusPane(layout.getSnapshot().focused);
|
|
2162
|
+
},
|
|
2163
|
+
wake: (leafId) => {
|
|
2164
|
+
panesReady.update((d) => {
|
|
2165
|
+
d[leafId] = false;
|
|
2166
|
+
});
|
|
2167
|
+
touchLive(leafId);
|
|
2168
|
+
layout.update((d) => {
|
|
2169
|
+
d.focused = leafId;
|
|
2170
|
+
});
|
|
2171
|
+
},
|
|
2172
|
+
focus: (leafId) => layout.update((d) => {
|
|
2173
|
+
d.focused = leafId;
|
|
2174
|
+
}),
|
|
2175
|
+
session: (leafId, sessionId) => layout.update((d) => setLeafSession(d, leafId, sessionId)),
|
|
2176
|
+
nudge: (branchId, index, delta) => layout.update((d) => nudgeFractions(d, branchId, index, delta)),
|
|
2177
|
+
maximize: (leafId) => layout.update((d) => {
|
|
2178
|
+
d.maximized = d.maximized === leafId ? null : leafId;
|
|
2179
|
+
}),
|
|
2180
|
+
// Rename the pane's session from the title bar. The top window runs a
|
|
2181
|
+
// full client of its own, so it resolves the session binding directly
|
|
2182
|
+
// and issues the rename RPC; the pane re-reports the new title once
|
|
2183
|
+
// the backend settles it. Blank sessions (workspace-folder placeholder
|
|
2184
|
+
// title) are refused here as well as in the editor UI.
|
|
2185
|
+
rename: (leafId, nextTitle) => {
|
|
2186
|
+
const snapNow = layout.getSnapshot();
|
|
2187
|
+
const leaf = findNode(snapNow.tree, leafId);
|
|
2188
|
+
if (!leaf || leaf.kind !== "leaf" || !leaf.session) return;
|
|
2189
|
+
const info = paneInfo.getSnapshot()[leafId];
|
|
2190
|
+
if (info && info.blank) return;
|
|
2191
|
+
let session = null;
|
|
2192
|
+
try {
|
|
2193
|
+
const binding = ctx.sessions.binding(leaf.session);
|
|
2194
|
+
session = binding && binding.session;
|
|
2195
|
+
} catch {
|
|
2196
|
+
session = null;
|
|
2197
|
+
}
|
|
2198
|
+
if (!session) return;
|
|
2199
|
+
const prevTitle = paneTitles.getSnapshot()[leafId] ?? null;
|
|
2200
|
+
paneTitles.update((d) => {
|
|
2201
|
+
d[leafId] = nextTitle;
|
|
2202
|
+
});
|
|
2203
|
+
titleSuppress.set(leafId, { until: Date.now() + 5000, title: nextTitle });
|
|
2204
|
+
Promise.resolve(session.rename(nextTitle)).then((result) => {
|
|
2205
|
+
if (!result || result.ok !== true) {
|
|
2206
|
+
titleSuppress.delete(leafId);
|
|
2207
|
+
paneTitles.update((d) => {
|
|
2208
|
+
d[leafId] = prevTitle;
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
}).catch(() => {
|
|
2212
|
+
titleSuppress.delete(leafId);
|
|
2213
|
+
paneTitles.update((d) => {
|
|
2214
|
+
d[leafId] = prevTitle;
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
// ── Public service ────────────────────────────────────────────────
|
|
2220
|
+
// Other plugins reach this through `inject: ["dsh-split-view"]` or
|
|
2221
|
+
// `ctx.get("dsh-split-view")`. It is provided ONLY in the top window
|
|
2222
|
+
// (panes run their own cordis trees), and shaped like this:
|
|
2223
|
+
//
|
|
2224
|
+
// - Pane addressing is one optional id everywhere: omitted/null
|
|
2225
|
+
// targets the focused pane; unknown ids throw. Payload verbs take
|
|
2226
|
+
// the target through `{pane}`; target-only verbs take the paneId
|
|
2227
|
+
// positionally.
|
|
2228
|
+
// - Session verbs resolve the pane's session and execute it in the
|
|
2229
|
+
// top window's own client (sessions.binding → SessionFace). The
|
|
2230
|
+
// session is backend-hosted — the pane is just its display — so
|
|
2231
|
+
// the pane picks the turn up through the host stream, dormant
|
|
2232
|
+
// panes included. Only "which session a pane shows" (open /
|
|
2233
|
+
// newSession / reload) crosses the postMessage boundary.
|
|
2234
|
+
// - Mutators are async, queries are sync snapshots, subscribe()
|
|
2235
|
+
// streams the merged pane state, and failures throw
|
|
2236
|
+
// Error("dsh-split-view: …").
|
|
2237
|
+
const fail = (message) => {
|
|
2238
|
+
throw new Error("dsh-split-view: " + message);
|
|
2239
|
+
};
|
|
2240
|
+
/** Resolve an optional pane target: absent → focused pane; explicit
|
|
2241
|
+
* ids must name a leaf and are validated loud. */
|
|
2242
|
+
const resolvePane = (paneId) => {
|
|
2243
|
+
const snap = layout.getSnapshot();
|
|
2244
|
+
const id = paneId === void 0 || paneId === null ? snap.focused : paneId;
|
|
2245
|
+
const node = typeof id === "string" && id.length > 0 ? findNode(snap.tree, id) : null;
|
|
2246
|
+
if (!node || node.kind !== "leaf") fail("no such pane: " + String(id));
|
|
2247
|
+
return id;
|
|
2248
|
+
};
|
|
2249
|
+
const paneOfOptions = (options) => (options && typeof options === "object" ? options.pane : void 0);
|
|
2250
|
+
/** The session the pane currently shows (its last report), or a loud
|
|
2251
|
+
* error when the pane has none yet. */
|
|
2252
|
+
const paneSession = (leafId) => {
|
|
2253
|
+
const leaf = findNode(layout.getSnapshot().tree, leafId);
|
|
2254
|
+
const sid = leaf && typeof leaf.session === "string" && leaf.session.length > 0 ? leaf.session : null;
|
|
2255
|
+
if (sid === null) fail("the pane has no session yet (it may still be booting — pass prompt to split(), or retry once the pane reports its session)");
|
|
2256
|
+
return sid;
|
|
2257
|
+
};
|
|
2258
|
+
const bindSession = (sessionId) => {
|
|
2259
|
+
let binding = null;
|
|
2260
|
+
try {
|
|
2261
|
+
binding = ctx.sessions.binding(sessionId);
|
|
2262
|
+
} catch {
|
|
2263
|
+
binding = null;
|
|
2264
|
+
}
|
|
2265
|
+
if (!binding || !binding.session) fail("cannot bind session " + sessionId);
|
|
2266
|
+
return binding.session;
|
|
2267
|
+
};
|
|
2268
|
+
const bindPaneSession = (leafId) => {
|
|
2269
|
+
const sid = paneSession(leafId);
|
|
2270
|
+
return { sid, session: bindSession(sid) };
|
|
2271
|
+
};
|
|
2272
|
+
const liveSetOf = () => computeLiveSet(layout.getSnapshot(), liveOrder.getSnapshot(), pool, caps, maxLive);
|
|
2273
|
+
/** Public pane descriptors: one entry per leaf, render-state included. */
|
|
2274
|
+
const describePanes = () => {
|
|
2275
|
+
const snap = layout.getSnapshot();
|
|
2276
|
+
const live = computeLiveSet(snap, liveOrder.getSnapshot(), pool, caps, maxLive);
|
|
2277
|
+
const titles = paneTitles.getSnapshot();
|
|
2278
|
+
const readyMap = panesReady.getSnapshot();
|
|
2279
|
+
return collectLeaves(snap.tree, []).map((leaf) => ({
|
|
2280
|
+
id: leaf.id,
|
|
2281
|
+
session: typeof leaf.session === "string" ? leaf.session : null,
|
|
2282
|
+
title: titles[leaf.id] ?? null,
|
|
2283
|
+
focused: snap.focused === leaf.id,
|
|
2284
|
+
maximized: snap.maximized === leaf.id,
|
|
2285
|
+
ready: readyMap[leaf.id] === true,
|
|
2286
|
+
dormant: !live.has(leaf.id)
|
|
2287
|
+
}));
|
|
2288
|
+
};
|
|
2289
|
+
const assertRpc = (result, what) => {
|
|
2290
|
+
if (result && result.ok === false) {
|
|
2291
|
+
const e = result.error;
|
|
2292
|
+
fail(what + " failed: " + (e ? (e.message || e.code) : "unknown"));
|
|
2293
|
+
}
|
|
2294
|
+
return result;
|
|
2295
|
+
};
|
|
2296
|
+
/** Run the mode/model/title/prompt configuration pass on an already
|
|
2297
|
+
* created session (the fixed order split() has always used).
|
|
2298
|
+
* `strict` decides whether an unresolvable binding throws or is
|
|
2299
|
+
* skipped — split() keeps its historic tolerance, newSession() does
|
|
2300
|
+
* not. */
|
|
2301
|
+
const configureSession = async (sessionId, opts, strict) => {
|
|
2302
|
+
let binding = null;
|
|
2303
|
+
try {
|
|
2304
|
+
binding = ctx.sessions.binding(sessionId);
|
|
2305
|
+
} catch {
|
|
2306
|
+
binding = null;
|
|
2307
|
+
}
|
|
2308
|
+
const session = binding && binding.session;
|
|
2309
|
+
if (!session) {
|
|
2310
|
+
if (strict) fail("cannot bind session " + sessionId);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (opts.mode !== null) {
|
|
2314
|
+
const res = await session.api.agentPresets.select({ sessionId, agentPreset: opts.mode });
|
|
2315
|
+
if (res && res.result && res.result.ok === false) {
|
|
2316
|
+
const e = res.result.error;
|
|
2317
|
+
fail("preset select failed: " + (e ? (e.message || e.code) : "unknown"));
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (opts.model !== null) {
|
|
2321
|
+
const res = await session.api.sessions.selectModel({
|
|
2322
|
+
sessionId,
|
|
2323
|
+
provider: opts.model.provider,
|
|
2324
|
+
model: opts.model.model,
|
|
2325
|
+
...(opts.model.reasoningEffort !== void 0 ? { reasoningEffort: opts.model.reasoningEffort } : {})
|
|
2326
|
+
});
|
|
2327
|
+
if (res && res.result && res.result.ok === false) {
|
|
2328
|
+
const e = res.result.error;
|
|
2329
|
+
fail("model select failed: " + (e ? (e.message || e.code) : "unknown"));
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (opts.title !== null) await session.rename(opts.title);
|
|
2333
|
+
if (opts.prompt !== null) assertRpc(await session.prompt([{ type: "text", text: opts.prompt }], "queue"), "prompt");
|
|
2334
|
+
};
|
|
2335
|
+
const createConfiguredSession = async (opts, strict) => {
|
|
2336
|
+
const createOpts = opts.workspaceId !== null ? { workspaceId: opts.workspaceId } : opts.cwd !== null ? { cwd: opts.cwd } : {};
|
|
2337
|
+
const sessionId = await ctx.sessions.create(createOpts);
|
|
2338
|
+
await configureSession(sessionId, opts, strict);
|
|
2339
|
+
return sessionId;
|
|
2340
|
+
};
|
|
2341
|
+
/** Point a pane at a session: update the layout seed (persistence +
|
|
2342
|
+
* dormant-boot truth), then swap the shown session live when the
|
|
2343
|
+
* pane is up, or pull it into the live set when it is hibernating. */
|
|
2344
|
+
const repointPane = (leafId, sessionId) => {
|
|
2345
|
+
layout.update((d) => {
|
|
2346
|
+
const leaf = findNode(d.tree, leafId);
|
|
2347
|
+
if (leaf && leaf.kind === "leaf") {
|
|
2348
|
+
leaf.session = sessionId;
|
|
2349
|
+
delete leaf.workspace;
|
|
2350
|
+
delete leaf.cwd;
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
if (liveSetOf().has(leafId)) postToPane(leafId, { kind: "open-session", sessionId });
|
|
2354
|
+
else touchLive(leafId);
|
|
2355
|
+
};
|
|
2356
|
+
/**
|
|
2357
|
+
* Split a pane and open the new pane in a fresh session (or pin an
|
|
2358
|
+
* existing one via options.session). Every option is optional and
|
|
2359
|
+
* defaults to a plain new session in the default working directory.
|
|
2360
|
+
* @param {object} [options]
|
|
2361
|
+
* @param {string} [options.pane] pane to split from (defaults to the focused pane)
|
|
2362
|
+
* @param {"right"|"down"} [options.direction="right"]
|
|
2363
|
+
* @param {string} [options.prompt] first user message (sent as turn one)
|
|
2364
|
+
* @param {string} [options.title] session title (renamed after create)
|
|
2365
|
+
* @param {string|{provider:string,model:string,reasoningEffort?:string}} [options.model]
|
|
2366
|
+
* @param {string} [options.workspaceId]
|
|
2367
|
+
* @param {string} [options.cwd]
|
|
2368
|
+
* @param {string} [options.mode] agent-preset id
|
|
2369
|
+
* @param {string} [options.session] pin an EXISTING session into the new pane (skips create/configure)
|
|
2370
|
+
* @returns {Promise<{paneId: string|null, leafId: string|null, sessionId: string|null}>}
|
|
2371
|
+
*/
|
|
2372
|
+
const splitImpl = async (options) => {
|
|
2373
|
+
const opts = normalizeSplitOptions(options);
|
|
2374
|
+
const snap = layout.getSnapshot();
|
|
2375
|
+
let target;
|
|
2376
|
+
if (opts.pane !== null) {
|
|
2377
|
+
const anchor = findNode(snap.tree, opts.pane);
|
|
2378
|
+
if (!anchor || anchor.kind !== "leaf") fail("no such pane: " + opts.pane);
|
|
2379
|
+
target = opts.pane;
|
|
2380
|
+
} else {
|
|
2381
|
+
target = snap.focused;
|
|
2382
|
+
}
|
|
2383
|
+
if (!target) fail("no focused pane to split");
|
|
2384
|
+
let sessionId = null;
|
|
2385
|
+
let seed = null;
|
|
2386
|
+
if (opts.session !== null) {
|
|
2387
|
+
// Pin an existing session into the new pane (e.g. an
|
|
2388
|
+
// agent-teams member child session): no create/configure,
|
|
2389
|
+
// the pane seeds straight from the given session id.
|
|
2390
|
+
sessionId = opts.session;
|
|
2391
|
+
seed = { session: opts.session };
|
|
2392
|
+
}
|
|
2393
|
+
const wantsCustom = sessionId === null && (opts.workspaceId !== null || opts.cwd !== null || opts.prompt !== null || opts.title !== null || opts.model !== null || opts.mode !== null);
|
|
2394
|
+
if (wantsCustom) {
|
|
2395
|
+
sessionId = await createConfiguredSession(opts, false);
|
|
2396
|
+
seed = { session: sessionId };
|
|
2397
|
+
}
|
|
2398
|
+
let newLeafId = null;
|
|
2399
|
+
layout.update((d) => {
|
|
2400
|
+
splitLeaf(d, target, opts.direction, seed);
|
|
2401
|
+
const newLeaf = findNode(d.tree, d.focused);
|
|
2402
|
+
if (newLeaf && newLeaf.kind === "leaf") {
|
|
2403
|
+
newLeaf.origin = pickOrigin(d, pool, caps);
|
|
2404
|
+
newLeafId = newLeaf.id;
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2407
|
+
touchLive(layout.getSnapshot().focused);
|
|
2408
|
+
// `leafId` keeps the pre-rename field for existing callers.
|
|
2409
|
+
return { paneId: newLeafId, leafId: newLeafId, sessionId };
|
|
2410
|
+
};
|
|
2411
|
+
const api = {
|
|
2412
|
+
/** Service-surface generation: feature-detect new members with this. */
|
|
2413
|
+
version: 2,
|
|
2414
|
+
|
|
2415
|
+
// ── Introspection (sync snapshots; tolerant — unknown ids → null) ──
|
|
2416
|
+
/** Every pane, tree order: {id, session, title, focused, maximized, ready, dormant}. */
|
|
2417
|
+
panes: () => describePanes(),
|
|
2418
|
+
/** One pane's descriptor (focused pane when omitted), or null. */
|
|
2419
|
+
pane: (paneId) => {
|
|
2420
|
+
const snap = layout.getSnapshot();
|
|
2421
|
+
const id = paneId === void 0 || paneId === null ? snap.focused : paneId;
|
|
2422
|
+
if (typeof id !== "string" || !findNode(snap.tree, id)) return null;
|
|
2423
|
+
return describePanes().find((p) => p.id === id) ?? null;
|
|
2424
|
+
},
|
|
2425
|
+
/** The focused pane id, or null. */
|
|
2426
|
+
focused: () => layout.getSnapshot().focused ?? null,
|
|
2427
|
+
/** Deep copy of the raw layout state {tree, focused, maximized} —
|
|
2428
|
+
* the input shape applyLayout() accepts. */
|
|
2429
|
+
layout: () => JSON.parse(JSON.stringify(layout.getSnapshot())),
|
|
2430
|
+
|
|
2431
|
+
// ── Pane lifecycle (paneId optional → focused pane) ──
|
|
2432
|
+
split: splitImpl,
|
|
2433
|
+
/** Close a pane; the adjacent sibling inherits the space and focus. */
|
|
2434
|
+
close: async (paneId) => {
|
|
2435
|
+
const id = resolvePane(paneId);
|
|
2436
|
+
actions.close(id);
|
|
2437
|
+
return { closed: id, focused: layout.getSnapshot().focused ?? null };
|
|
2438
|
+
},
|
|
2439
|
+
/** Focus a pane (layout focus + browser focus transfer). */
|
|
2440
|
+
focus: async (paneId) => {
|
|
2441
|
+
const id = resolvePane(paneId);
|
|
2442
|
+
actions.focus(id);
|
|
2443
|
+
touchLive(id);
|
|
2444
|
+
focusPane(id);
|
|
2445
|
+
return { focused: id };
|
|
2446
|
+
},
|
|
2447
|
+
/** Maximize a pane: maximize(paneId?, true/false) sets explicitly,
|
|
2448
|
+
* maximize(paneId?) toggles. */
|
|
2449
|
+
maximize: async (paneId, on) => {
|
|
2450
|
+
const id = resolvePane(paneId);
|
|
2451
|
+
layout.update((d) => {
|
|
2452
|
+
if (on === true) d.maximized = id;
|
|
2453
|
+
else if (on === false) d.maximized = null;
|
|
2454
|
+
else d.maximized = d.maximized === id ? null : id;
|
|
2455
|
+
});
|
|
2456
|
+
return { maximized: layout.getSnapshot().maximized ?? null };
|
|
2457
|
+
},
|
|
2458
|
+
/** Restore from maximize. */
|
|
2459
|
+
restore: async () => {
|
|
2460
|
+
layout.update((d) => {
|
|
2461
|
+
d.maximized = null;
|
|
2462
|
+
});
|
|
2463
|
+
return { maximized: null };
|
|
2464
|
+
},
|
|
2465
|
+
/** Wake a hibernating pane (boots its iframe back into the live set). */
|
|
2466
|
+
wake: async (paneId) => {
|
|
2467
|
+
const id = resolvePane(paneId);
|
|
2468
|
+
actions.wake(id);
|
|
2469
|
+
return { paneId: id };
|
|
2470
|
+
},
|
|
2471
|
+
/** Reload a pane's client (live panes reload in place; hibernating
|
|
2472
|
+
* ones wake, which boots them). */
|
|
2473
|
+
reload: async (paneId) => {
|
|
2474
|
+
const id = resolvePane(paneId);
|
|
2475
|
+
if (!liveSetOf().has(id) || !postToPane(id, { kind: "reload" })) actions.wake(id);
|
|
2476
|
+
return { paneId: id };
|
|
2477
|
+
},
|
|
2478
|
+
/** Replace the whole layout tree (validated): the escape hatch
|
|
2479
|
+
* for moves, resizes and rearrangements the verb API does not
|
|
2480
|
+
* cover. Reused leaf ids keep their panes (iframes survive);
|
|
2481
|
+
* new ids boot fresh panes. See layout() for the shape. */
|
|
2482
|
+
applyLayout: async (tree) => {
|
|
2483
|
+
const norm = normalizeLayoutTree(tree);
|
|
2484
|
+
layout.update((d) => {
|
|
2485
|
+
d.tree = norm;
|
|
2486
|
+
d.focused = firstLeafId(norm);
|
|
2487
|
+
d.maximized = null;
|
|
2488
|
+
for (const leaf of collectLeaves(d.tree, [])) {
|
|
2489
|
+
if (!(typeof leaf.origin === "number" && leaf.origin < pool.length)) leaf.origin = pickOrigin(d, pool, caps);
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
return { panes: describePanes() };
|
|
2493
|
+
},
|
|
2494
|
+
|
|
2495
|
+
// ── Pane × session ──
|
|
2496
|
+
/**
|
|
2497
|
+
* Show an existing session in a pane — live swap when the pane is
|
|
2498
|
+
* up, boot seed when it is hibernating.
|
|
2499
|
+
* @param {string} sessionId
|
|
2500
|
+
* @param {{pane?: string}} [options]
|
|
2501
|
+
*/
|
|
2502
|
+
open: async (sessionId, options) => {
|
|
2503
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) fail("open needs a session id");
|
|
2504
|
+
const id = resolvePane(paneOfOptions(options));
|
|
2505
|
+
const listSnap = ctx.sessions.list.getSnapshot();
|
|
2506
|
+
// Skip validation while the list baseline is in flight: the
|
|
2507
|
+
// pane agent gates its seed the same way and falls back
|
|
2508
|
+
// gracefully.
|
|
2509
|
+
if (listSnap.phase !== "pending" && !listSnap.byId[sessionId]) fail("unknown session: " + sessionId);
|
|
2510
|
+
repointPane(id, sessionId);
|
|
2511
|
+
return { paneId: id, sessionId };
|
|
2512
|
+
},
|
|
2513
|
+
/**
|
|
2514
|
+
* Create a fresh session and show it in a pane (replaces the
|
|
2515
|
+
* pane's current session). Same session options as split().
|
|
2516
|
+
* @param {{pane?: string, workspaceId?: string, cwd?: string, title?: string, model?: string|object, mode?: string, prompt?: string}} [options]
|
|
2517
|
+
*/
|
|
2518
|
+
newSession: async (options) => {
|
|
2519
|
+
const o = options && typeof options === "object" ? options : {};
|
|
2520
|
+
const id = resolvePane(o.pane);
|
|
2521
|
+
const opts = normalizeSplitOptions(o);
|
|
2522
|
+
const sessionId = await createConfiguredSession(opts, true);
|
|
2523
|
+
repointPane(id, sessionId);
|
|
2524
|
+
return { paneId: id, sessionId };
|
|
2525
|
+
},
|
|
2526
|
+
/**
|
|
2527
|
+
* Send a user message to the session a pane shows. Executes in
|
|
2528
|
+
* the top window's client — the pane renders the turn through
|
|
2529
|
+
* the host stream, whether live or hibernating.
|
|
2530
|
+
* @param {string|Array<{type: string, text?: string}>} content text or prompt content parts
|
|
2531
|
+
* @param {{pane?: string, mode?: "queue"|"steer"}} [options] queue (default) appends a turn; steer interrupts the running one
|
|
2532
|
+
*/
|
|
2533
|
+
send: async (content, options) => {
|
|
2534
|
+
const id = resolvePane(paneOfOptions(options));
|
|
2535
|
+
const blocks = typeof content === "string"
|
|
2536
|
+
? (content.length > 0 ? [{ type: "text", text: content }] : null)
|
|
2537
|
+
: (Array.isArray(content) && content.length > 0 ? content : null);
|
|
2538
|
+
if (blocks === null) fail("send needs a non-empty string or content-part array");
|
|
2539
|
+
const mode = options && options.mode === "steer" ? "steer" : "queue";
|
|
2540
|
+
const { sid, session } = bindPaneSession(id);
|
|
2541
|
+
assertRpc(await session.prompt(blocks, mode), "prompt");
|
|
2542
|
+
return { paneId: id, sessionId: sid };
|
|
2543
|
+
},
|
|
2544
|
+
/** Rename the session a pane shows (blank sessions are refused,
|
|
2545
|
+
* like the title-bar editor). */
|
|
2546
|
+
rename: async (title, options) => {
|
|
2547
|
+
const id = resolvePane(paneOfOptions(options));
|
|
2548
|
+
const next = typeof title === "string" ? title.trim() : "";
|
|
2549
|
+
if (next.length === 0) fail("rename needs a non-empty title");
|
|
2550
|
+
const sid = paneSession(id);
|
|
2551
|
+
const info = paneInfo.getSnapshot()[id];
|
|
2552
|
+
if (info && info.blank) fail("blank sessions are not renamable (their title is the workspace folder name)");
|
|
2553
|
+
bindSession(sid);
|
|
2554
|
+
actions.rename(id, next);
|
|
2555
|
+
return { paneId: id, sessionId: sid, title: next };
|
|
2556
|
+
},
|
|
2557
|
+
/** Change the model and/or agent preset of the session a pane
|
|
2558
|
+
* shows. @param {{model?: string|object, mode?: string}} patch */
|
|
2559
|
+
configure: async (patch, options) => {
|
|
2560
|
+
const id = resolvePane(paneOfOptions(options));
|
|
2561
|
+
const p = patch && typeof patch === "object" ? patch : {};
|
|
2562
|
+
const mode = str(p.mode);
|
|
2563
|
+
const model = normalizeModel(p.model);
|
|
2564
|
+
if (mode === null && model === null) fail("configure needs a mode and/or a model");
|
|
2565
|
+
const { sid, session } = bindPaneSession(id);
|
|
2566
|
+
if (mode !== null) {
|
|
2567
|
+
const res = await session.api.agentPresets.select({ sessionId: sid, agentPreset: mode });
|
|
2568
|
+
if (res && res.result && res.result.ok === false) {
|
|
2569
|
+
const e = res.result.error;
|
|
2570
|
+
fail("preset select failed: " + (e ? (e.message || e.code) : "unknown"));
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (model !== null) {
|
|
2574
|
+
const res = await session.api.sessions.selectModel({
|
|
2575
|
+
sessionId: sid,
|
|
2576
|
+
provider: model.provider,
|
|
2577
|
+
model: model.model,
|
|
2578
|
+
...(model.reasoningEffort !== void 0 ? { reasoningEffort: model.reasoningEffort } : {})
|
|
2579
|
+
});
|
|
2580
|
+
if (res && res.result && res.result.ok === false) {
|
|
2581
|
+
const e = res.result.error;
|
|
2582
|
+
fail("model select failed: " + (e ? (e.message || e.code) : "unknown"));
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
return { paneId: id, sessionId: sid };
|
|
2586
|
+
},
|
|
2587
|
+
/** Cancel the running turn of the session a pane shows. */
|
|
2588
|
+
cancel: async (paneId) => {
|
|
2589
|
+
const id = resolvePane(paneId);
|
|
2590
|
+
const { sid, session } = bindPaneSession(id);
|
|
2591
|
+
assertRpc(await session.cancel(), "cancel");
|
|
2592
|
+
return { paneId: id, sessionId: sid };
|
|
2593
|
+
},
|
|
2594
|
+
|
|
2595
|
+
// ── Observation ──
|
|
2596
|
+
/**
|
|
2597
|
+
* Subscribe to pane-state changes (structure, focus, sessions,
|
|
2598
|
+
* titles, ready/dormant). The listener receives the same shape
|
|
2599
|
+
* panes() describes, wrapped as {panes, focused, maximized};
|
|
2600
|
+
* duplicate states are coalesced. Returns the unsubscribe fn.
|
|
2601
|
+
*/
|
|
2602
|
+
subscribe: (listener) => {
|
|
2603
|
+
if (typeof listener !== "function") fail("subscribe needs a listener function");
|
|
2604
|
+
let last = null;
|
|
2605
|
+
const emit = () => {
|
|
2606
|
+
const snap = layout.getSnapshot();
|
|
2607
|
+
const state = {
|
|
2608
|
+
panes: describePanes(),
|
|
2609
|
+
focused: snap.focused ?? null,
|
|
2610
|
+
maximized: snap.maximized ?? null
|
|
2611
|
+
};
|
|
2612
|
+
const key = JSON.stringify(state);
|
|
2613
|
+
if (key === last) return;
|
|
2614
|
+
last = key;
|
|
2615
|
+
try {
|
|
2616
|
+
listener(state);
|
|
2617
|
+
} catch {
|
|
2618
|
+
// listener errors must not break the store bus
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
2621
|
+
const offs = [
|
|
2622
|
+
layout.subscribe(emit),
|
|
2623
|
+
panesReady.subscribe(emit),
|
|
2624
|
+
liveOrder.subscribe(emit),
|
|
2625
|
+
paneTitles.subscribe(emit)
|
|
2626
|
+
];
|
|
2627
|
+
return () => {
|
|
2628
|
+
for (const off of offs) {
|
|
2629
|
+
try {
|
|
2630
|
+
off();
|
|
2631
|
+
} catch {
|
|
2632
|
+
// already disposed
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
ctx.provide("dsh-split-view", api);
|
|
2639
|
+
// Dev hook: with localStorage `dsh-split:debug` set, mirror the
|
|
2640
|
+
// service on window.__dshSplitView so the API can be exercised from
|
|
2641
|
+
// devtools. Off by default — other plugins keep using ctx.get/inject.
|
|
2642
|
+
try {
|
|
2643
|
+
if (window.localStorage.getItem("dsh-split:debug")) window.__dshSplitView = api;
|
|
2644
|
+
} catch {
|
|
2645
|
+
// storage unavailable — the hook stays off
|
|
2646
|
+
}
|
|
2647
|
+
// TEMP(agent-team-demo): auto-pin agent-teams member sessions into panes
|
|
2648
|
+
// on top-level boot. Remove once the dedicated dsh-agent-team plugin lands.
|
|
2649
|
+
(() => {
|
|
2650
|
+
if (window.__dshMemberDemoScheduled) return;
|
|
2651
|
+
window.__dshMemberDemoScheduled = true;
|
|
2652
|
+
const KEY = "dsh-split:memberPanes";
|
|
2653
|
+
const TRACE = "dsh-split:demoTrace";
|
|
2654
|
+
const trace = (m) => { try { localStorage.setItem(TRACE, (localStorage.getItem(TRACE) || "").slice(-400) + "\n" + new Date().toISOString().slice(11,19) + " " + m); } catch (e) {} };
|
|
2655
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2656
|
+
const run = async () => {
|
|
2657
|
+
try {
|
|
2658
|
+
trace("start iframes=" + document.querySelectorAll("iframe").length);
|
|
2659
|
+
const res = await fetch("/plugins/dsh-agent-teams/state", { cache: "no-store" });
|
|
2660
|
+
if (!res.ok) { trace("state route not ok: " + res.status); return; }
|
|
2661
|
+
const data = await res.json();
|
|
2662
|
+
const teams = (data && data.teams) || [];
|
|
2663
|
+
trace("fetched teams=" + teams.length);
|
|
2664
|
+
const pinned = new Set(JSON.parse(localStorage.getItem(KEY) || "[]"));
|
|
2665
|
+
let changed = false;
|
|
2666
|
+
for (const team of teams) {
|
|
2667
|
+
for (const m of team.members || []) {
|
|
2668
|
+
if (!m || !m.id || pinned.has(m.id)) continue;
|
|
2669
|
+
trace("split before " + m.id.slice(0, 8));
|
|
2670
|
+
try {
|
|
2671
|
+
const out = await splitImpl({ session: m.id, direction: "right" });
|
|
2672
|
+
trace("split after leaf=" + (out && out.leafId));
|
|
2673
|
+
if (out && out.leafId) { pinned.add(m.id); changed = true; await sleep(1200); }
|
|
2674
|
+
} catch (e) { trace("split err: " + (e && e.message)); }
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
if (changed) localStorage.setItem(KEY, JSON.stringify([...pinned]));
|
|
2678
|
+
trace("done iframes=" + document.querySelectorAll("iframe").length);
|
|
2679
|
+
} catch (e) { trace("outer err: " + (e && e.message)); }
|
|
2680
|
+
};
|
|
2681
|
+
// TEMP(agent-team-demo): 6s delay lets boot/layout restore settle first.
|
|
2682
|
+
setTimeout(run, 6000);
|
|
2683
|
+
})();
|
|
2684
|
+
const handleCommand = (cmd, paneId) => {
|
|
2685
|
+
const snap = layout.getSnapshot();
|
|
2686
|
+
const target = paneId && findNode(snap.tree, paneId) ? paneId : snap.focused;
|
|
2687
|
+
if (!target) return;
|
|
2688
|
+
if (cmd === "split-right") actions.split(target, "h");
|
|
2689
|
+
else if (cmd === "split-down") actions.split(target, "v");
|
|
2690
|
+
else if (cmd === "close") actions.close(target);
|
|
2691
|
+
else if (cmd === "maximize") actions.maximize(target);
|
|
2692
|
+
};
|
|
2693
|
+
ctx.effect(() => {
|
|
2694
|
+
const onMessage = (event) => {
|
|
2695
|
+
// Panes may live on the alternate loopback origin (origin pool).
|
|
2696
|
+
if (!allowedOrigins.has(event.origin)) return;
|
|
2697
|
+
const data = event.data;
|
|
2698
|
+
if (!data || data.type !== MSG_TYPE || typeof data.pane !== "string") return;
|
|
2699
|
+
// Any well-formed pane message registers the sender as a prefs
|
|
2700
|
+
// broadcast peer (window-level, cross-origin safe).
|
|
2701
|
+
panePeers.set(event.source, event.origin);
|
|
2702
|
+
// …and refreshes the pane address book (top→pane control channel).
|
|
2703
|
+
paneWindows.set(data.pane, { source: event.source, origin: event.origin });
|
|
2704
|
+
if (data.kind === "session") {
|
|
2705
|
+
actions.session(data.pane, typeof data.sessionId === "string" ? data.sessionId : null);
|
|
2706
|
+
paneInfo.update((d) => {
|
|
2707
|
+
d[data.pane] = {
|
|
2708
|
+
blank: data.blank === true,
|
|
2709
|
+
realTitle: typeof data.realTitle === "string" ? data.realTitle : null
|
|
2710
|
+
};
|
|
2711
|
+
});
|
|
2712
|
+
// A rename just applied at the top suppresses stale title reports
|
|
2713
|
+
// until the pane catches up (its report matches the new title)
|
|
2714
|
+
// or the suppression window expires.
|
|
2715
|
+
const sup = titleSuppress.get(data.pane);
|
|
2716
|
+
if (sup !== void 0) {
|
|
2717
|
+
if ((typeof data.title === "string" && data.title === sup.title) || Date.now() >= sup.until) titleSuppress.delete(data.pane);
|
|
2718
|
+
}
|
|
2719
|
+
if (!titleSuppress.has(data.pane)) {
|
|
2720
|
+
paneTitles.update((d) => {
|
|
2721
|
+
d[data.pane] = typeof data.title === "string" && data.title.length > 0 ? data.title : null;
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
panesReady.update((d) => {
|
|
2725
|
+
d[data.pane] = true;
|
|
2726
|
+
});
|
|
2727
|
+
touchLive(data.pane);
|
|
2728
|
+
} else if (data.kind === "focus") {
|
|
2729
|
+
actions.focus(data.pane);
|
|
2730
|
+
panesReady.update((d) => {
|
|
2731
|
+
d[data.pane] = true;
|
|
2732
|
+
});
|
|
2733
|
+
touchLive(data.pane);
|
|
2734
|
+
} else if (data.kind === "cmd" && typeof data.cmd === "string") {
|
|
2735
|
+
handleCommand(data.cmd, data.pane);
|
|
2736
|
+
} else if (data.kind === "prefs-pull") {
|
|
2737
|
+
// Pane boot asking for the current preferences.
|
|
2738
|
+
try {
|
|
2739
|
+
event.source.postMessage({ type: MSG_TYPE, kind: "prefs-push", prefs: prefs.getSnapshot() }, event.origin);
|
|
2740
|
+
} catch {
|
|
2741
|
+
// pane may already be gone
|
|
2742
|
+
}
|
|
2743
|
+
} else if (data.kind === "prefs-set" && data.patch && typeof data.patch === "object") {
|
|
2744
|
+
// Settings edit from a pane's settings modal — validate and
|
|
2745
|
+
// merge here (the top is authoritative); the store's
|
|
2746
|
+
// subscriber broadcasts the canonical state to every pane.
|
|
2747
|
+
const next = patchPrefs(prefs.getSnapshot(), data.patch);
|
|
2748
|
+
if (next) prefs.set(next);
|
|
2749
|
+
} else if (data.kind === "storage-pull") {
|
|
2750
|
+
// Cross-origin pane asking for the top's client-side config
|
|
2751
|
+
// snapshot — answer with every non-per-client key.
|
|
2752
|
+
try {
|
|
2753
|
+
event.source.postMessage({ type: MSG_TYPE, kind: "storage-push", values: collectSyncedStorage() }, event.origin);
|
|
2754
|
+
} catch {
|
|
2755
|
+
// pane may already be gone
|
|
2756
|
+
}
|
|
2757
|
+
} else if (data.kind === "storage-set" && typeof data.key === "string") {
|
|
2758
|
+
// A cross-origin pane wrote to its localStorage — mirror the
|
|
2759
|
+
// write in the top so it survives the next reload.
|
|
2760
|
+
if (!shouldSyncKey(data.key)) return;
|
|
2761
|
+
try {
|
|
2762
|
+
if (data.value === null || data.value === void 0) window.localStorage.removeItem(data.key);
|
|
2763
|
+
else window.localStorage.setItem(data.key, String(data.value));
|
|
2764
|
+
} catch {
|
|
2765
|
+
// storage unavailable — the write cannot persist
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
const onKey = (event) => {
|
|
2770
|
+
const cmd = commandOfEvent(event, prefs.getSnapshot().shortcuts);
|
|
2771
|
+
if (cmd === null) return;
|
|
2772
|
+
event.preventDefault();
|
|
2773
|
+
handleCommand(cmd, null);
|
|
2774
|
+
};
|
|
2775
|
+
const style = document.createElement("style");
|
|
2776
|
+
style.setAttribute("data-plugin", "dsh-split-view");
|
|
2777
|
+
style.textContent = CSS;
|
|
2778
|
+
document.head.append(style);
|
|
2779
|
+
window.addEventListener("message", onMessage);
|
|
2780
|
+
window.addEventListener("keydown", onKey);
|
|
2781
|
+
return () => {
|
|
2782
|
+
window.removeEventListener("message", onMessage);
|
|
2783
|
+
window.removeEventListener("keydown", onKey);
|
|
2784
|
+
style.remove();
|
|
2785
|
+
};
|
|
2786
|
+
}, "split-view: top bus + styles");
|
|
2787
|
+
// Shadow the AppFrame root entry (priority 0) — lowest priority renders.
|
|
2788
|
+
// The AppFrame subtree (sidebar/conversation/details/shell.overlay) stays
|
|
2789
|
+
// registered but unrendered; every pane iframe runs its own full AppFrame.
|
|
2790
|
+
reapplyBootSkin(ctx);
|
|
2791
|
+
ctx.slots.inject("root", () => ctx.slots.register({
|
|
2792
|
+
name: "root",
|
|
2793
|
+
priority: -1
|
|
2794
|
+
}, () => React.createElement(SplitterRoot, { layout, panesReady, liveOrder, actions, handleCommand, pool, caps, maxLive, paneTitles, paneInfo, prefs })));
|
|
2795
|
+
}
|
|
2796
|
+
//#endregion
|
|
2797
|
+
|
|
2798
|
+
/** Cordis fiber inject — slots for the root shadow and the settings
|
|
2799
|
+
* section, sessions for pane seeding, theme for the cross-origin settings
|
|
2800
|
+
* re-apply, locale for the settings rows' dictionaries. */
|
|
2801
|
+
const inject = ["slots", "sessions", "theme", "locale"];
|
|
2802
|
+
/**
|
|
2803
|
+
* Client plugin body: exactly one of the two modes runs per page load,
|
|
2804
|
+
* selected by the ?dshPane parameter.
|
|
2805
|
+
* @param ctx - client root context.
|
|
2806
|
+
*/
|
|
2807
|
+
function apply(ctx) {
|
|
2808
|
+
if (PANE_ID) applyPane(ctx);
|
|
2809
|
+
else applyTop(ctx);
|
|
2810
|
+
}
|
|
2811
|
+
exports.apply = apply;
|
|
2812
|
+
exports.inject = inject;
|
|
2813
|
+
return module.exports;
|
|
2814
|
+
}
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
//# sourceMappingURL=client.js.map
|