@bakapiano/ccsm 0.22.3 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +148 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +62 -15
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -3,6 +3,7 @@
3
3
  // resize propagation, paste handling, and browser/mobile lifecycle hooks.
4
4
 
5
5
  import { wsBase, getToken, getDeviceId } from '../backend.js';
6
+ import { TerminalResizeDebouncer } from './TerminalResizeDebouncer.js';
6
7
  import { XtermTerminal } from './XtermTerminal.js';
7
8
 
8
9
  export class TerminalInstance {
@@ -18,19 +19,36 @@ export class TerminalInstance {
18
19
  this.attempts = 0;
19
20
  this.everOpened = false;
20
21
  this.inReplay = false;
22
+ this.replayDepth = 0;
23
+ this.isVisible = false;
24
+ this.lastLayoutDimensions = null;
21
25
  this.lastSentDimensions = null;
26
+ this.pendingLayoutFrame = null;
27
+ this.layoutRetryTimers = new Set();
22
28
  this.disposables = [];
23
29
  this.helperTextarea = null;
30
+ this.resizeDebouncer = new TerminalResizeDebouncer({
31
+ isVisible: () => this.isVisible,
32
+ getXterm: () => this.xterm,
33
+ resizeBoth: (cols, rows) => this._applyResize(cols, rows),
34
+ resizeX: (cols) => this._applyResize(cols, this.xterm.rows),
35
+ resizeY: (rows) => this._applyResize(this.xterm.cols, rows),
36
+ });
37
+ const refreshDisposable = this.xterm.onDidRequestRefreshDimensions(() => {
38
+ this.scheduleLayout({ immediate: this.isVisible, retries: true });
39
+ });
40
+ this.disposables.push(() => refreshDisposable.dispose());
24
41
  }
25
42
 
26
43
  attachToElement(host) {
27
44
  this.host = host;
28
45
  this.xterm.attachToElement(host);
29
46
  this._registerColorOscHandlers();
30
- this._connect();
31
47
  this._wireXtermEvents();
32
48
  this._wireDomLifecycle();
33
- this.xterm.focus();
49
+ this.setVisible(this._isHostVisible());
50
+ this._connect();
51
+ if (this.isVisible) this.xterm.focus();
34
52
  }
35
53
 
36
54
  sendInput(data) {
@@ -45,19 +63,60 @@ export class TerminalInstance {
45
63
  this.xterm.applyResolvedTheme();
46
64
  }
47
65
 
48
- layout(width, height) {
49
- const dimensions = (width > 0 && height > 0)
50
- ? this.xterm.layout(width, height)
51
- : this.xterm.layoutFromElement();
52
- if (dimensions) {
53
- this._sendResize(dimensions.cols, dimensions.rows);
66
+ layout(width, height, immediate = false) {
67
+ const layoutDimensions = this._resolveLayoutDimensions(width, height);
68
+ if (!layoutDimensions) return null;
69
+
70
+ this.lastLayoutDimensions = layoutDimensions;
71
+ const proposed = this.xterm.proposeDimensions(layoutDimensions.width, layoutDimensions.height);
72
+ if (!proposed) return null;
73
+
74
+ this.resizeDebouncer.resize(proposed.cols, proposed.rows, immediate);
75
+ return proposed;
76
+ }
77
+
78
+ scheduleLayout(options = {}) {
79
+ const { immediate = false, retries = false, forceRedraw = false } =
80
+ typeof options === 'boolean' ? { immediate: options } : options;
81
+ if (this.closedByUs) return null;
82
+
83
+ if (immediate) {
84
+ this._cancelScheduledLayout();
85
+ const result = this.layout(undefined, undefined, true);
86
+ if (forceRedraw) this.xterm.forceRedraw();
87
+ if (retries) this._scheduleLayoutRetries(forceRedraw);
88
+ return result;
89
+ }
90
+
91
+ if (this.pendingLayoutFrame === null) {
92
+ this.pendingLayoutFrame = requestAnimationFrame(() => {
93
+ this.pendingLayoutFrame = null;
94
+ this.layout();
95
+ if (forceRedraw) this.xterm.forceRedraw();
96
+ });
54
97
  }
55
- return dimensions;
98
+ if (retries) this._scheduleLayoutRetries(forceRedraw);
99
+ return null;
100
+ }
101
+
102
+ setVisible(visible) {
103
+ const nextVisible = !!visible;
104
+ const didChange = this.isVisible !== nextVisible;
105
+ this.isVisible = nextVisible;
106
+ this.host?.classList.toggle('active', nextVisible);
107
+
108
+ if (nextVisible) {
109
+ this.resizeDebouncer.flush();
110
+ this.scheduleLayout({ immediate: true, retries: true, forceRedraw: true });
111
+ }
112
+ return didChange;
56
113
  }
57
114
 
58
115
  dispose() {
59
116
  this.closedByUs = true;
60
117
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
118
+ this._cancelScheduledLayout();
119
+ this.resizeDebouncer.dispose();
61
120
  for (const dispose of this.disposables.splice(0)) {
62
121
  try { dispose(); } catch {}
63
122
  }
@@ -78,8 +137,7 @@ export class TerminalInstance {
78
137
  }
79
138
  this.everOpened = true;
80
139
  this.attempts = 0;
81
- this.layout();
82
- this.xterm.scheduleLayout();
140
+ this.scheduleLayout({ immediate: true, retries: true });
83
141
  this._sendResize(this.xterm.cols, this.xterm.rows, true);
84
142
  };
85
143
  ws.onmessage = (ev) => {
@@ -140,7 +198,7 @@ export class TerminalInstance {
140
198
  this.disposables.push(() => ro.disconnect());
141
199
 
142
200
  const vv = window.visualViewport;
143
- const onVisualResize = () => this.xterm.scheduleLayout();
201
+ const onVisualResize = () => this.scheduleLayout({ retries: true });
144
202
  vv?.addEventListener?.('resize', onVisualResize);
145
203
  vv?.addEventListener?.('scroll', onVisualResize);
146
204
  this.disposables.push(() => {
@@ -155,6 +213,7 @@ export class TerminalInstance {
155
213
  }
156
214
 
157
215
  this._wireTabVisibilityRefresh(host);
216
+ this._wireDocumentVisibilityRefresh();
158
217
  this._wirePasteHandlers(host);
159
218
  this._wireModifiedEnterHandler(host);
160
219
  this._wireCompositionHandlers();
@@ -164,19 +223,24 @@ export class TerminalInstance {
164
223
  const panel = host.closest('.tab-panel');
165
224
  if (!panel) return;
166
225
  const panelMo = new MutationObserver(() => {
167
- if (panel.hasAttribute('data-active')) {
168
- requestAnimationFrame(() => {
169
- this.xterm.clearTextureAtlas();
170
- this.xterm.scheduleLayout();
171
- this.layout();
172
- this.xterm.refresh();
173
- });
174
- }
226
+ this.setVisible(this._isHostVisible());
175
227
  });
176
228
  panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
177
229
  this.disposables.push(() => panelMo.disconnect());
178
230
  }
179
231
 
232
+ _wireDocumentVisibilityRefresh() {
233
+ const onVisibilityChange = () => {
234
+ this.setVisible(!document.hidden && this._isHostVisible());
235
+ };
236
+ document.addEventListener('visibilitychange', onVisibilityChange);
237
+ window.addEventListener('focus', onVisibilityChange);
238
+ this.disposables.push(
239
+ () => document.removeEventListener('visibilitychange', onVisibilityChange),
240
+ () => window.removeEventListener('focus', onVisibilityChange),
241
+ );
242
+ }
243
+
180
244
  _wirePasteHandlers(host) {
181
245
  const isOurs = () => {
182
246
  const ae = document.activeElement;
@@ -294,12 +358,74 @@ export class TerminalInstance {
294
358
  this.xterm.write(data);
295
359
  return;
296
360
  }
297
- this.inReplay = true;
361
+ this._beginReplay();
298
362
  this.xterm.write(data, () => {
299
- this.inReplay = false;
363
+ this._endReplay();
300
364
  });
301
365
  }
302
366
 
367
+ _beginReplay() {
368
+ this.replayDepth++;
369
+ this.inReplay = true;
370
+ }
371
+
372
+ _endReplay() {
373
+ this.replayDepth = Math.max(0, this.replayDepth - 1);
374
+ this.inReplay = this.replayDepth > 0;
375
+ }
376
+
377
+ _applyResize(cols, rows) {
378
+ if (this.closedByUs) return;
379
+ if (!(cols > 0 && rows > 0)) return;
380
+ this.xterm.resize(cols, rows);
381
+ this._sendResize(this.xterm.cols, this.xterm.rows);
382
+ }
383
+
384
+ _resolveLayoutDimensions(width, height) {
385
+ if (width > 0 && height > 0) {
386
+ return { width, height };
387
+ }
388
+ if (!this.host) return null;
389
+ const rect = this.host.getBoundingClientRect();
390
+ const resolvedWidth = rect.width || this.host.clientWidth;
391
+ const resolvedHeight = rect.height || this.host.clientHeight;
392
+ if (!(resolvedWidth > 0 && resolvedHeight > 0)) return null;
393
+ return { width: resolvedWidth, height: resolvedHeight };
394
+ }
395
+
396
+ _scheduleLayoutRetries(forceRedraw = false) {
397
+ this._clearLayoutRetryTimers();
398
+ for (const delay of [60, 200]) {
399
+ const timer = setTimeout(() => {
400
+ this.layoutRetryTimers.delete(timer);
401
+ this.layout(undefined, undefined, true);
402
+ if (forceRedraw) this.xterm.forceRedraw();
403
+ }, delay);
404
+ this.layoutRetryTimers.add(timer);
405
+ }
406
+ }
407
+
408
+ _cancelScheduledLayout() {
409
+ if (this.pendingLayoutFrame !== null) {
410
+ cancelAnimationFrame(this.pendingLayoutFrame);
411
+ this.pendingLayoutFrame = null;
412
+ }
413
+ this._clearLayoutRetryTimers();
414
+ }
415
+
416
+ _clearLayoutRetryTimers() {
417
+ for (const timer of this.layoutRetryTimers) clearTimeout(timer);
418
+ this.layoutRetryTimers.clear();
419
+ }
420
+
421
+ _isHostVisible() {
422
+ if (!this.host || !this.host.isConnected || document.hidden) return false;
423
+ const panel = this.host.closest('.tab-panel');
424
+ if (panel && !panel.hasAttribute('data-active')) return false;
425
+ const style = window.getComputedStyle(this.host);
426
+ return style.display !== 'none' && style.visibility !== 'hidden';
427
+ }
428
+
303
429
  _wsUrl() {
304
430
  const tok = getToken();
305
431
  const dev = getDeviceId();
@@ -0,0 +1,126 @@
1
+ const START_DEBOUNCING_THRESHOLD = 200;
2
+ const DEBOUNCE_RESIZE_X_DELAY = 100;
3
+
4
+ export class TerminalResizeDebouncer {
5
+ constructor({ isVisible, getXterm, resizeBoth, resizeX, resizeY }) {
6
+ this.isVisible = isVisible;
7
+ this.getXterm = getXterm;
8
+ this.resizeBoth = resizeBoth;
9
+ this.resizeX = resizeX;
10
+ this.resizeY = resizeY;
11
+ this.latestX = 0;
12
+ this.latestY = 0;
13
+ this.resizeXTimer = null;
14
+ this.resizeXIdle = null;
15
+ this.resizeYIdle = null;
16
+ this.disposed = false;
17
+ }
18
+
19
+ resize(cols, rows, immediate = false) {
20
+ if (this.disposed) return;
21
+ this.latestX = cols;
22
+ this.latestY = rows;
23
+
24
+ const xterm = this.getXterm();
25
+ const normalBufferLength = xterm?.normalBufferLength ?? 0;
26
+ if (immediate || normalBufferLength < START_DEBOUNCING_THRESHOLD) {
27
+ this._clearPending();
28
+ this.resizeBoth(cols, rows);
29
+ return;
30
+ }
31
+
32
+ if (!this.isVisible()) {
33
+ this._scheduleIdleResizeX();
34
+ this._scheduleIdleResizeY();
35
+ return;
36
+ }
37
+
38
+ this._cancelIdleResizeY();
39
+ this.resizeY(rows);
40
+ this._scheduleDebouncedResizeX();
41
+ }
42
+
43
+ flush() {
44
+ if (this.disposed) return;
45
+ if (!this._hasPending()) return;
46
+ this._clearPending();
47
+ this.resizeBoth(this.latestX, this.latestY);
48
+ }
49
+
50
+ dispose() {
51
+ this.disposed = true;
52
+ this._clearPending();
53
+ }
54
+
55
+ _hasPending() {
56
+ return this.resizeXTimer !== null || this.resizeXIdle !== null || this.resizeYIdle !== null;
57
+ }
58
+
59
+ _clearPending() {
60
+ this._cancelDebouncedResizeX();
61
+ this._cancelIdleResizeX();
62
+ this._cancelIdleResizeY();
63
+ }
64
+
65
+ _scheduleDebouncedResizeX() {
66
+ this._cancelIdleResizeX();
67
+ if (this.resizeXTimer !== null) clearTimeout(this.resizeXTimer);
68
+ this.resizeXTimer = setTimeout(() => {
69
+ this.resizeXTimer = null;
70
+ if (!this.disposed) this.resizeX(this.latestX);
71
+ }, DEBOUNCE_RESIZE_X_DELAY);
72
+ }
73
+
74
+ _cancelDebouncedResizeX() {
75
+ if (this.resizeXTimer !== null) {
76
+ clearTimeout(this.resizeXTimer);
77
+ this.resizeXTimer = null;
78
+ }
79
+ }
80
+
81
+ _scheduleIdleResizeX() {
82
+ this._cancelDebouncedResizeX();
83
+ if (this.resizeXIdle !== null) return;
84
+ this.resizeXIdle = this._requestIdle(() => {
85
+ this.resizeXIdle = null;
86
+ if (!this.disposed) this.resizeX(this.latestX);
87
+ });
88
+ }
89
+
90
+ _scheduleIdleResizeY() {
91
+ if (this.resizeYIdle !== null) return;
92
+ this.resizeYIdle = this._requestIdle(() => {
93
+ this.resizeYIdle = null;
94
+ if (!this.disposed) this.resizeY(this.latestY);
95
+ });
96
+ }
97
+
98
+ _cancelIdleResizeX() {
99
+ if (this.resizeXIdle !== null) {
100
+ this._cancelIdle(this.resizeXIdle);
101
+ this.resizeXIdle = null;
102
+ }
103
+ }
104
+
105
+ _cancelIdleResizeY() {
106
+ if (this.resizeYIdle !== null) {
107
+ this._cancelIdle(this.resizeYIdle);
108
+ this.resizeYIdle = null;
109
+ }
110
+ }
111
+
112
+ _requestIdle(callback) {
113
+ if (window.requestIdleCallback) {
114
+ return { kind: 'idle', id: window.requestIdleCallback(callback) };
115
+ }
116
+ return { kind: 'timeout', id: setTimeout(callback, 50) };
117
+ }
118
+
119
+ _cancelIdle(handle) {
120
+ if (handle.kind === 'idle') {
121
+ window.cancelIdleCallback?.(handle.id);
122
+ } else {
123
+ clearTimeout(handle.id);
124
+ }
125
+ }
126
+ }
@@ -59,6 +59,8 @@ export class XtermTerminal {
59
59
  this.currentTheme = themeFor(isDarkTheme());
60
60
  this.fitAddon = new FitAddon();
61
61
  this.webglAddon = null;
62
+ this.webglContextLossDisposable = null;
63
+ this.refreshDimensionListeners = new Set();
62
64
  this.host = null;
63
65
 
64
66
  this.raw = new Terminal({
@@ -82,12 +84,12 @@ export class XtermTerminal {
82
84
  this.raw.loadAddon(this.fitAddon);
83
85
  this.raw.loadAddon(new WebLinksAddon());
84
86
  this.raw.loadAddon(new ClipboardAddon());
85
- this._loadRendererAddon();
86
87
  this._installSelectionCopyGuard();
87
88
  }
88
89
 
89
90
  get cols() { return this.raw.cols; }
90
91
  get rows() { return this.raw.rows; }
92
+ get normalBufferLength() { return this.raw.buffer?.normal?.length ?? 0; }
91
93
  get theme() { return this.currentTheme; }
92
94
  get parser() { return this.raw.parser; }
93
95
  get helperTextarea() {
@@ -97,14 +99,21 @@ export class XtermTerminal {
97
99
  attachToElement(host) {
98
100
  this.host = host;
99
101
  this.raw.open(host);
100
- this.scheduleLayout();
102
+ this._enableWebglRenderer();
101
103
  try {
102
104
  document.fonts?.ready?.then(() => {
103
- if (this.host === host) this.scheduleLayout();
105
+ if (this.host === host) this._fireRequestRefreshDimensions();
104
106
  });
105
107
  } catch {}
106
108
  }
107
109
 
110
+ onDidRequestRefreshDimensions(listener) {
111
+ this.refreshDimensionListeners.add(listener);
112
+ return {
113
+ dispose: () => this.refreshDimensionListeners.delete(listener),
114
+ };
115
+ }
116
+
108
117
  applyResolvedTheme() {
109
118
  const theme = themeFor(isDarkTheme());
110
119
  this.currentTheme = theme;
@@ -128,15 +137,6 @@ export class XtermTerminal {
128
137
  try { this.raw.write('\x1b[?25l'); } catch {}
129
138
  }
130
139
 
131
- scheduleLayout() {
132
- this.layoutFromElement();
133
- requestAnimationFrame(() => {
134
- this.layoutFromElement();
135
- setTimeout(() => this.layoutFromElement(), 60);
136
- setTimeout(() => this.layoutFromElement(), 200);
137
- });
138
- }
139
-
140
140
  layoutFromElement() {
141
141
  if (!this.host) return null;
142
142
  const rect = this.host.getBoundingClientRect();
@@ -156,6 +156,16 @@ export class XtermTerminal {
156
156
  return proposed;
157
157
  }
158
158
 
159
+ proposeDimensions(width, height) {
160
+ return this._proposeDimensions(width, height);
161
+ }
162
+
163
+ resize(cols, rows) {
164
+ if (!(cols > 0 && rows > 0)) return;
165
+ try { this.raw.resize(cols, rows); } catch {}
166
+ lastKnownGridDimensions = { cols: this.raw.cols, rows: this.raw.rows };
167
+ }
168
+
159
169
  fit() {
160
170
  try { this.fitAddon.fit(); } catch {}
161
171
  }
@@ -168,6 +178,11 @@ export class XtermTerminal {
168
178
  try { this.raw.clearTextureAtlas?.(); } catch {}
169
179
  }
170
180
 
181
+ forceRedraw() {
182
+ this.clearTextureAtlas();
183
+ this.refresh();
184
+ }
185
+
171
186
  write(data, callback) {
172
187
  try { this.raw.write(data, callback); } catch { callback?.(); }
173
188
  }
@@ -194,20 +209,50 @@ export class XtermTerminal {
194
209
 
195
210
  dispose() {
196
211
  this.host = null;
212
+ this._disposeWebglRenderer(false);
213
+ this.refreshDimensionListeners.clear();
197
214
  try { this.raw.dispose(); } catch {}
198
215
  }
199
216
 
200
- _loadRendererAddon() {
217
+ _shouldLoadWebgl() {
218
+ return !this.isMobile && XtermTerminal._suggestedRendererType !== 'dom';
219
+ }
220
+
221
+ _enableWebglRenderer() {
201
222
  // Keep the current mobile guard: @xterm/addon-webgl@0.18 can mis-measure
202
223
  // glyph atlases on fractional mobile DPRs.
203
- if (this.isMobile) return;
224
+ if (!this.raw.element || !this._shouldLoadWebgl()) return;
225
+ this._disposeWebglRenderer(false);
204
226
  try {
205
227
  const webgl = new WebglAddon();
206
228
  this.webglAddon = webgl;
207
- webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
229
+ this.webglContextLossDisposable = webgl.onContextLoss(() => {
230
+ console.warn('[ccsm] WebGL context lost, using DOM renderer');
231
+ this._disposeWebglRenderer();
232
+ });
208
233
  this.raw.loadAddon(webgl);
234
+ this._fireRequestRefreshDimensions();
209
235
  } catch (e) {
236
+ XtermTerminal._suggestedRendererType = 'dom';
237
+ this._disposeWebglRenderer(false);
210
238
  console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
239
+ this._fireRequestRefreshDimensions();
240
+ }
241
+ }
242
+
243
+ _disposeWebglRenderer(requestRefresh = true) {
244
+ try { this.webglContextLossDisposable?.dispose(); } catch {}
245
+ this.webglContextLossDisposable = null;
246
+ if (this.webglAddon) {
247
+ try { this.webglAddon.dispose(); } catch {}
248
+ this.webglAddon = null;
249
+ }
250
+ if (requestRefresh) this._fireRequestRefreshDimensions();
251
+ }
252
+
253
+ _fireRequestRefreshDimensions() {
254
+ for (const listener of this.refreshDimensionListeners) {
255
+ try { listener(); } catch {}
211
256
  }
212
257
  }
213
258
 
@@ -283,3 +328,5 @@ export class XtermTerminal {
283
328
  return width > 0 ? width : SCROLLBAR_WIDTH_FALLBACK;
284
329
  }
285
330
  }
331
+
332
+ XtermTerminal._suggestedRendererType = undefined;
@@ -1,67 +1,67 @@
1
- // Lightweight HTML5 drag-reorder helper.
2
- //
3
- // Usage:
4
- // const dnd = useDragSort(items.map((i) => i.id), async (nextIds) => {
5
- // await reorderFolders(nextIds);
6
- // });
7
- // items.map((it) => html`
8
- // <div ...${dnd.rowProps(it.id)}>
9
- // <span ...${dnd.handleProps(it.id)}>⋮⋮</span>
10
- // ...
11
- // </div>`);
12
- //
13
- // rowProps spreads onDragOver / onDrop / data-* on the row container.
14
- // handleProps spreads draggable + onDragStart on the drag handle. We
15
- // gate "draggable" on the handle so clicks inside the row don't start a
16
- // drag and the user can still click rows normally.
17
-
18
- import { useRef, useState } from 'preact/hooks';
19
-
20
- export function useDragSort(ids, onCommit) {
21
- const dragging = useRef(null);
22
- const [overId, setOverId] = useState(null);
23
-
24
- const handleProps = (id) => ({
25
- draggable: true,
26
- onDragStart: (ev) => {
27
- dragging.current = id;
28
- ev.dataTransfer.effectAllowed = 'move';
29
- // Setting some data is required for Firefox to actually start a drag.
30
- try { ev.dataTransfer.setData('text/plain', id); } catch {}
31
- },
32
- onDragEnd: () => { dragging.current = null; setOverId(null); },
33
- });
34
-
35
- const rowProps = (id) => ({
36
- 'data-dnd-id': id,
37
- 'data-dnd-over': overId === id ? 'true' : undefined,
38
- onDragOver: (ev) => {
39
- if (dragging.current == null || dragging.current === id) return;
40
- ev.preventDefault();
41
- ev.dataTransfer.dropEffect = 'move';
42
- if (overId !== id) setOverId(id);
43
- },
44
- onDragLeave: (ev) => {
45
- // Only clear if the pointer leaves the row entirely (not when entering a child).
46
- const rt = ev.relatedTarget;
47
- if (rt && ev.currentTarget.contains(rt)) return;
48
- if (overId === id) setOverId(null);
49
- },
50
- onDrop: (ev) => {
51
- ev.preventDefault();
52
- const src = dragging.current;
53
- dragging.current = null;
54
- setOverId(null);
55
- if (src == null || src === id) return;
56
- const cur = [...ids];
57
- const from = cur.indexOf(src);
58
- const to = cur.indexOf(id);
59
- if (from < 0 || to < 0) return;
60
- cur.splice(from, 1);
61
- cur.splice(to, 0, src);
62
- onCommit?.(cur);
63
- },
64
- });
65
-
66
- return { handleProps, rowProps, draggingId: dragging.current, overId };
67
- }
1
+ // Lightweight HTML5 drag-reorder helper.
2
+ //
3
+ // Usage:
4
+ // const dnd = useDragSort(items.map((i) => i.id), async (nextIds) => {
5
+ // await reorderFolders(nextIds);
6
+ // });
7
+ // items.map((it) => html`
8
+ // <div ...${dnd.rowProps(it.id)}>
9
+ // <span ...${dnd.handleProps(it.id)}>⋮⋮</span>
10
+ // ...
11
+ // </div>`);
12
+ //
13
+ // rowProps spreads onDragOver / onDrop / data-* on the row container.
14
+ // handleProps spreads draggable + onDragStart on the drag handle. We
15
+ // gate "draggable" on the handle so clicks inside the row don't start a
16
+ // drag and the user can still click rows normally.
17
+
18
+ import { useRef, useState } from 'preact/hooks';
19
+
20
+ export function useDragSort(ids, onCommit) {
21
+ const dragging = useRef(null);
22
+ const [overId, setOverId] = useState(null);
23
+
24
+ const handleProps = (id) => ({
25
+ draggable: true,
26
+ onDragStart: (ev) => {
27
+ dragging.current = id;
28
+ ev.dataTransfer.effectAllowed = 'move';
29
+ // Setting some data is required for Firefox to actually start a drag.
30
+ try { ev.dataTransfer.setData('text/plain', id); } catch {}
31
+ },
32
+ onDragEnd: () => { dragging.current = null; setOverId(null); },
33
+ });
34
+
35
+ const rowProps = (id) => ({
36
+ 'data-dnd-id': id,
37
+ 'data-dnd-over': overId === id ? 'true' : undefined,
38
+ onDragOver: (ev) => {
39
+ if (dragging.current == null || dragging.current === id) return;
40
+ ev.preventDefault();
41
+ ev.dataTransfer.dropEffect = 'move';
42
+ if (overId !== id) setOverId(id);
43
+ },
44
+ onDragLeave: (ev) => {
45
+ // Only clear if the pointer leaves the row entirely (not when entering a child).
46
+ const rt = ev.relatedTarget;
47
+ if (rt && ev.currentTarget.contains(rt)) return;
48
+ if (overId === id) setOverId(null);
49
+ },
50
+ onDrop: (ev) => {
51
+ ev.preventDefault();
52
+ const src = dragging.current;
53
+ dragging.current = null;
54
+ setOverId(null);
55
+ if (src == null || src === id) return;
56
+ const cur = [...ids];
57
+ const from = cur.indexOf(src);
58
+ const to = cur.indexOf(id);
59
+ if (from < 0 || to < 0) return;
60
+ cur.splice(from, 1);
61
+ cur.splice(to, 0, src);
62
+ onCommit?.(cur);
63
+ },
64
+ });
65
+
66
+ return { handleProps, rowProps, draggingId: dragging.current, overId };
67
+ }