devglide 0.1.1
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 +338 -0
- package/bin/claude-md-template.js +94 -0
- package/bin/devglide.js +387 -0
- package/package.json +85 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/apps/coder/.turbo/turbo-lint.log +5 -0
- package/src/apps/coder/package.json +16 -0
- package/src/apps/coder/public/favicon.svg +7 -0
- package/src/apps/coder/public/page.css +275 -0
- package/src/apps/coder/public/page.js +528 -0
- package/src/apps/coder/server.js +3 -0
- package/src/apps/documentation/public/page.css +597 -0
- package/src/apps/documentation/public/page.js +609 -0
- package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
- package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/kanban/package.json +32 -0
- package/src/apps/kanban/public/favicon.svg +7 -0
- package/src/apps/kanban/public/page.css +1010 -0
- package/src/apps/kanban/public/page.js +1730 -0
- package/src/apps/kanban/public/vendor/marked.min.js +6 -0
- package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
- package/src/apps/kanban/src/db.ts +319 -0
- package/src/apps/kanban/src/index.ts +14 -0
- package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
- package/src/apps/kanban/src/mcp-helpers.ts +60 -0
- package/src/apps/kanban/src/mcp.ts +59 -0
- package/src/apps/kanban/src/routes/attachments.ts +161 -0
- package/src/apps/kanban/src/routes/features.ts +233 -0
- package/src/apps/kanban/src/routes/issues.ts +373 -0
- package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
- package/src/apps/kanban/src/tools/item-tools.ts +307 -0
- package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
- package/src/apps/kanban/tsconfig.check.json +9 -0
- package/src/apps/kanban/tsconfig.json +9 -0
- package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
- package/src/apps/keymap/package.json +16 -0
- package/src/apps/keymap/public/page.css +275 -0
- package/src/apps/keymap/public/page.js +294 -0
- package/src/apps/keymap/server.js +25 -0
- package/src/apps/log/.turbo/turbo-build.log +5 -0
- package/src/apps/log/.turbo/turbo-lint.log +45 -0
- package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/log/node_modules/.bin/tsc +21 -0
- package/src/apps/log/node_modules/.bin/tsserver +21 -0
- package/src/apps/log/node_modules/.bin/tsx +21 -0
- package/src/apps/log/package.json +36 -0
- package/src/apps/log/public/console-sniffer.js +221 -0
- package/src/apps/log/public/favicon.svg +7 -0
- package/src/apps/log/public/page.css +322 -0
- package/src/apps/log/public/page.js +463 -0
- package/src/apps/log/src/index.ts +9 -0
- package/src/apps/log/src/mcp.ts +122 -0
- package/src/apps/log/src/routes/log.ts +333 -0
- package/src/apps/log/src/routes/status.ts +25 -0
- package/src/apps/log/src/server-sniffer.ts +118 -0
- package/src/apps/log/src/services/file-patterns.ts +39 -0
- package/src/apps/log/src/services/file-tailer.ts +228 -0
- package/src/apps/log/src/services/line-parser.ts +94 -0
- package/src/apps/log/src/services/log-writer.ts +39 -0
- package/src/apps/log/tsconfig.json +8 -0
- package/src/apps/prompts/.turbo/turbo-build.log +5 -0
- package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
- package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/prompts/mcp.ts +175 -0
- package/src/apps/prompts/node_modules/.bin/tsc +21 -0
- package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
- package/src/apps/prompts/node_modules/.bin/tsx +21 -0
- package/src/apps/prompts/package.json +25 -0
- package/src/apps/prompts/public/page.css +315 -0
- package/src/apps/prompts/public/page.js +541 -0
- package/src/apps/prompts/services/prompt-store.ts +212 -0
- package/src/apps/prompts/src/index.ts +9 -0
- package/src/apps/prompts/tsconfig.json +8 -0
- package/src/apps/prompts/types.ts +27 -0
- package/src/apps/shell/.turbo/turbo-build.log +5 -0
- package/src/apps/shell/.turbo/turbo-lint.log +34 -0
- package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/shell/package.json +35 -0
- package/src/apps/shell/public/favicon.svg +7 -0
- package/src/apps/shell/public/page.css +407 -0
- package/src/apps/shell/public/page.js +1577 -0
- package/src/apps/shell/src/index.ts +150 -0
- package/src/apps/shell/src/mcp.ts +398 -0
- package/src/apps/shell/src/shell-types.ts +41 -0
- package/src/apps/shell/tsconfig.json +8 -0
- package/src/apps/test/.turbo/turbo-build.log +5 -0
- package/src/apps/test/.turbo/turbo-lint.log +27 -0
- package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/test/node_modules/.bin/tsc +21 -0
- package/src/apps/test/node_modules/.bin/tsserver +21 -0
- package/src/apps/test/node_modules/.bin/tsx +21 -0
- package/src/apps/test/node_modules/.bin/uuid +21 -0
- package/src/apps/test/package.json +35 -0
- package/src/apps/test/public/favicon.svg +7 -0
- package/src/apps/test/public/page.css +499 -0
- package/src/apps/test/public/page.js +417 -0
- package/src/apps/test/public/scenario-runner.js +450 -0
- package/src/apps/test/src/index.ts +9 -0
- package/src/apps/test/src/mcp.ts +192 -0
- package/src/apps/test/src/routes/trigger.ts +285 -0
- package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
- package/src/apps/test/src/services/scenario-manager.ts +361 -0
- package/src/apps/test/src/services/scenario-store.ts +145 -0
- package/src/apps/test/tsconfig.json +8 -0
- package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
- package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
- package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/vocabulary/mcp.ts +173 -0
- package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
- package/src/apps/vocabulary/package.json +25 -0
- package/src/apps/vocabulary/public/page.css +247 -0
- package/src/apps/vocabulary/public/page.js +444 -0
- package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
- package/src/apps/vocabulary/src/index.ts +10 -0
- package/src/apps/vocabulary/tsconfig.json +8 -0
- package/src/apps/vocabulary/types.ts +22 -0
- package/src/apps/voice/.turbo/turbo-build.log +5 -0
- package/src/apps/voice/.turbo/turbo-lint.log +43 -0
- package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/voice/node_modules/.bin/openai +21 -0
- package/src/apps/voice/node_modules/.bin/tsc +21 -0
- package/src/apps/voice/node_modules/.bin/tsserver +21 -0
- package/src/apps/voice/node_modules/.bin/tsx +21 -0
- package/src/apps/voice/package.json +35 -0
- package/src/apps/voice/public/favicon.svg +7 -0
- package/src/apps/voice/public/page.css +388 -0
- package/src/apps/voice/public/page.js +718 -0
- package/src/apps/voice/src/index.ts +10 -0
- package/src/apps/voice/src/mcp.ts +70 -0
- package/src/apps/voice/src/providers/index.ts +85 -0
- package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
- package/src/apps/voice/src/providers/types.ts +27 -0
- package/src/apps/voice/src/routes/config.ts +118 -0
- package/src/apps/voice/src/routes/transcribe.ts +90 -0
- package/src/apps/voice/src/services/config-store.ts +129 -0
- package/src/apps/voice/src/services/stats.ts +108 -0
- package/src/apps/voice/src/transcribe.ts +11 -0
- package/src/apps/voice/src/utils/mime.ts +16 -0
- package/src/apps/voice/tsconfig.json +8 -0
- package/src/apps/workflow/.turbo/turbo-build.log +5 -0
- package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
- package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
- package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
- package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
- package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
- package/src/apps/workflow/engine/executors/index.ts +28 -0
- package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
- package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
- package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
- package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
- package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
- package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
- package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
- package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
- package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
- package/src/apps/workflow/engine/graph-runner.ts +438 -0
- package/src/apps/workflow/engine/node-executor.ts +104 -0
- package/src/apps/workflow/engine/node-registry.ts +15 -0
- package/src/apps/workflow/engine/variable-resolver.ts +109 -0
- package/src/apps/workflow/mcp.ts +223 -0
- package/src/apps/workflow/node_modules/.bin/tsc +21 -0
- package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
- package/src/apps/workflow/node_modules/.bin/tsx +21 -0
- package/src/apps/workflow/package.json +25 -0
- package/src/apps/workflow/public/editor/canvas.js +366 -0
- package/src/apps/workflow/public/editor/drag-manager.js +326 -0
- package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
- package/src/apps/workflow/public/editor/history-manager.js +147 -0
- package/src/apps/workflow/public/editor/layout-engine.js +159 -0
- package/src/apps/workflow/public/editor/node-renderer.js +199 -0
- package/src/apps/workflow/public/editor/selection-manager.js +193 -0
- package/src/apps/workflow/public/favicon.svg +7 -0
- package/src/apps/workflow/public/models/node-types.js +300 -0
- package/src/apps/workflow/public/models/workflow-model.js +257 -0
- package/src/apps/workflow/public/page.css +406 -0
- package/src/apps/workflow/public/page.js +658 -0
- package/src/apps/workflow/public/panels/inspector.js +360 -0
- package/src/apps/workflow/public/panels/palette.js +106 -0
- package/src/apps/workflow/public/panels/run-view.js +275 -0
- package/src/apps/workflow/public/panels/toolbar.js +232 -0
- package/src/apps/workflow/public/panels/workflow-list.js +237 -0
- package/src/apps/workflow/public/state/store.js +47 -0
- package/src/apps/workflow/services/custom-node-loader.ts +48 -0
- package/src/apps/workflow/services/legacy-converter.ts +72 -0
- package/src/apps/workflow/services/run-manager.ts +190 -0
- package/src/apps/workflow/services/workflow-store.ts +424 -0
- package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
- package/src/apps/workflow/services/workflow-validator.ts +98 -0
- package/src/apps/workflow/src/index.ts +10 -0
- package/src/apps/workflow/templates/ci-pipeline.json +18 -0
- package/src/apps/workflow/templates/code-review.json +22 -0
- package/src/apps/workflow/templates/kanban-testing.json +24 -0
- package/src/apps/workflow/tsconfig.json +8 -0
- package/src/apps/workflow/types.ts +268 -0
- package/src/packages/auth-middleware.ts +14 -0
- package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
- package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
- package/src/packages/design-tokens/build.js +413 -0
- package/src/packages/design-tokens/demo/index.html +1367 -0
- package/src/packages/design-tokens/demo/proposition-a.html +717 -0
- package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
- package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
- package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
- package/src/packages/design-tokens/dist/tokens.css +345 -0
- package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
- package/src/packages/design-tokens/dist/tokens.js +386 -0
- package/src/packages/design-tokens/package.json +25 -0
- package/src/packages/design-tokens/tokens.json +228 -0
- package/src/packages/devtools-middleware.ts +22 -0
- package/src/packages/eslint-config/index.js +63 -0
- package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
- package/src/packages/eslint-config/package.json +18 -0
- package/src/packages/json-file-store.ts +232 -0
- package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
- package/src/packages/mcp-utils/dist/index.d.ts +33 -0
- package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
- package/src/packages/mcp-utils/dist/index.js +126 -0
- package/src/packages/mcp-utils/dist/index.js.map +1 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
- package/src/packages/mcp-utils/package.json +32 -0
- package/src/packages/mcp-utils/src/index.ts +171 -0
- package/src/packages/mcp-utils/tsconfig.json +9 -0
- package/src/packages/paths.ts +18 -0
- package/src/packages/project-context/index.js +55 -0
- package/src/packages/project-context/package.json +13 -0
- package/src/packages/project-store.ts +127 -0
- package/src/packages/server-sniffer.ts +132 -0
- package/src/packages/shared-assets/favicon.svg +7 -0
- package/src/packages/shared-assets/keymap-registry.js +512 -0
- package/src/packages/shared-assets/logo.svg +6 -0
- package/src/packages/shared-assets/package.json +11 -0
- package/src/packages/shared-assets/ui-utils.js +48 -0
- package/src/packages/shared-assets/voice-widget.d.ts +37 -0
- package/src/packages/shared-assets/voice-widget.js +695 -0
- package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
- package/src/packages/shared-types/dist/index.d.ts +39 -0
- package/src/packages/shared-types/dist/index.d.ts.map +1 -0
- package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
- package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
- package/src/packages/shared-types/package.json +25 -0
- package/src/packages/shared-types/src/index.ts +41 -0
- package/src/packages/shared-types/tsconfig.json +11 -0
- package/src/packages/tsconfig/base.json +15 -0
- package/src/packages/tsconfig/next.json +14 -0
- package/src/packages/tsconfig/node.json +11 -0
- package/src/packages/tsconfig/package.json +10 -0
- package/turbo.json +25 -0
|
@@ -0,0 +1,1577 @@
|
|
|
1
|
+
// ── Shell App — Native Page Module ────────────────────────────────────
|
|
2
|
+
// ES module that exports mount(container, ctx), unmount(container),
|
|
3
|
+
// and onProjectChange(project).
|
|
4
|
+
//
|
|
5
|
+
// Replaces the iframe-based page module with a native implementation
|
|
6
|
+
// that renders terminal panes directly in the app shell container.
|
|
7
|
+
|
|
8
|
+
import { shellSocket as socket } from '/state.js';
|
|
9
|
+
|
|
10
|
+
// ── Module state ─────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
let _container = null;
|
|
13
|
+
let _resizeTimer = null;
|
|
14
|
+
let _voiceHandler = null;
|
|
15
|
+
let _keydownHandler = null;
|
|
16
|
+
let _xtermLoaded = false;
|
|
17
|
+
let _mountedOnce = false;
|
|
18
|
+
let _restoring = false; // true during snapshot batch restore — suppresses premature fits
|
|
19
|
+
|
|
20
|
+
const panes = new Map(); // id -> pane object
|
|
21
|
+
let activePaneId = null;
|
|
22
|
+
let activeTab = 'grid';
|
|
23
|
+
let activeProject = null;
|
|
24
|
+
|
|
25
|
+
// Track socket handlers for cleanup
|
|
26
|
+
const _socketHandlers = {};
|
|
27
|
+
|
|
28
|
+
// ── Terminal theme ───────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const TERMINAL_THEME = {
|
|
31
|
+
background: '#1c2128', foreground: '#adbac7', cursor: '#7ee787',
|
|
32
|
+
selectionBackground: '#7ee78744',
|
|
33
|
+
black: '#1c1c1c', red: '#f85149', green: '#7ee787', yellow: '#e3b341',
|
|
34
|
+
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#76e3ea', white: '#b1bac4',
|
|
35
|
+
brightBlack: '#6e7681', brightRed: '#ff7b72', brightGreen: '#56d364',
|
|
36
|
+
brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
|
37
|
+
brightCyan: '#87deea', brightWhite: '#ffffff',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
43
|
+
const isMobile = () => window.innerWidth <= 640;
|
|
44
|
+
const isMobileDevice = 'ontouchstart' in window;
|
|
45
|
+
|
|
46
|
+
function makeLabel(num, folder) {
|
|
47
|
+
return folder ? `${num}: ${folder}` : `${num}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Dynamic xterm.js loader ─────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function loadScript(src) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.dataset.loaded) { resolve(); return; }
|
|
57
|
+
existing.addEventListener('load', resolve, { once: true });
|
|
58
|
+
existing.addEventListener('error', reject, { once: true });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const s = document.createElement('script');
|
|
62
|
+
s.src = src;
|
|
63
|
+
s.onload = () => { s.dataset.loaded = '1'; resolve(); };
|
|
64
|
+
s.onerror = reject;
|
|
65
|
+
document.head.appendChild(s);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadCSS(href) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const existing = document.querySelector(`link[href="${href}"]`);
|
|
72
|
+
if (existing) { resolve(); return; }
|
|
73
|
+
const link = document.createElement('link');
|
|
74
|
+
link.rel = 'stylesheet';
|
|
75
|
+
link.href = href;
|
|
76
|
+
link.onload = resolve;
|
|
77
|
+
link.onerror = reject;
|
|
78
|
+
document.head.appendChild(link);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureXterm() {
|
|
83
|
+
if (_xtermLoaded && window.Terminal && window.FitAddon && window.WebLinksAddon) return;
|
|
84
|
+
|
|
85
|
+
// Monaco's AMD loader pollutes window.define, causing xterm's UMD wrapper
|
|
86
|
+
// to register as an AMD module instead of setting window.Terminal.
|
|
87
|
+
// Temporarily hide define while loading xterm scripts.
|
|
88
|
+
const savedDefine = window.define;
|
|
89
|
+
window.define = undefined;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await loadCSS('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css');
|
|
93
|
+
|
|
94
|
+
// Load xterm first, then addons (they depend on it)
|
|
95
|
+
await loadScript('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js');
|
|
96
|
+
|
|
97
|
+
await Promise.all([
|
|
98
|
+
loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js'),
|
|
99
|
+
loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js'),
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
_xtermLoaded = true;
|
|
103
|
+
} finally {
|
|
104
|
+
window.define = savedDefine;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── HTML ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const BODY_HTML = `
|
|
111
|
+
<header>
|
|
112
|
+
<div class="brand">Shell</div>
|
|
113
|
+
<div class="header-meta">
|
|
114
|
+
<span class="pane-count" data-ref="paneCount">0 panes</span>
|
|
115
|
+
<div class="mobile-actions" data-ref="mobileActions">
|
|
116
|
+
<button class="mobile-action-btn" data-action="new-terminal" title="New Terminal">>_</button>
|
|
117
|
+
<button class="mobile-action-btn" data-action="new-browser" title="New Browser">□</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</header>
|
|
121
|
+
<div class="shell-disconnect-banner" data-ref="disconnect">Disconnected — reconnecting...</div>
|
|
122
|
+
<div class="shell-tab-bar" data-ref="tabBar" role="tablist">
|
|
123
|
+
<button class="shell-tab active" data-tab="grid" role="tab">Dashboard</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="shell-pane-container" data-ref="paneContainer" role="tabpanel">
|
|
126
|
+
<div class="shell-empty-state" data-ref="emptyState">
|
|
127
|
+
<div class="hint">No terminals open</div>
|
|
128
|
+
<div class="sub">Use keyboard shortcuts to open a shell or browser</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
// ── Refs helper ─────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function getRefs(container) {
|
|
136
|
+
return {
|
|
137
|
+
tabBar: container.querySelector('[data-ref="tabBar"]'),
|
|
138
|
+
paneContainer: container.querySelector('[data-ref="paneContainer"]'),
|
|
139
|
+
emptyState: container.querySelector('[data-ref="emptyState"]'),
|
|
140
|
+
disconnect: container.querySelector('[data-ref="disconnect"]'),
|
|
141
|
+
paneCount: container.querySelector('[data-ref="paneCount"]'),
|
|
142
|
+
mobileActions: container.querySelector('[data-ref="mobileActions"]'),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function updatePaneCount(refs) {
|
|
147
|
+
if (!refs.paneCount) return;
|
|
148
|
+
const visible = [...panes.values()].filter(p => !p.element.classList.contains('project-hidden')).length;
|
|
149
|
+
refs.paneCount.textContent = `${visible} pane${visible !== 1 ? 's' : ''}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Tab management ──────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function addTab(refs, id, title) {
|
|
155
|
+
const tab = document.createElement('button');
|
|
156
|
+
tab.className = 'shell-tab';
|
|
157
|
+
tab.dataset.tab = id;
|
|
158
|
+
tab.setAttribute('role', 'tab');
|
|
159
|
+
|
|
160
|
+
const label = document.createElement('span');
|
|
161
|
+
label.className = 'shell-tab-label';
|
|
162
|
+
label.textContent = title;
|
|
163
|
+
|
|
164
|
+
const closeBtn = document.createElement('span');
|
|
165
|
+
closeBtn.className = 'shell-tab-close';
|
|
166
|
+
closeBtn.textContent = '\u2715';
|
|
167
|
+
closeBtn.setAttribute('aria-label', `Close ${title}`);
|
|
168
|
+
closeBtn.addEventListener('click', (e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
panes.get(id)?.destroy();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
tab.appendChild(label);
|
|
174
|
+
tab.appendChild(closeBtn);
|
|
175
|
+
tab.addEventListener('click', () => setActiveTab(refs, id));
|
|
176
|
+
tab.addEventListener('mousedown', (e) => {
|
|
177
|
+
if (e.button === 1) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
panes.get(id)?.destroy();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
tab.addEventListener('auxclick', (e) => {
|
|
184
|
+
if (e.button === 1) {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
refs.tabBar.appendChild(tab);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function removeTab(refs, id) {
|
|
193
|
+
refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`)?.remove();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Navigation helpers ──────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function getNavigableTabs(refs) {
|
|
199
|
+
return [...refs.tabBar.querySelectorAll('.shell-tab:not(.project-hidden)')]
|
|
200
|
+
.map(t => t.dataset.tab);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Active tab ──────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function setActiveTab(refs, tabId) {
|
|
206
|
+
_applyActiveTab(refs, tabId);
|
|
207
|
+
socket.emit('state:set-active-tab', { tabId });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _applyActiveTab(refs, tabId) {
|
|
211
|
+
if (activeTab !== 'grid' && activeTab !== tabId) {
|
|
212
|
+
panes.get(activeTab)?.disableKeyboard?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
activeTab = tabId;
|
|
216
|
+
|
|
217
|
+
refs.tabBar.querySelectorAll('.shell-tab').forEach(t => {
|
|
218
|
+
t.classList.toggle('active', t.dataset.tab === tabId);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Scroll the tab into view horizontally only — scrollIntoView() can bubble
|
|
222
|
+
// up and scroll parent containers to the top, causing the terminal to jump.
|
|
223
|
+
const activeTabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${tabId}"]`);
|
|
224
|
+
if (activeTabEl) {
|
|
225
|
+
const barRect = refs.tabBar.getBoundingClientRect();
|
|
226
|
+
const tabRect = activeTabEl.getBoundingClientRect();
|
|
227
|
+
if (tabRect.left < barRect.left) {
|
|
228
|
+
refs.tabBar.scrollLeft -= barRect.left - tabRect.left + 8;
|
|
229
|
+
} else if (tabRect.right > barRect.right) {
|
|
230
|
+
refs.tabBar.scrollLeft += tabRect.right - barRect.right + 8;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (tabId === 'grid') {
|
|
235
|
+
refs.paneContainer.style.gridTemplateRows = '';
|
|
236
|
+
for (const pane of panes.values()) {
|
|
237
|
+
if (!pane.element.classList.contains('project-hidden')) pane.element.style.display = '';
|
|
238
|
+
}
|
|
239
|
+
relayout(refs);
|
|
240
|
+
// Focus the active pane's terminal in grid view (delayed to avoid double-cursor flicker)
|
|
241
|
+
if (!isMobile() && activePaneId) {
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
panes.get(activePaneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
244
|
+
}, 300);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
for (const [id, pane] of panes) {
|
|
248
|
+
pane.element.style.display = id === tabId ? '' : 'none';
|
|
249
|
+
}
|
|
250
|
+
refs.emptyState.style.display = 'none';
|
|
251
|
+
refs.paneContainer.style.gridTemplateColumns = '1fr';
|
|
252
|
+
refs.paneContainer.style.gridTemplateRows = '1fr';
|
|
253
|
+
setActivePaneHighlight(tabId);
|
|
254
|
+
requestAnimationFrame(() => {
|
|
255
|
+
document.fonts.ready.then(() => {
|
|
256
|
+
const pane = panes.get(tabId);
|
|
257
|
+
pane?.fit();
|
|
258
|
+
pane?.scrollToBottom();
|
|
259
|
+
if (!isMobile()) {
|
|
260
|
+
pane?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Layout ──────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function relayout(refs) {
|
|
270
|
+
updatePaneCount(refs);
|
|
271
|
+
const visiblePanes = [...panes.values()].filter(p => !p.element.classList.contains('project-hidden'));
|
|
272
|
+
const count = visiblePanes.length;
|
|
273
|
+
|
|
274
|
+
if (count === 0) {
|
|
275
|
+
refs.paneContainer.style.gridTemplateColumns = '';
|
|
276
|
+
refs.emptyState.style.display = 'flex';
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
refs.emptyState.style.display = 'none';
|
|
280
|
+
|
|
281
|
+
if (activeTab !== 'grid') return;
|
|
282
|
+
|
|
283
|
+
for (const pane of visiblePanes) pane.element.style.display = '';
|
|
284
|
+
|
|
285
|
+
const mobile = window.innerWidth <= 640;
|
|
286
|
+
const cols = mobile ? 1 : count === 1 ? 1 : count <= 4 ? 2 : 3;
|
|
287
|
+
const rows = Math.ceil(count / cols);
|
|
288
|
+
refs.paneContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
289
|
+
refs.paneContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
|
290
|
+
|
|
291
|
+
// Wait for DOM to settle and fonts to load before fitting terminals.
|
|
292
|
+
// rAF ensures layout is flushed, fonts.ready ensures correct metrics.
|
|
293
|
+
requestAnimationFrame(() => {
|
|
294
|
+
document.fonts.ready.then(() => {
|
|
295
|
+
for (const pane of visiblePanes) {
|
|
296
|
+
pane.fit();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function setActivePaneHighlight(id) {
|
|
303
|
+
if (activePaneId) panes.get(activePaneId)?.element.classList.remove('active');
|
|
304
|
+
activePaneId = id;
|
|
305
|
+
if (id) panes.get(id)?.element.classList.add('active');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Drag-to-reorder helpers ─────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
let _draggedPaneId = null;
|
|
311
|
+
|
|
312
|
+
function _attachDragHandlers(header, wrapper, id) {
|
|
313
|
+
header.addEventListener('dragstart', (e) => {
|
|
314
|
+
// Don't start drag from close button
|
|
315
|
+
if (e.target.closest('.pane-close')) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
_draggedPaneId = id;
|
|
320
|
+
wrapper.classList.add('dragging');
|
|
321
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
322
|
+
e.dataTransfer.setData('text/plain', id);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
header.addEventListener('dragend', () => {
|
|
326
|
+
wrapper.classList.remove('dragging');
|
|
327
|
+
_draggedPaneId = null;
|
|
328
|
+
// Clean up all drag-over highlights
|
|
329
|
+
document.querySelectorAll('.page-shell .pane.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
wrapper.addEventListener('dragover', (e) => {
|
|
333
|
+
if (!_draggedPaneId || _draggedPaneId === id) return;
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
e.dataTransfer.dropEffect = 'move';
|
|
336
|
+
wrapper.classList.add('drag-over');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
wrapper.addEventListener('dragleave', (e) => {
|
|
340
|
+
// Only remove if we actually left this pane (not entering a child)
|
|
341
|
+
if (!wrapper.contains(e.relatedTarget)) {
|
|
342
|
+
wrapper.classList.remove('drag-over');
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
wrapper.addEventListener('drop', (e) => {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
wrapper.classList.remove('drag-over');
|
|
349
|
+
if (!_draggedPaneId || _draggedPaneId === id) return;
|
|
350
|
+
|
|
351
|
+
const container = wrapper.parentElement;
|
|
352
|
+
if (!container) return;
|
|
353
|
+
|
|
354
|
+
const draggedPane = panes.get(_draggedPaneId);
|
|
355
|
+
const targetPane = panes.get(id);
|
|
356
|
+
if (!draggedPane || !targetPane) return;
|
|
357
|
+
|
|
358
|
+
// Swap DOM positions
|
|
359
|
+
const draggedEl = draggedPane.element;
|
|
360
|
+
const targetEl = targetPane.element;
|
|
361
|
+
const draggedNext = draggedEl.nextElementSibling;
|
|
362
|
+
const targetNext = targetEl.nextElementSibling;
|
|
363
|
+
|
|
364
|
+
if (draggedNext === targetEl) {
|
|
365
|
+
// Dragged is immediately before target
|
|
366
|
+
container.insertBefore(targetEl, draggedEl);
|
|
367
|
+
} else if (targetNext === draggedEl) {
|
|
368
|
+
// Target is immediately before dragged
|
|
369
|
+
container.insertBefore(draggedEl, targetEl);
|
|
370
|
+
} else {
|
|
371
|
+
// General case: swap positions
|
|
372
|
+
const placeholder = document.createElement('div');
|
|
373
|
+
container.insertBefore(placeholder, draggedEl);
|
|
374
|
+
container.insertBefore(draggedEl, targetNext);
|
|
375
|
+
container.insertBefore(targetEl, placeholder);
|
|
376
|
+
placeholder.remove();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Build new order from DOM
|
|
380
|
+
const newOrder = [...container.querySelectorAll('.pane[data-id]')]
|
|
381
|
+
.map(el => el.dataset.id)
|
|
382
|
+
.filter(pid => panes.has(pid));
|
|
383
|
+
|
|
384
|
+
// Reorder tabs in the tab bar to match
|
|
385
|
+
const tabBar = document.querySelector('.page-shell .shell-tab-bar');
|
|
386
|
+
if (tabBar) {
|
|
387
|
+
for (const pid of newOrder) {
|
|
388
|
+
const tab = tabBar.querySelector(`.shell-tab[data-tab="${pid}"]`);
|
|
389
|
+
if (tab) tabBar.appendChild(tab);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Emit to server for persistence
|
|
394
|
+
socket.emit('state:reorder-panes', { order: newOrder });
|
|
395
|
+
|
|
396
|
+
_draggedPaneId = null;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Terminal pane creation ──────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
function createTerminalPane({ id, shellType, title, onClose, onFocus, skipInitialFit = false }) {
|
|
403
|
+
const wrapper = document.createElement('div');
|
|
404
|
+
wrapper.className = 'pane';
|
|
405
|
+
wrapper.dataset.id = id;
|
|
406
|
+
|
|
407
|
+
const header = document.createElement('div');
|
|
408
|
+
header.className = 'pane-header';
|
|
409
|
+
header.draggable = true;
|
|
410
|
+
|
|
411
|
+
const titleEl = document.createElement('span');
|
|
412
|
+
titleEl.className = 'pane-title';
|
|
413
|
+
titleEl.textContent = title;
|
|
414
|
+
|
|
415
|
+
const closeBtn = document.createElement('button');
|
|
416
|
+
closeBtn.className = 'pane-close';
|
|
417
|
+
closeBtn.title = 'Close';
|
|
418
|
+
closeBtn.textContent = '\u2715';
|
|
419
|
+
|
|
420
|
+
header.appendChild(titleEl);
|
|
421
|
+
header.appendChild(closeBtn);
|
|
422
|
+
|
|
423
|
+
// ── Drag-to-reorder ──────────────────────────────────────────────
|
|
424
|
+
_attachDragHandlers(header, wrapper, id);
|
|
425
|
+
|
|
426
|
+
const termDiv = document.createElement('div');
|
|
427
|
+
termDiv.className = 'pane-terminal';
|
|
428
|
+
|
|
429
|
+
wrapper.appendChild(header);
|
|
430
|
+
wrapper.appendChild(termDiv);
|
|
431
|
+
|
|
432
|
+
// xterm.js
|
|
433
|
+
const term = new window.Terminal({
|
|
434
|
+
theme: TERMINAL_THEME,
|
|
435
|
+
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace",
|
|
436
|
+
fontSize: 14,
|
|
437
|
+
lineHeight: 1.2,
|
|
438
|
+
cursorBlink: true,
|
|
439
|
+
allowProposedApi: true,
|
|
440
|
+
scrollback: 5000,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const fitAddon = new window.FitAddon.FitAddon();
|
|
444
|
+
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
445
|
+
term.loadAddon(fitAddon);
|
|
446
|
+
term.loadAddon(webLinksAddon);
|
|
447
|
+
|
|
448
|
+
term.open(termDiv);
|
|
449
|
+
|
|
450
|
+
let disposed = false;
|
|
451
|
+
|
|
452
|
+
// Alternate screen buffer detection
|
|
453
|
+
term.buffer.onBufferChange((buf) => {
|
|
454
|
+
if (buf.type === 'alternate') {
|
|
455
|
+
termDiv.classList.add('alt-screen');
|
|
456
|
+
} else {
|
|
457
|
+
termDiv.classList.remove('alt-screen');
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Fallback copy
|
|
462
|
+
function _fallbackCopy(text) {
|
|
463
|
+
const ta = document.createElement('textarea');
|
|
464
|
+
ta.value = text;
|
|
465
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
|
466
|
+
document.body.appendChild(ta);
|
|
467
|
+
ta.select();
|
|
468
|
+
try { document.execCommand('copy'); } catch {}
|
|
469
|
+
document.body.removeChild(ta);
|
|
470
|
+
term.focus();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Custom key event handler
|
|
474
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
475
|
+
if (typeof KeymapRegistry !== 'undefined') {
|
|
476
|
+
const action = KeymapRegistry.resolve(e);
|
|
477
|
+
if (action && (action.startsWith('shell:') || action.startsWith('voice:'))) return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (e.type !== 'keydown') return true;
|
|
481
|
+
|
|
482
|
+
// Ctrl+C / Ctrl+Shift+C / Cmd+C -> copy
|
|
483
|
+
if ((e.ctrlKey && e.code === 'KeyC' && !e.shiftKey && !e.altKey) ||
|
|
484
|
+
(e.ctrlKey && e.shiftKey && e.code === 'KeyC' && !e.altKey) ||
|
|
485
|
+
(isMac && e.metaKey && e.code === 'KeyC' && !e.altKey)) {
|
|
486
|
+
const sel = term.getSelection();
|
|
487
|
+
if (sel) {
|
|
488
|
+
e.preventDefault();
|
|
489
|
+
if (navigator.clipboard?.writeText) {
|
|
490
|
+
navigator.clipboard.writeText(sel).catch(() => _fallbackCopy(sel));
|
|
491
|
+
} else {
|
|
492
|
+
_fallbackCopy(sel);
|
|
493
|
+
}
|
|
494
|
+
term.clearSelection();
|
|
495
|
+
term.focus();
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
if (e.shiftKey || (isMac && e.metaKey && !e.ctrlKey)) return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Ctrl+V / Cmd+V -> paste
|
|
502
|
+
if ((e.ctrlKey || (isMac && e.metaKey)) && e.code === 'KeyV' && !e.altKey) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return true;
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Defer fit until element is in DOM and fonts are loaded.
|
|
510
|
+
// Skip during batch creation (snapshot restore) — relayout handles fit after grid is set.
|
|
511
|
+
if (!skipInitialFit) {
|
|
512
|
+
requestAnimationFrame(() => {
|
|
513
|
+
document.fonts.ready.then(() => {
|
|
514
|
+
if (disposed) return;
|
|
515
|
+
fitAddon.fit();
|
|
516
|
+
socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
|
|
517
|
+
term.scrollToBottom();
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Mobile keyboard management
|
|
523
|
+
if (isMobileDevice) {
|
|
524
|
+
requestAnimationFrame(() => {
|
|
525
|
+
const h = termDiv.querySelector('.xterm-helper-textarea');
|
|
526
|
+
if (!h) return;
|
|
527
|
+
h.setAttribute('inputmode', 'none');
|
|
528
|
+
h.addEventListener('blur', () => h.setAttribute('inputmode', 'none'));
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function disableKeyboard() {
|
|
533
|
+
if (!isMobileDevice) return;
|
|
534
|
+
const h = termDiv.querySelector('.xterm-helper-textarea');
|
|
535
|
+
if (!h) return;
|
|
536
|
+
h.setAttribute('inputmode', 'none');
|
|
537
|
+
h.blur();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function enableKeyboard() {
|
|
541
|
+
if (!isMobileDevice) return;
|
|
542
|
+
const h = termDiv.querySelector('.xterm-helper-textarea');
|
|
543
|
+
if (h) h.removeAttribute('inputmode');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Auto-scroll tracking — only scroll to bottom when user hasn't scrolled up
|
|
547
|
+
let _atBottom = true;
|
|
548
|
+
term.onScroll(() => {
|
|
549
|
+
const buf = term.buffer.active;
|
|
550
|
+
_atBottom = buf.viewportY >= buf.baseY;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
function autoScroll() {
|
|
554
|
+
if (_atBottom) term.scrollToBottom();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Socket handlers
|
|
558
|
+
const dataHandler = ({ id: eid, data }) => {
|
|
559
|
+
if (disposed) return;
|
|
560
|
+
if (eid === id) {
|
|
561
|
+
term.write(data);
|
|
562
|
+
autoScroll();
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const exitHandler = ({ id: eid, code }) => {
|
|
566
|
+
if (disposed) return;
|
|
567
|
+
if (eid === id) {
|
|
568
|
+
term.write(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m\r\n`);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
socket.on('terminal:data', dataHandler);
|
|
573
|
+
socket.on('terminal:exit', exitHandler);
|
|
574
|
+
|
|
575
|
+
// Terminal input -> socket (with CSI/OSC response filtering)
|
|
576
|
+
const csiResponseRe =
|
|
577
|
+
/\x1b\[\??\d+;\d+R|\x1b\[\?[\d;]+c|\x1b\[>[\d;]+c|\x1b\[[03]n|\x1b\[\?[\d;]+\$y/g;
|
|
578
|
+
const oscResponseRe =
|
|
579
|
+
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
580
|
+
|
|
581
|
+
const createdAt = Date.now();
|
|
582
|
+
const FILTER_WINDOW_MS = 3000;
|
|
583
|
+
|
|
584
|
+
term.onData((data) => {
|
|
585
|
+
let out = data;
|
|
586
|
+
if (Date.now() - createdAt < FILTER_WINDOW_MS) {
|
|
587
|
+
out = data.replace(csiResponseRe, '').replace(oscResponseRe, '');
|
|
588
|
+
}
|
|
589
|
+
if (out) socket.emit('terminal:input', { id, data: out });
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Resize observer — suppressed during batch restore to prevent premature fits
|
|
593
|
+
let roTimer;
|
|
594
|
+
const ro = new ResizeObserver(() => {
|
|
595
|
+
if (disposed || _restoring) return;
|
|
596
|
+
clearTimeout(roTimer);
|
|
597
|
+
roTimer = setTimeout(() => {
|
|
598
|
+
if (disposed || _restoring) return;
|
|
599
|
+
try {
|
|
600
|
+
fitAddon.fit();
|
|
601
|
+
autoScroll();
|
|
602
|
+
socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
|
|
603
|
+
} catch {}
|
|
604
|
+
}, 100);
|
|
605
|
+
});
|
|
606
|
+
ro.observe(termDiv);
|
|
607
|
+
|
|
608
|
+
// Focus tracking
|
|
609
|
+
let lastTapTime = 0;
|
|
610
|
+
termDiv.addEventListener('pointerdown', (e) => {
|
|
611
|
+
if (e.pointerType === 'touch') {
|
|
612
|
+
const now = Date.now();
|
|
613
|
+
const doubleTap = now - lastTapTime < 300;
|
|
614
|
+
lastTapTime = now;
|
|
615
|
+
if (doubleTap) {
|
|
616
|
+
enableKeyboard();
|
|
617
|
+
onFocus(id, false);
|
|
618
|
+
} else {
|
|
619
|
+
onFocus(id, true);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
onFocus(id, false);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Close button
|
|
627
|
+
closeBtn.addEventListener('click', () => destroy());
|
|
628
|
+
|
|
629
|
+
function fit() {
|
|
630
|
+
try {
|
|
631
|
+
fitAddon.fit();
|
|
632
|
+
autoScroll();
|
|
633
|
+
socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
|
|
634
|
+
} catch {}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function sendInput(text) {
|
|
638
|
+
socket.emit('terminal:input', { id, data: text });
|
|
639
|
+
term.focus();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function destroy() {
|
|
643
|
+
socket.emit('terminal:close', { id });
|
|
644
|
+
onClose(id);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function cleanup() {
|
|
648
|
+
disposed = true;
|
|
649
|
+
clearTimeout(roTimer);
|
|
650
|
+
socket.off('terminal:data', dataHandler);
|
|
651
|
+
socket.off('terminal:exit', exitHandler);
|
|
652
|
+
ro.disconnect();
|
|
653
|
+
term.dispose();
|
|
654
|
+
wrapper.remove();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function setTitle(text) {
|
|
658
|
+
titleEl.textContent = text;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function writeScrollback(data) {
|
|
662
|
+
if (!data) return;
|
|
663
|
+
term.write(data, () => {
|
|
664
|
+
// Sync alt-screen class after replay — the onBufferChange event may have
|
|
665
|
+
// been missed if the alt-screen entry sequence was truncated from scrollback.
|
|
666
|
+
if (term.buffer.active.type === 'alternate') {
|
|
667
|
+
termDiv.classList.add('alt-screen');
|
|
668
|
+
} else {
|
|
669
|
+
termDiv.classList.remove('alt-screen');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// During batch restore, skip — centralized fit + scroll happens after grid is set
|
|
673
|
+
if (_restoring || disposed) return;
|
|
674
|
+
requestAnimationFrame(() => {
|
|
675
|
+
document.fonts.ready.then(() => {
|
|
676
|
+
if (disposed) return;
|
|
677
|
+
if (termDiv.offsetWidth > 0 && termDiv.offsetHeight > 0) {
|
|
678
|
+
fitAddon.fit();
|
|
679
|
+
socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
|
|
680
|
+
}
|
|
681
|
+
_atBottom = true;
|
|
682
|
+
term.scrollToBottom();
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function scrollToBottom() { _atBottom = true; term.scrollToBottom(); }
|
|
689
|
+
|
|
690
|
+
return { id, element: wrapper, fit, sendInput, destroy, cleanup, setTitle, disableKeyboard, writeScrollback, scrollToBottom };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Browser pane creation ───────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
function createBrowserPaneLocal({ id, url, title, onClose, onFocus, onTitleChange }) {
|
|
696
|
+
const wrapper = document.createElement('div');
|
|
697
|
+
wrapper.className = 'pane pane-browser';
|
|
698
|
+
wrapper.dataset.id = id;
|
|
699
|
+
|
|
700
|
+
const header = document.createElement('div');
|
|
701
|
+
header.className = 'pane-header';
|
|
702
|
+
header.draggable = true;
|
|
703
|
+
|
|
704
|
+
const titleEl = document.createElement('span');
|
|
705
|
+
titleEl.className = 'pane-title';
|
|
706
|
+
titleEl.textContent = title || 'Browser';
|
|
707
|
+
|
|
708
|
+
const closeBtn = document.createElement('button');
|
|
709
|
+
closeBtn.className = 'pane-close';
|
|
710
|
+
closeBtn.title = 'Close';
|
|
711
|
+
closeBtn.textContent = '\u2715';
|
|
712
|
+
|
|
713
|
+
header.appendChild(titleEl);
|
|
714
|
+
header.appendChild(closeBtn);
|
|
715
|
+
|
|
716
|
+
// ── Drag-to-reorder ──────────────────────────────────────────────
|
|
717
|
+
_attachDragHandlers(header, wrapper, id);
|
|
718
|
+
|
|
719
|
+
// Navigation bar
|
|
720
|
+
const navBar = document.createElement('div');
|
|
721
|
+
navBar.className = 'browser-nav';
|
|
722
|
+
|
|
723
|
+
const backBtn = document.createElement('button');
|
|
724
|
+
backBtn.className = 'browser-nav-btn';
|
|
725
|
+
backBtn.textContent = '\u2190';
|
|
726
|
+
backBtn.title = 'Back';
|
|
727
|
+
|
|
728
|
+
const fwdBtn = document.createElement('button');
|
|
729
|
+
fwdBtn.className = 'browser-nav-btn';
|
|
730
|
+
fwdBtn.textContent = '\u2192';
|
|
731
|
+
fwdBtn.title = 'Forward';
|
|
732
|
+
|
|
733
|
+
const reloadBtn = document.createElement('button');
|
|
734
|
+
reloadBtn.className = 'browser-nav-btn';
|
|
735
|
+
reloadBtn.textContent = '\u21BB';
|
|
736
|
+
reloadBtn.title = 'Reload';
|
|
737
|
+
|
|
738
|
+
const urlInput = document.createElement('input');
|
|
739
|
+
urlInput.type = 'text';
|
|
740
|
+
urlInput.className = 'browser-url-input';
|
|
741
|
+
urlInput.value = url || '';
|
|
742
|
+
urlInput.placeholder = 'Enter URL...';
|
|
743
|
+
urlInput.spellcheck = false;
|
|
744
|
+
|
|
745
|
+
navBar.append(backBtn, fwdBtn, reloadBtn, urlInput);
|
|
746
|
+
|
|
747
|
+
// Iframe
|
|
748
|
+
const iframe = document.createElement('iframe');
|
|
749
|
+
iframe.className = 'browser-iframe';
|
|
750
|
+
iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
|
751
|
+
iframe.allow = 'autoplay; fullscreen; focus-without-user-activation';
|
|
752
|
+
iframe.setAttribute('referrerpolicy', 'no-referrer');
|
|
753
|
+
|
|
754
|
+
// Error overlay
|
|
755
|
+
const errorOverlay = document.createElement('div');
|
|
756
|
+
errorOverlay.className = 'browser-error';
|
|
757
|
+
errorOverlay.style.display = 'none';
|
|
758
|
+
|
|
759
|
+
// Loading indicator
|
|
760
|
+
const loadingEl = document.createElement('div');
|
|
761
|
+
loadingEl.className = 'browser-error';
|
|
762
|
+
loadingEl.textContent = 'Loading\u2026';
|
|
763
|
+
loadingEl.style.display = 'none';
|
|
764
|
+
|
|
765
|
+
const iframeWrap = document.createElement('div');
|
|
766
|
+
iframeWrap.className = 'browser-viewport';
|
|
767
|
+
iframeWrap.appendChild(iframe);
|
|
768
|
+
iframeWrap.appendChild(errorOverlay);
|
|
769
|
+
iframeWrap.appendChild(loadingEl);
|
|
770
|
+
|
|
771
|
+
wrapper.append(header, navBar, iframeWrap);
|
|
772
|
+
|
|
773
|
+
// URL rewriting
|
|
774
|
+
function resolveUrl(rawUrl) {
|
|
775
|
+
try {
|
|
776
|
+
const u = new URL(rawUrl);
|
|
777
|
+
const isYouTube = /^(www\.)?youtube\.com$/.test(u.hostname);
|
|
778
|
+
if (isYouTube && u.pathname === '/watch') {
|
|
779
|
+
const v = u.searchParams.get('v');
|
|
780
|
+
if (v) return { url: `https://www.youtube.com/embed/${v}?autoplay=1`, isEmbed: true };
|
|
781
|
+
}
|
|
782
|
+
if (u.hostname === 'youtu.be') {
|
|
783
|
+
const v = u.pathname.slice(1);
|
|
784
|
+
if (v) return { url: `https://www.youtube.com/embed/${v}?autoplay=1`, isEmbed: true };
|
|
785
|
+
}
|
|
786
|
+
if (isYouTube && u.pathname === '/playlist') {
|
|
787
|
+
const list = u.searchParams.get('list');
|
|
788
|
+
if (list) return { url: `https://www.youtube.com/embed/videoseries?list=${list}`, isEmbed: true };
|
|
789
|
+
}
|
|
790
|
+
if (isYouTube || u.hostname === 'youtu.be') {
|
|
791
|
+
return { url: rawUrl, isEmbed: true };
|
|
792
|
+
}
|
|
793
|
+
} catch {}
|
|
794
|
+
return { url: rawUrl, isEmbed: false };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function _isLocalUrl(rawUrl) {
|
|
798
|
+
try {
|
|
799
|
+
const u = new URL(rawUrl);
|
|
800
|
+
const h = u.hostname;
|
|
801
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1') return true;
|
|
802
|
+
if (/^10\./.test(h)) return true;
|
|
803
|
+
if (/^192\.168\./.test(h)) return true;
|
|
804
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
|
805
|
+
return false;
|
|
806
|
+
} catch {}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const CLICK_INTERCEPTOR = '<script>(function(){' +
|
|
811
|
+
'document.addEventListener("click",function(e){' +
|
|
812
|
+
'var a=e.target.closest("a");' +
|
|
813
|
+
'if(a&&a.href){e.preventDefault();e.stopPropagation();' +
|
|
814
|
+
'window.parent.postMessage({type:"proxy-navigate",url:a.href},"*");}' +
|
|
815
|
+
'},true);' +
|
|
816
|
+
'document.addEventListener("submit",function(e){' +
|
|
817
|
+
'e.preventDefault();e.stopPropagation();' +
|
|
818
|
+
'var f=e.target;var url=f.action||window.location.href;' +
|
|
819
|
+
'window.parent.postMessage({type:"proxy-navigate",url:url},"*");' +
|
|
820
|
+
'},true);' +
|
|
821
|
+
'var _open=window.open;window.open=function(url){' +
|
|
822
|
+
'if(url){window.parent.postMessage({type:"proxy-navigate",url:url},"*");}' +
|
|
823
|
+
'};' +
|
|
824
|
+
'})()<\/script>';
|
|
825
|
+
|
|
826
|
+
// Navigation logic
|
|
827
|
+
let navHistory = [];
|
|
828
|
+
let historyIdx = -1;
|
|
829
|
+
let loadAbort = null;
|
|
830
|
+
|
|
831
|
+
function _extractDomain(rawUrl) {
|
|
832
|
+
try { return new URL(rawUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function _updateTitle(rawUrl) {
|
|
836
|
+
const domain = _extractDomain(rawUrl);
|
|
837
|
+
const label = domain || 'Browser';
|
|
838
|
+
titleEl.textContent = label;
|
|
839
|
+
if (onTitleChange) onTitleChange(id, label);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function _resolveProtocol(rawUrl) {
|
|
843
|
+
if (/^https?:\/\//i.test(rawUrl) || /^\/\//.test(rawUrl)) return rawUrl;
|
|
844
|
+
if (/^\//.test(rawUrl)) return window.location.origin + rawUrl;
|
|
845
|
+
if (/^(localhost|[\w.-]+\.\w{2,})/.test(rawUrl)) {
|
|
846
|
+
return (/^localhost/.test(rawUrl) ? 'http://' : 'https://') + rawUrl;
|
|
847
|
+
}
|
|
848
|
+
return 'https://' + rawUrl;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function navigate(newUrl) {
|
|
852
|
+
if (!newUrl) return;
|
|
853
|
+
if (historyIdx < navHistory.length - 1) {
|
|
854
|
+
navHistory = navHistory.slice(0, historyIdx + 1);
|
|
855
|
+
}
|
|
856
|
+
navHistory.push(newUrl);
|
|
857
|
+
historyIdx = navHistory.length - 1;
|
|
858
|
+
urlInput.value = newUrl;
|
|
859
|
+
updateNavButtons();
|
|
860
|
+
_updateTitle(_resolveProtocol(newUrl));
|
|
861
|
+
_loadUrl(newUrl);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function _loadUrl(rawUrl) {
|
|
865
|
+
const withProto = _resolveProtocol(rawUrl);
|
|
866
|
+
const resolved = resolveUrl(withProto);
|
|
867
|
+
errorOverlay.style.display = 'none';
|
|
868
|
+
loadingEl.style.display = 'none';
|
|
869
|
+
|
|
870
|
+
if (loadAbort) { loadAbort.abort(); loadAbort = null; }
|
|
871
|
+
|
|
872
|
+
if (_isLocalUrl(withProto)) {
|
|
873
|
+
iframe.removeAttribute('srcdoc');
|
|
874
|
+
iframe.src = resolved.url;
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (resolved.isEmbed) {
|
|
879
|
+
iframe.removeAttribute('srcdoc');
|
|
880
|
+
iframe.src = resolved.url;
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
loadAbort = new AbortController();
|
|
885
|
+
loadingEl.style.display = 'flex';
|
|
886
|
+
iframe.removeAttribute('src');
|
|
887
|
+
iframe.srcdoc = '';
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const resp = await fetch(`/proxy?url=${encodeURIComponent(resolved.url)}`, {
|
|
891
|
+
signal: loadAbort.signal,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
if (!resp.ok) {
|
|
895
|
+
const errBody = await resp.text();
|
|
896
|
+
let msg;
|
|
897
|
+
try { msg = JSON.parse(errBody).error; } catch { msg = `HTTP ${resp.status}`; }
|
|
898
|
+
throw new Error(msg);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const finalUrl = resp.headers.get('X-Final-URL') || resolved.url;
|
|
902
|
+
let html = await resp.text();
|
|
903
|
+
|
|
904
|
+
const u = new URL(finalUrl);
|
|
905
|
+
const basePath = u.pathname.replace(/[^/]*$/, '');
|
|
906
|
+
const baseHref = `${u.protocol}//${u.host}${basePath}`;
|
|
907
|
+
|
|
908
|
+
if (/<head[^>]*>/i.test(html)) {
|
|
909
|
+
html = html.replace(/<head([^>]*)>/i, `<head$1><base href="${baseHref}">${CLICK_INTERCEPTOR}`);
|
|
910
|
+
} else if (/<html[^>]*>/i.test(html)) {
|
|
911
|
+
html = html.replace(/<html([^>]*)>/i, `<html$1><head><base href="${baseHref}">${CLICK_INTERCEPTOR}</head>`);
|
|
912
|
+
} else {
|
|
913
|
+
html = `<head><base href="${baseHref}">${CLICK_INTERCEPTOR}</head>${html}`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
html = html.replace(/<link[^>]*rel=["']manifest["'][^>]*>/gi, '');
|
|
917
|
+
html = html.replace(/<meta[^>]*http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '');
|
|
918
|
+
|
|
919
|
+
if (finalUrl !== resolved.url) {
|
|
920
|
+
urlInput.value = finalUrl;
|
|
921
|
+
if (historyIdx >= 0) navHistory[historyIdx] = finalUrl;
|
|
922
|
+
_updateTitle(finalUrl);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
loadingEl.style.display = 'none';
|
|
926
|
+
iframe.srcdoc = html;
|
|
927
|
+
} catch (err) {
|
|
928
|
+
if (err.name === 'AbortError') return;
|
|
929
|
+
loadingEl.style.display = 'none';
|
|
930
|
+
errorOverlay.textContent = `Failed to load: ${err.message}`;
|
|
931
|
+
errorOverlay.style.display = 'flex';
|
|
932
|
+
} finally {
|
|
933
|
+
loadAbort = null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function goBack() {
|
|
938
|
+
if (historyIdx > 0) {
|
|
939
|
+
historyIdx--;
|
|
940
|
+
urlInput.value = navHistory[historyIdx];
|
|
941
|
+
updateNavButtons();
|
|
942
|
+
_updateTitle(_resolveProtocol(navHistory[historyIdx]));
|
|
943
|
+
_loadUrl(navHistory[historyIdx]);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function goForward() {
|
|
948
|
+
if (historyIdx < navHistory.length - 1) {
|
|
949
|
+
historyIdx++;
|
|
950
|
+
urlInput.value = navHistory[historyIdx];
|
|
951
|
+
updateNavButtons();
|
|
952
|
+
_updateTitle(_resolveProtocol(navHistory[historyIdx]));
|
|
953
|
+
_loadUrl(navHistory[historyIdx]);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function reload() {
|
|
958
|
+
if (historyIdx >= 0) {
|
|
959
|
+
errorOverlay.style.display = 'none';
|
|
960
|
+
_loadUrl(navHistory[historyIdx]);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function updateNavButtons() {
|
|
965
|
+
backBtn.disabled = historyIdx <= 0;
|
|
966
|
+
fwdBtn.disabled = historyIdx >= navHistory.length - 1;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Handle navigation from injected click interceptor
|
|
970
|
+
const messageHandler = (e) => {
|
|
971
|
+
if (e.source !== iframe.contentWindow) return;
|
|
972
|
+
if (e.data && e.data.type === 'proxy-navigate') {
|
|
973
|
+
navigate(e.data.url);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
window.addEventListener('message', messageHandler);
|
|
977
|
+
|
|
978
|
+
// Event handlers
|
|
979
|
+
urlInput.addEventListener('keydown', (e) => {
|
|
980
|
+
if (e.key === 'Enter') {
|
|
981
|
+
e.preventDefault();
|
|
982
|
+
navigate(urlInput.value.trim());
|
|
983
|
+
}
|
|
984
|
+
e.stopPropagation();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
urlInput.addEventListener('focus', () => urlInput.select());
|
|
988
|
+
backBtn.addEventListener('click', goBack);
|
|
989
|
+
fwdBtn.addEventListener('click', goForward);
|
|
990
|
+
reloadBtn.addEventListener('click', reload);
|
|
991
|
+
|
|
992
|
+
iframe.addEventListener('error', () => {
|
|
993
|
+
errorOverlay.textContent = 'Failed to load page';
|
|
994
|
+
errorOverlay.style.display = 'flex';
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// Focus tracking
|
|
998
|
+
wrapper.addEventListener('pointerdown', (e) => {
|
|
999
|
+
const isTouch = e.pointerType === 'touch';
|
|
1000
|
+
onFocus(id, isTouch);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Close
|
|
1004
|
+
closeBtn.addEventListener('click', () => destroy());
|
|
1005
|
+
|
|
1006
|
+
function destroy() {
|
|
1007
|
+
socket.emit('terminal:close', { id });
|
|
1008
|
+
onClose(id);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function cleanup() {
|
|
1012
|
+
window.removeEventListener('message', messageHandler);
|
|
1013
|
+
if (loadAbort) { loadAbort.abort(); loadAbort = null; }
|
|
1014
|
+
iframe.removeAttribute('srcdoc');
|
|
1015
|
+
iframe.src = 'about:blank';
|
|
1016
|
+
wrapper.remove();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function setTitle(text) { titleEl.textContent = text; }
|
|
1020
|
+
function fit() {}
|
|
1021
|
+
function scrollToBottom() {}
|
|
1022
|
+
function disableKeyboard() {}
|
|
1023
|
+
function writeScrollback() {}
|
|
1024
|
+
|
|
1025
|
+
updateNavButtons();
|
|
1026
|
+
if (url) navigate(url);
|
|
1027
|
+
|
|
1028
|
+
return {
|
|
1029
|
+
id,
|
|
1030
|
+
element: wrapper,
|
|
1031
|
+
fit,
|
|
1032
|
+
sendInput() {},
|
|
1033
|
+
destroy,
|
|
1034
|
+
cleanup,
|
|
1035
|
+
setTitle,
|
|
1036
|
+
disableKeyboard,
|
|
1037
|
+
writeScrollback,
|
|
1038
|
+
scrollToBottom,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ── Server-driven pane lifecycle ────────────────────────────────────
|
|
1043
|
+
|
|
1044
|
+
async function _addPaneFromServer(refs, { id, shellType, title, num, cwd, url, projectId }, scrollback, skipRelayout = false) {
|
|
1045
|
+
if (panes.has(id)) return;
|
|
1046
|
+
|
|
1047
|
+
// Ensure xterm.js is loaded before creating terminal panes
|
|
1048
|
+
if (shellType !== 'browser' && !window.Terminal) {
|
|
1049
|
+
await ensureXterm();
|
|
1050
|
+
if (!_container) return; // unmounted while waiting
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const onClose = () => { /* state:pane-removed from server handles cleanup */ };
|
|
1054
|
+
const onFocus = (focusedId, isTouch = false) => {
|
|
1055
|
+
const switching = activePaneId !== focusedId;
|
|
1056
|
+
setActivePaneHighlight(focusedId);
|
|
1057
|
+
socket.emit('state:set-active-pane', { paneId: focusedId });
|
|
1058
|
+
if (switching) panes.get(focusedId)?.scrollToBottom();
|
|
1059
|
+
if (!isTouch) {
|
|
1060
|
+
panes.get(focusedId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
const onTitleChange = (paneId, label) => {
|
|
1065
|
+
const fullLabel = makeLabel(panes.get(paneId)?._num ?? '', label);
|
|
1066
|
+
const tabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${paneId}"] .shell-tab-label`);
|
|
1067
|
+
if (tabEl) tabEl.textContent = fullLabel;
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
const browserLabel = url ? undefined : 'Browser';
|
|
1071
|
+
const initialLabel = shellType === 'browser' ? makeLabel(num, browserLabel || title) : title;
|
|
1072
|
+
const pane = shellType === 'browser'
|
|
1073
|
+
? createBrowserPaneLocal({ id, url, title: initialLabel, onClose, onFocus, onTitleChange })
|
|
1074
|
+
: createTerminalPane({ id, shellType, title, onClose, onFocus, skipInitialFit: skipRelayout });
|
|
1075
|
+
|
|
1076
|
+
pane._num = num;
|
|
1077
|
+
pane._cwd = cwd || null;
|
|
1078
|
+
pane._projectId = projectId || null;
|
|
1079
|
+
panes.set(id, pane);
|
|
1080
|
+
refs.paneContainer.appendChild(pane.element);
|
|
1081
|
+
addTab(refs, id, initialLabel);
|
|
1082
|
+
|
|
1083
|
+
// If CWD is known, show folder in label
|
|
1084
|
+
if (cwd) {
|
|
1085
|
+
const folder = cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/';
|
|
1086
|
+
const label = makeLabel(num, folder);
|
|
1087
|
+
pane.setTitle(label);
|
|
1088
|
+
const tabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"] .shell-tab-label`);
|
|
1089
|
+
if (tabEl) tabEl.textContent = label;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (scrollback) pane.writeScrollback(scrollback);
|
|
1093
|
+
|
|
1094
|
+
if (!skipRelayout) relayout(refs);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function _removePaneLocal(refs, id) {
|
|
1098
|
+
const pane = panes.get(id);
|
|
1099
|
+
if (!pane) return;
|
|
1100
|
+
|
|
1101
|
+
const keys = [...panes.keys()];
|
|
1102
|
+
const closedIdx = keys.indexOf(id);
|
|
1103
|
+
const prevKey = closedIdx > 0 ? keys[closedIdx - 1] : keys[closedIdx + 1] ?? null;
|
|
1104
|
+
|
|
1105
|
+
pane.cleanup();
|
|
1106
|
+
panes.delete(id);
|
|
1107
|
+
removeTab(refs, id);
|
|
1108
|
+
|
|
1109
|
+
if (activePaneId === id) setActivePaneHighlight(prevKey);
|
|
1110
|
+
if (activeTab === id) activeTab = 'grid';
|
|
1111
|
+
relayout(refs);
|
|
1112
|
+
|
|
1113
|
+
if (activePaneId && activeTab === 'grid') {
|
|
1114
|
+
setTimeout(() => {
|
|
1115
|
+
panes.get(activePaneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1116
|
+
}, 50);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ── Request a new pane ──────────────────────────────────────────────
|
|
1121
|
+
|
|
1122
|
+
function requestPane({ shellType, cwd }) {
|
|
1123
|
+
socket.emit('terminal:create', {
|
|
1124
|
+
shellType,
|
|
1125
|
+
cwd: cwd || activeProject?.path || null,
|
|
1126
|
+
cols: 80,
|
|
1127
|
+
rows: 24,
|
|
1128
|
+
currentTab: activeTab,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ── Project filtering ───────────────────────────────────────────────
|
|
1133
|
+
|
|
1134
|
+
function _applyProjectFilter(refs) {
|
|
1135
|
+
const pid = activeProject?.id || null;
|
|
1136
|
+
for (const [id, pane] of panes) {
|
|
1137
|
+
const visible = !pid || !pane._projectId || pane._projectId === pid;
|
|
1138
|
+
pane.element.classList.toggle('project-hidden', !visible);
|
|
1139
|
+
const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
|
|
1140
|
+
if (tab) tab.classList.toggle('project-hidden', !visible);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function _switchProject(refs, newProject) {
|
|
1145
|
+
activeProject = newProject;
|
|
1146
|
+
_applyProjectFilter(refs);
|
|
1147
|
+
|
|
1148
|
+
if (activeTab !== 'grid') {
|
|
1149
|
+
const activePane = panes.get(activeTab);
|
|
1150
|
+
if (activePane?.element.classList.contains('project-hidden')) {
|
|
1151
|
+
_applyActiveTab(refs, 'grid');
|
|
1152
|
+
socket.emit('state:set-active-tab', { tabId: 'grid' });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
relayout(refs);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ── Wire socket events ──────────────────────────────────────────────
|
|
1160
|
+
|
|
1161
|
+
function wireSocketEvents(refs) {
|
|
1162
|
+
_socketHandlers['state:snapshot'] = async ({ panes: serverPanes, activeTab: at, activePaneId: ap, scrollbacks, activeProject: snapshotProject }) => {
|
|
1163
|
+
const serverIds = new Set(serverPanes.map(p => p.id));
|
|
1164
|
+
|
|
1165
|
+
if (snapshotProject && !activeProject) activeProject = snapshotProject;
|
|
1166
|
+
|
|
1167
|
+
// Phase 1: Create panes WITHOUT scrollback. Suppress fits during batch
|
|
1168
|
+
// creation so terminals stay at default 80×24 until the grid is set up.
|
|
1169
|
+
_restoring = true;
|
|
1170
|
+
try {
|
|
1171
|
+
for (const paneData of serverPanes) {
|
|
1172
|
+
await _addPaneFromServer(refs, paneData, null /* scrollback deferred */, true);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Clean up panes that no longer exist on server
|
|
1176
|
+
for (const [id, pane] of panes) {
|
|
1177
|
+
if (!serverIds.has(id)) {
|
|
1178
|
+
pane.cleanup();
|
|
1179
|
+
panes.delete(id);
|
|
1180
|
+
removeTab(refs, id);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Clean up orphan tabs
|
|
1185
|
+
for (const tab of refs.tabBar.querySelectorAll('.shell-tab:not([data-tab="grid"])')) {
|
|
1186
|
+
if (!serverIds.has(tab.dataset.tab)) tab.remove();
|
|
1187
|
+
}
|
|
1188
|
+
} finally {
|
|
1189
|
+
_restoring = false;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Phase 2: Establish grid layout and fit terminals to actual container size.
|
|
1193
|
+
_applyProjectFilter(refs);
|
|
1194
|
+
_applyActiveTab(refs, at || 'grid');
|
|
1195
|
+
|
|
1196
|
+
// Phase 3: Write scrollback AFTER terminals are fit to correct dimensions.
|
|
1197
|
+
// This prevents xterm from baking content at 80×24 (wrong line wrapping).
|
|
1198
|
+
// Wait for relayout's deferred fit (rAF → fonts.ready) to complete first.
|
|
1199
|
+
requestAnimationFrame(() => {
|
|
1200
|
+
document.fonts.ready.then(() => {
|
|
1201
|
+
// Fit one more time to be sure dimensions are correct before writing
|
|
1202
|
+
for (const pane of panes.values()) pane.fit();
|
|
1203
|
+
|
|
1204
|
+
for (const paneData of serverPanes) {
|
|
1205
|
+
const sb = scrollbacks?.[paneData.id];
|
|
1206
|
+
if (sb) panes.get(paneData.id)?.writeScrollback(sb);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// Safety re-fit after layout fully settles (handles CSS transitions, late container sizing)
|
|
1212
|
+
setTimeout(() => {
|
|
1213
|
+
if (!_container) return;
|
|
1214
|
+
for (const pane of panes.values()) {
|
|
1215
|
+
pane.fit();
|
|
1216
|
+
}
|
|
1217
|
+
}, 300);
|
|
1218
|
+
|
|
1219
|
+
if (ap) {
|
|
1220
|
+
setActivePaneHighlight(ap);
|
|
1221
|
+
if ((at || 'grid') === 'grid') {
|
|
1222
|
+
setTimeout(() => {
|
|
1223
|
+
panes.get(ap)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1224
|
+
}, 100);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
_socketHandlers['state:pane-added'] = (paneData) => {
|
|
1230
|
+
_addPaneFromServer(refs, paneData, null);
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
_socketHandlers['state:pane-removed'] = ({ id }) => {
|
|
1234
|
+
_removePaneLocal(refs, id);
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
_socketHandlers['state:active-tab'] = ({ tabId }) => {
|
|
1238
|
+
_applyActiveTab(refs, tabId);
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
_socketHandlers['project:active'] = (project) => {
|
|
1242
|
+
_switchProject(refs, project);
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
_socketHandlers['state:active-pane'] = ({ paneId }) => {
|
|
1246
|
+
setActivePaneHighlight(paneId);
|
|
1247
|
+
if (paneId && activeTab === 'grid') {
|
|
1248
|
+
setTimeout(() => {
|
|
1249
|
+
panes.get(paneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1250
|
+
}, 50);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
_socketHandlers['terminal:cwd'] = ({ id, cwd }) => {
|
|
1255
|
+
const pane = panes.get(id);
|
|
1256
|
+
if (!pane) return;
|
|
1257
|
+
pane._cwd = cwd;
|
|
1258
|
+
const folder = cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/';
|
|
1259
|
+
const label = makeLabel(pane._num, folder);
|
|
1260
|
+
const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
|
|
1261
|
+
if (tab) {
|
|
1262
|
+
tab.querySelector('.shell-tab-label').textContent = label;
|
|
1263
|
+
tab.title = cwd;
|
|
1264
|
+
}
|
|
1265
|
+
pane.setTitle(label);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
_socketHandlers['state:panes-reordered'] = ({ order }) => {
|
|
1269
|
+
if (!Array.isArray(order)) return;
|
|
1270
|
+
// Reorder DOM elements to match server order
|
|
1271
|
+
for (const id of order) {
|
|
1272
|
+
const pane = panes.get(id);
|
|
1273
|
+
if (pane && !pane.element.classList.contains('project-hidden')) {
|
|
1274
|
+
refs.paneContainer.appendChild(pane.element);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
// Also reorder tabs to match
|
|
1278
|
+
for (const id of order) {
|
|
1279
|
+
const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
|
|
1280
|
+
if (tab) refs.tabBar.appendChild(tab);
|
|
1281
|
+
}
|
|
1282
|
+
// Re-fit after DOM reorder — moving elements can change terminal dimensions
|
|
1283
|
+
relayout(refs);
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
_socketHandlers['state:panes-renumbered'] = (updates) => {
|
|
1287
|
+
for (const { id, num } of updates) {
|
|
1288
|
+
const pane = panes.get(id);
|
|
1289
|
+
if (!pane) continue;
|
|
1290
|
+
pane._num = num;
|
|
1291
|
+
const folder = pane._cwd
|
|
1292
|
+
? pane._cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/'
|
|
1293
|
+
: null;
|
|
1294
|
+
const label = makeLabel(num, folder);
|
|
1295
|
+
pane.setTitle(label);
|
|
1296
|
+
const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
|
|
1297
|
+
if (tab) tab.querySelector('.shell-tab-label').textContent = label;
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
_socketHandlers['disconnect'] = () => {
|
|
1302
|
+
refs.disconnect.style.display = 'flex';
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
_socketHandlers['connect'] = () => {
|
|
1306
|
+
refs.disconnect.style.display = 'none';
|
|
1307
|
+
// Re-sync after reconnect — snapshot handler safely skips existing panes
|
|
1308
|
+
socket.emit('state:request-snapshot');
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// Register all handlers
|
|
1312
|
+
for (const [event, handler] of Object.entries(_socketHandlers)) {
|
|
1313
|
+
socket.on(event, handler);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function unwireSocketEvents() {
|
|
1318
|
+
for (const [event, handler] of Object.entries(_socketHandlers)) {
|
|
1319
|
+
socket.off(event, handler);
|
|
1320
|
+
}
|
|
1321
|
+
// Clear the handlers map
|
|
1322
|
+
for (const key of Object.keys(_socketHandlers)) {
|
|
1323
|
+
delete _socketHandlers[key];
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// ── Exports ─────────────────────────────────────────────────────────
|
|
1328
|
+
|
|
1329
|
+
export async function mount(container, ctx) {
|
|
1330
|
+
_container = container;
|
|
1331
|
+
|
|
1332
|
+
// 1. Scope the container
|
|
1333
|
+
container.classList.add('page-shell');
|
|
1334
|
+
|
|
1335
|
+
// 2. Build HTML
|
|
1336
|
+
container.innerHTML = BODY_HTML;
|
|
1337
|
+
|
|
1338
|
+
// 3. Get refs
|
|
1339
|
+
const refs = getRefs(container);
|
|
1340
|
+
|
|
1341
|
+
// 4. Set initial project
|
|
1342
|
+
activeProject = ctx?.project || null;
|
|
1343
|
+
|
|
1344
|
+
// 5. Load xterm.js dynamically
|
|
1345
|
+
await ensureXterm();
|
|
1346
|
+
|
|
1347
|
+
// Guard: if unmounted while loading xterm, bail out
|
|
1348
|
+
if (!_container) return;
|
|
1349
|
+
|
|
1350
|
+
// 6. Wire socket events
|
|
1351
|
+
wireSocketEvents(refs);
|
|
1352
|
+
|
|
1353
|
+
if (panes.size > 0) {
|
|
1354
|
+
// Reattach existing panes (returning from another page)
|
|
1355
|
+
for (const [id, pane] of panes) {
|
|
1356
|
+
refs.paneContainer.appendChild(pane.element);
|
|
1357
|
+
const title = pane.element.querySelector('.pane-title')?.textContent || '';
|
|
1358
|
+
addTab(refs, id, title);
|
|
1359
|
+
}
|
|
1360
|
+
_applyProjectFilter(refs);
|
|
1361
|
+
_applyActiveTab(refs, activeTab);
|
|
1362
|
+
|
|
1363
|
+
// Re-fit after reattachment and auto-focus the active terminal
|
|
1364
|
+
requestAnimationFrame(() => {
|
|
1365
|
+
document.fonts.ready.then(() => {
|
|
1366
|
+
for (const pane of panes.values()) {
|
|
1367
|
+
pane.fit();
|
|
1368
|
+
pane.scrollToBottom();
|
|
1369
|
+
}
|
|
1370
|
+
// Delayed focus to avoid double-cursor flicker with apps like Claude Code
|
|
1371
|
+
if (!isMobile()) {
|
|
1372
|
+
const focusId = activeTab !== 'grid' ? activeTab : activePaneId;
|
|
1373
|
+
if (focusId) {
|
|
1374
|
+
setTimeout(() => {
|
|
1375
|
+
panes.get(focusId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1376
|
+
}, 300);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
} else {
|
|
1382
|
+
// Fresh mount — request snapshot from server
|
|
1383
|
+
socket.emit('state:request-snapshot');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// 7. Wire Grid tab click
|
|
1387
|
+
refs.tabBar.querySelector('[data-tab="grid"]').addEventListener('click', () => setActiveTab(refs, 'grid'));
|
|
1388
|
+
|
|
1389
|
+
// 7a. Wire mobile action buttons (new terminal / new browser)
|
|
1390
|
+
refs.mobileActions?.addEventListener('click', (e) => {
|
|
1391
|
+
const btn = e.target.closest('[data-action]');
|
|
1392
|
+
if (!btn) return;
|
|
1393
|
+
if (btn.dataset.action === 'new-terminal') requestPane({ shellType: 'default' });
|
|
1394
|
+
if (btn.dataset.action === 'new-browser') socket.emit('browser:create', { url: '', currentTab: activeTab });
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// 7b. Auto-focus active terminal when clicking the shell container background
|
|
1398
|
+
container.addEventListener('click', (e) => {
|
|
1399
|
+
if (isMobile()) return;
|
|
1400
|
+
// Only handle clicks on the container/pane-container background, not on interactive elements
|
|
1401
|
+
const target = e.target;
|
|
1402
|
+
if (target !== container && target !== refs.paneContainer && !target.matches('.shell-empty-state, .shell-empty-state *')) return;
|
|
1403
|
+
const focusId = activeTab !== 'grid' ? activeTab : activePaneId;
|
|
1404
|
+
if (focusId) {
|
|
1405
|
+
setTimeout(() => {
|
|
1406
|
+
panes.get(focusId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1407
|
+
}, 300);
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
// 8. Voice input — listen for voice:result on document
|
|
1412
|
+
_voiceHandler = (e) => {
|
|
1413
|
+
const text = e.detail?.text;
|
|
1414
|
+
if (text && activePaneId) panes.get(activePaneId)?.sendInput(text);
|
|
1415
|
+
};
|
|
1416
|
+
document.addEventListener('voice:result', _voiceHandler);
|
|
1417
|
+
|
|
1418
|
+
// 9. Keyboard shortcuts
|
|
1419
|
+
_keydownHandler = (e) => {
|
|
1420
|
+
if (typeof KeymapRegistry === 'undefined') return;
|
|
1421
|
+
const action = KeymapRegistry.resolve(e);
|
|
1422
|
+
if (!action) return;
|
|
1423
|
+
|
|
1424
|
+
switch (action) {
|
|
1425
|
+
case 'shell:terminal-up':
|
|
1426
|
+
case 'shell:terminal-down':
|
|
1427
|
+
case 'shell:terminal-left':
|
|
1428
|
+
case 'shell:terminal-right': {
|
|
1429
|
+
e.preventDefault();
|
|
1430
|
+
if (activeTab !== 'grid') return;
|
|
1431
|
+
const ids = [...panes.entries()]
|
|
1432
|
+
.filter(([, p]) => !p.element.classList.contains('project-hidden'))
|
|
1433
|
+
.map(([id]) => id);
|
|
1434
|
+
if (ids.length === 0) return;
|
|
1435
|
+
const idx = ids.indexOf(activePaneId);
|
|
1436
|
+
if (idx === -1) return;
|
|
1437
|
+
const count = ids.length;
|
|
1438
|
+
const mobile = window.innerWidth <= 640;
|
|
1439
|
+
const cols = mobile ? 1 : count === 1 ? 1 : count <= 4 ? 2 : 3;
|
|
1440
|
+
const row = Math.floor(idx / cols);
|
|
1441
|
+
const col = idx % cols;
|
|
1442
|
+
let target = idx;
|
|
1443
|
+
if (action === 'shell:terminal-right') target = row * cols + Math.min(col + 1, Math.min(cols, count - row * cols) - 1);
|
|
1444
|
+
else if (action === 'shell:terminal-left') target = row * cols + Math.max(col - 1, 0);
|
|
1445
|
+
else if (action === 'shell:terminal-down') {
|
|
1446
|
+
const below = (row + 1) * cols + col;
|
|
1447
|
+
target = below < count ? below : idx;
|
|
1448
|
+
} else if (action === 'shell:terminal-up') {
|
|
1449
|
+
const above = (row - 1) * cols + col;
|
|
1450
|
+
target = above >= 0 ? above : idx;
|
|
1451
|
+
}
|
|
1452
|
+
if (target === idx) break;
|
|
1453
|
+
const targetId = ids[target];
|
|
1454
|
+
setActivePaneHighlight(targetId);
|
|
1455
|
+
socket.emit('state:set-active-pane', { paneId: targetId });
|
|
1456
|
+
panes.get(targetId)?.scrollToBottom();
|
|
1457
|
+
panes.get(targetId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
case 'shell:new-terminal': {
|
|
1461
|
+
e.preventDefault();
|
|
1462
|
+
requestPane({ shellType: 'default' });
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
case 'shell:new-browser': {
|
|
1466
|
+
e.preventDefault();
|
|
1467
|
+
socket.emit('browser:create', { url: '', currentTab: activeTab });
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
case 'shell:close-pane': {
|
|
1471
|
+
e.preventDefault();
|
|
1472
|
+
if (activePaneId) panes.get(activePaneId)?.destroy();
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
default: {
|
|
1476
|
+
const termMatch = action.match(/^shell:terminal-(\d)$/);
|
|
1477
|
+
if (termMatch) {
|
|
1478
|
+
e.preventDefault();
|
|
1479
|
+
const num = parseInt(termMatch[1]);
|
|
1480
|
+
const termTabs = [...refs.tabBar.querySelectorAll('.shell-tab:not(.project-hidden)')]
|
|
1481
|
+
.map(t => t.dataset.tab)
|
|
1482
|
+
.filter(id => id !== 'grid');
|
|
1483
|
+
const targetId = termTabs[num - 1];
|
|
1484
|
+
if (targetId) setActiveTab(refs, targetId);
|
|
1485
|
+
}
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
document.addEventListener('keydown', _keydownHandler);
|
|
1491
|
+
|
|
1492
|
+
// 10. Swipe navigation (mobile)
|
|
1493
|
+
let touchStartX = 0;
|
|
1494
|
+
refs.paneContainer.addEventListener('touchstart', (e) => {
|
|
1495
|
+
touchStartX = e.touches[0].clientX;
|
|
1496
|
+
}, { passive: true });
|
|
1497
|
+
|
|
1498
|
+
refs.paneContainer.addEventListener('touchend', (e) => {
|
|
1499
|
+
const dx = e.changedTouches[0].clientX - touchStartX;
|
|
1500
|
+
if (Math.abs(dx) >= 50) {
|
|
1501
|
+
const ids = getNavigableTabs(refs);
|
|
1502
|
+
const idx = ids.indexOf(activeTab);
|
|
1503
|
+
if (idx === -1) return;
|
|
1504
|
+
const next = dx < 0
|
|
1505
|
+
? (idx + 1) % ids.length
|
|
1506
|
+
: (idx - 1 + ids.length) % ids.length;
|
|
1507
|
+
setActiveTab(refs, ids[next]);
|
|
1508
|
+
}
|
|
1509
|
+
}, { passive: true });
|
|
1510
|
+
|
|
1511
|
+
// 11. Window resize handler
|
|
1512
|
+
const resizeHandler = () => {
|
|
1513
|
+
clearTimeout(_resizeTimer);
|
|
1514
|
+
_resizeTimer = setTimeout(() => relayout(refs), 100);
|
|
1515
|
+
};
|
|
1516
|
+
window.addEventListener('resize', resizeHandler);
|
|
1517
|
+
// Store for cleanup
|
|
1518
|
+
_container._resizeHandler = resizeHandler;
|
|
1519
|
+
|
|
1520
|
+
_mountedOnce = true;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
export function unmount(container) {
|
|
1524
|
+
// 1. Remove keyboard handler
|
|
1525
|
+
if (_keydownHandler) {
|
|
1526
|
+
document.removeEventListener('keydown', _keydownHandler);
|
|
1527
|
+
_keydownHandler = null;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// 2. Remove voice handler
|
|
1531
|
+
if (_voiceHandler) {
|
|
1532
|
+
document.removeEventListener('voice:result', _voiceHandler);
|
|
1533
|
+
_voiceHandler = null;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// 3. Remove resize handler
|
|
1537
|
+
if (container._resizeHandler) {
|
|
1538
|
+
window.removeEventListener('resize', container._resizeHandler);
|
|
1539
|
+
container._resizeHandler = null;
|
|
1540
|
+
}
|
|
1541
|
+
clearTimeout(_resizeTimer);
|
|
1542
|
+
_resizeTimer = null;
|
|
1543
|
+
|
|
1544
|
+
// 4. Unwire module-level socket events (per-pane handlers stay wired)
|
|
1545
|
+
unwireSocketEvents();
|
|
1546
|
+
|
|
1547
|
+
// 5. Detach pane elements (keep xterm instances alive for reattachment)
|
|
1548
|
+
for (const [, pane] of panes) {
|
|
1549
|
+
pane.element.remove();
|
|
1550
|
+
}
|
|
1551
|
+
// Don't clear panes, activePaneId, or activeTab — they persist across navigations
|
|
1552
|
+
|
|
1553
|
+
// 6. Remove scope class & clear HTML
|
|
1554
|
+
_restoring = false;
|
|
1555
|
+
container.classList.remove('page-shell');
|
|
1556
|
+
container.innerHTML = '';
|
|
1557
|
+
|
|
1558
|
+
// 7. Clear module reference
|
|
1559
|
+
_container = null;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
export function onProjectChange(project) {
|
|
1563
|
+
activeProject = project;
|
|
1564
|
+
if (!_container) return;
|
|
1565
|
+
const refs = getRefs(_container);
|
|
1566
|
+
_applyProjectFilter(refs);
|
|
1567
|
+
|
|
1568
|
+
if (activeTab !== 'grid') {
|
|
1569
|
+
const activePane = panes.get(activeTab);
|
|
1570
|
+
if (activePane?.element.classList.contains('project-hidden')) {
|
|
1571
|
+
_applyActiveTab(refs, 'grid');
|
|
1572
|
+
socket.emit('state:set-active-tab', { tabId: 'grid' });
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
relayout(refs);
|
|
1577
|
+
}
|