@donotdev/cli 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dependencies-matrix.json +205 -50
- package/dist/bin/commands/agent-setup.js +2 -2
- package/dist/bin/commands/build.js +6 -6
- package/dist/bin/commands/bump.js +495 -70
- package/dist/bin/commands/cacheout.js +6 -6
- package/dist/bin/commands/coach.js +6 -6
- package/dist/bin/commands/create-app.js +24 -16
- package/dist/bin/commands/create-project.js +114 -18
- package/dist/bin/commands/db.js +142136 -0
- package/dist/bin/commands/deploy.js +354 -126
- package/dist/bin/commands/dev.js +6 -6
- package/dist/bin/commands/doctor.js +140 -33
- package/dist/bin/commands/emu.js +6 -6
- package/dist/bin/commands/format.js +6 -6
- package/dist/bin/commands/get-demo.js +11 -6
- package/dist/bin/commands/make-admin.js +14210 -13770
- package/dist/bin/commands/preview.js +6 -6
- package/dist/bin/commands/seed.js +142426 -0
- package/dist/bin/commands/setup-cicd.js +8904 -0
- package/dist/bin/commands/setup.js +259 -212
- package/dist/bin/commands/staging.js +361 -127
- package/dist/bin/commands/sync-secrets.js +55 -33
- package/dist/bin/commands/type-check.js +16 -10
- package/dist/bin/commands/wai.js +6 -6
- package/dist/bin/dndev.js +194 -188
- package/dist/bin/donotdev.js +139 -189
- package/dist/index.js +468 -144
- package/package.json +1 -1
- package/templates/app-demo/.env.example +1 -0
- package/templates/{root-consumer → app-demo}/entities/ExampleEntity.ts.example +15 -9
- package/templates/app-demo/index.html.example +1 -1
- package/templates/app-demo/public/apple-touch-icon.png.example +0 -0
- package/templates/app-demo/public/favicon.svg.example +1 -0
- package/templates/app-demo/public/icon-192x192.png.example +0 -0
- package/templates/app-demo/public/icon-512x512.png.example +0 -0
- package/templates/app-demo/src/App.tsx.example +3 -1
- package/templates/app-demo/src/config/app.ts.example +1 -0
- package/templates/app-demo/src/entities/booking.ts.example +75 -0
- package/templates/app-demo/src/entities/onboarding.ts.example +160 -0
- package/templates/app-demo/src/entities/product.ts.example +12 -0
- package/templates/app-demo/src/entities/quote.ts.example +70 -0
- package/templates/app-demo/src/pages/ChangelogPage.tsx.example +28 -1
- package/templates/app-demo/src/pages/ConditionalFormPage.tsx.example +88 -0
- package/templates/app-demo/src/pages/DashboardPage.tsx.example +2 -0
- package/templates/app-demo/src/pages/HomePage.tsx.example +355 -2
- package/templates/app-demo/src/pages/OnboardingPage.tsx.example +47 -0
- package/templates/app-demo/src/pages/PricingPage.tsx.example +28 -1
- package/templates/app-demo/src/pages/ProductsPage.tsx.example +2 -0
- package/templates/app-demo/src/pages/ProfilePage.tsx.example +2 -0
- package/templates/app-demo/src/pages/SettingsPage.tsx.example +2 -0
- package/templates/app-demo/src/pages/ShowcaseDetailPage.tsx.example +22 -16
- package/templates/app-demo/src/pages/ShowcasePage.tsx.example +3 -1
- package/templates/app-demo/src/pages/components/ComponentRenderer.tsx.example +147 -51
- package/templates/app-demo/src/pages/components/ComponentsData.tsx.example +103 -21
- package/templates/app-demo/src/pages/components/componentConfig.ts.example +139 -59
- package/templates/app-demo/src/pages/legal/LegalPage.tsx.example +12 -1
- package/templates/app-demo/src/pages/legal/PrivacyPage.tsx.example +10 -1
- package/templates/app-demo/src/pages/legal/TermsPage.tsx.example +10 -1
- package/templates/app-demo/src/themes.css.example +289 -77
- package/templates/app-demo/stats.html.example +4949 -0
- package/templates/app-dndev/index.html.example +164 -0
- package/templates/app-dndev/public/logo.svg.example +1 -0
- package/templates/app-dndev/public/manifest.json.example +10 -0
- package/templates/app-dndev/src/App.tsx.example +35 -0
- package/templates/app-dndev/src/components/CockpitLayout.css.example +181 -0
- package/templates/app-dndev/src/components/CockpitLayout.tsx.example +209 -0
- package/templates/app-dndev/src/components/Kanban.css.example +385 -0
- package/templates/app-dndev/src/components/ModeToggle.tsx.example +32 -0
- package/templates/app-dndev/src/components/OverlaySlot.tsx.example +68 -0
- package/templates/app-dndev/src/components/TerminalPanel.css.example +228 -0
- package/templates/app-dndev/src/components/TerminalPanel.tsx.example +714 -0
- package/templates/app-dndev/src/components/markdown-prose.css.example +49 -0
- package/templates/app-dndev/src/components/phases/CaptainLog.tsx.example +107 -0
- package/templates/app-dndev/src/components/phases/ContextTabs.tsx.example +352 -0
- package/templates/app-dndev/src/components/phases/PhaseCard.tsx.example +126 -0
- package/templates/app-dndev/src/components/phases/PhaseDetail.tsx.example +147 -0
- package/templates/app-dndev/src/components/phases/ReviewPanel.tsx.example +115 -0
- package/templates/app-dndev/src/components/phases/phaseData.ts.example +366 -0
- package/templates/app-dndev/src/config/app.ts.example +103 -0
- package/templates/app-dndev/src/config/commands.ts.example +171 -0
- package/templates/app-dndev/src/config/legal.ts.example +170 -0
- package/templates/app-dndev/src/config/providers.ts.example +7 -0
- package/templates/app-dndev/src/globals.css.example +10 -0
- package/templates/app-dndev/src/hooks/useDndevFile.ts.example +144 -0
- package/templates/app-dndev/src/main.tsx.example +21 -0
- package/templates/app-dndev/src/pages/BoardPage.tsx.example +640 -0
- package/templates/app-dndev/src/pages/GrillPage.tsx.example +658 -0
- package/templates/app-dndev/src/pages/HomePage.tsx.example +347 -0
- package/templates/app-dndev/src/pages/NotFoundPage.tsx.example +33 -0
- package/templates/app-dndev/src/pages/PhasesPage.tsx.example +137 -0
- package/templates/app-dndev/src/pages/SettingsPage.tsx.example +64 -0
- package/templates/app-dndev/src/pages/legal/LegalNoticePage.tsx.example +75 -0
- package/templates/app-dndev/src/pages/legal/PrivacyPage.tsx.example +69 -0
- package/templates/app-dndev/src/pages/legal/TermsPage.tsx.example +71 -0
- package/templates/app-dndev/src/stores/dndevStore.ts.example +386 -0
- package/templates/app-dndev/src/themes.css.example +161 -0
- package/templates/app-dndev/terminal-sidecar.cjs.example +341 -0
- package/templates/app-dndev/tsconfig.json.example +9 -0
- package/templates/app-dndev/vite.config.ts.example +24 -0
- package/templates/app-vite/index.html.example +1 -1
- package/templates/functions-supabase/supabase/functions/.env.example +0 -2
- package/templates/root-consumer/.claude/commands/grill.md.example +86 -8
- package/templates/root-consumer/.dndev.secrets.example +32 -0
- package/templates/root-consumer/.gitignore.example +3 -0
- package/templates/root-consumer/AI.md.example +4 -0
- package/templates/root-consumer/entities/index.ts.example +2 -5
- package/templates/root-consumer/guides/dndev/COMPONENTS_ATOMIC.md.example +4 -0
- package/templates/root-consumer/guides/dndev/ENV_SETUP.md.example +23 -20
- package/templates/root-consumer/guides/dndev/INDEX.md.example +1 -0
- package/templates/root-consumer/guides/dndev/SETUP_BILLING.md.example +3 -7
- package/templates/root-consumer/guides/dndev/SETUP_CICD.md.example +115 -0
- package/templates/root-consumer/guides/dndev/SETUP_CRUD.md.example +41 -0
- package/templates/root-consumer/guides/dndev/SETUP_SUPABASE.md.example +13 -18
- package/templates/root-consumer/guides/dndev/SETUP_VERCEL.md.example +17 -12
- package/templates/root-consumer/guides/wai-way/WAI_WAY_CLI.md.example +185 -251
- package/templates/root-consumer/guides/wai-way/agents/extractor.md.example +26 -8
- package/templates/root-consumer/guides/wai-way/blueprints/0_brainstorm.md.example +66 -49
- package/templates/root-consumer/guides/wai-way/blueprints/1_scaffold.md.example +6 -5
- package/templates/root-consumer/guides/wai-way/blueprints/2_entities.md.example +9 -9
- package/templates/root-consumer/guides/wai-way/blueprints/3_compose.md.example +1 -1
- package/templates/root-consumer/guides/wai-way/blueprints/4_configure.md.example +7 -6
- package/templates/root-consumer/guides/wai-way/context_map.json.example +51 -20
- package/templates/root-consumer/guides/wai-way/hld_template.md.example +138 -0
- package/templates/root-consumer/guides/wai-way/lld_template.md.example +103 -0
- package/templates/root-consumer/guides/wai-way/prd_template.md.example +140 -0
- /package/templates/{root-consumer → app-demo}/entities/Contact.ts.example +0 -0
- /package/templates/{root-consumer → app-demo}/entities/demo.ts.example +0 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Terminal panel — real PTY via WebSocket + node-pty sidecar
|
|
3
|
+
*
|
|
4
|
+
* WebSocket on port 24681. JSON protocol for control + data.
|
|
5
|
+
* Each tab spawns an interactive shell (real PTY) at the monorepo root.
|
|
6
|
+
*
|
|
7
|
+
* HMR-safe: all module-level state stored in import.meta.hot.data
|
|
8
|
+
*
|
|
9
|
+
* When `embedded` is true, terminal renders inline as a flex child
|
|
10
|
+
* (used by CockpitLayout). No fixed positioning, no manual drag resize.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useRef, useEffect, useState } from 'react';
|
|
14
|
+
import {
|
|
15
|
+
ChevronUp,
|
|
16
|
+
ChevronDown,
|
|
17
|
+
ChevronLeft,
|
|
18
|
+
ChevronRight,
|
|
19
|
+
Terminal,
|
|
20
|
+
SquareTerminal,
|
|
21
|
+
Cpu,
|
|
22
|
+
Circle,
|
|
23
|
+
X,
|
|
24
|
+
Plus,
|
|
25
|
+
Maximize2,
|
|
26
|
+
Minimize2,
|
|
27
|
+
} from 'lucide-react';
|
|
28
|
+
import { Terminal as XTerm } from '@xterm/xterm';
|
|
29
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
30
|
+
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
31
|
+
|
|
32
|
+
import { useDoNotDashStore } from '../stores/dndevStore';
|
|
33
|
+
|
|
34
|
+
import type { ReactNode } from 'react';
|
|
35
|
+
import type { TabConfig } from '../stores/dndevStore';
|
|
36
|
+
|
|
37
|
+
const ICON_MAP: Record<string, typeof Terminal> = {
|
|
38
|
+
cpu: Cpu,
|
|
39
|
+
terminal: Terminal,
|
|
40
|
+
'square-terminal': SquareTerminal,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function TabIcon({ icon }: { icon: string }) {
|
|
44
|
+
const Icon = ICON_MAP[icon] ?? Terminal;
|
|
45
|
+
return <Icon size={14} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// WebSocket — single connection, survives HMR, auto-reconnects
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/** @type {number} Max reconnect delay in ms (caps exponential backoff) */
|
|
53
|
+
const WS_MAX_RECONNECT_DELAY = 8000;
|
|
54
|
+
/** @type {number} Base reconnect delay in ms */
|
|
55
|
+
const WS_BASE_RECONNECT_DELAY = 500;
|
|
56
|
+
|
|
57
|
+
/** Client-side crash recovery: auto-reconnect tabs that exit with non-zero code */
|
|
58
|
+
const TAB_RECONNECT_MAX = 3;
|
|
59
|
+
const TAB_RECONNECT_DELAY = 800;
|
|
60
|
+
const tabReconnectAttempts = new Map<string, number>();
|
|
61
|
+
const tabReconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
62
|
+
|
|
63
|
+
function scheduleTabReconnect(tab: string): void {
|
|
64
|
+
const attempts = (tabReconnectAttempts.get(tab) ?? 0) + 1;
|
|
65
|
+
if (attempts > TAB_RECONNECT_MAX) {
|
|
66
|
+
console.warn(`[terminal] tab "${tab}" exhausted ${TAB_RECONNECT_MAX} client reconnect attempts`);
|
|
67
|
+
tabReconnectAttempts.delete(tab);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
tabReconnectAttempts.set(tab, attempts);
|
|
71
|
+
console.log(`[terminal] auto-reconnecting tab "${tab}" (${attempts}/${TAB_RECONNECT_MAX}) in ${TAB_RECONNECT_DELAY}ms`);
|
|
72
|
+
|
|
73
|
+
// Clear any pending timer for this tab
|
|
74
|
+
const prev = tabReconnectTimers.get(tab);
|
|
75
|
+
if (prev) clearTimeout(prev);
|
|
76
|
+
|
|
77
|
+
tabReconnectTimers.set(tab, setTimeout(() => {
|
|
78
|
+
tabReconnectTimers.delete(tab);
|
|
79
|
+
connectTab(tab);
|
|
80
|
+
}, TAB_RECONNECT_DELAY));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reconnection state — module-level so it survives HMR */
|
|
84
|
+
let wsReconnectAttempt = 0;
|
|
85
|
+
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
|
|
87
|
+
function getWs(): WebSocket | null {
|
|
88
|
+
if (import.meta.hot) return (import.meta.hot.data.terminalWs as WebSocket) ?? null;
|
|
89
|
+
return fallbackWs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setWs(ws: WebSocket | null): void {
|
|
93
|
+
if (import.meta.hot) import.meta.hot.data.terminalWs = ws;
|
|
94
|
+
else fallbackWs = ws;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let fallbackWs: WebSocket | null = null;
|
|
98
|
+
|
|
99
|
+
function ensureWs(): WebSocket {
|
|
100
|
+
let ws = getWs();
|
|
101
|
+
if (ws && ws.readyState <= 1) return ws;
|
|
102
|
+
|
|
103
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
104
|
+
ws = new WebSocket(`${protocol}//${window.location.hostname}:24681`);
|
|
105
|
+
setWs(ws);
|
|
106
|
+
|
|
107
|
+
ws.onopen = () => {
|
|
108
|
+
console.log('[terminal] connected to', ws!.url);
|
|
109
|
+
wsReconnectAttempt = 0;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
ws.onmessage = (event) => {
|
|
113
|
+
try {
|
|
114
|
+
const msg = JSON.parse(event.data);
|
|
115
|
+
const tab = msg.tab as string;
|
|
116
|
+
|
|
117
|
+
switch (msg.type) {
|
|
118
|
+
case 'started':
|
|
119
|
+
getConnectedTabs().add(tab);
|
|
120
|
+
useDoNotDashStore.getState().setSessionConnected(tab, true);
|
|
121
|
+
// Reset reconnect counter on successful start
|
|
122
|
+
tabReconnectAttempts.delete(tab);
|
|
123
|
+
{ const t = tabReconnectTimers.get(tab); if (t) { clearTimeout(t); tabReconnectTimers.delete(tab); } }
|
|
124
|
+
break;
|
|
125
|
+
case 'output': {
|
|
126
|
+
const entry = getTerminals().get(tab);
|
|
127
|
+
if (entry) entry.term.write(msg.data);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'exit': {
|
|
131
|
+
const entry = getTerminals().get(tab);
|
|
132
|
+
getConnectedTabs().delete(tab);
|
|
133
|
+
useDoNotDashStore.getState().setSessionConnected(tab, false);
|
|
134
|
+
// Non-zero exit = crash → auto-reconnect silently
|
|
135
|
+
if (msg.code !== 0) {
|
|
136
|
+
scheduleTabReconnect(tab);
|
|
137
|
+
} else {
|
|
138
|
+
if (entry) entry.term.writeln(`\r\n[Process exited with code ${msg.code}]`);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'error': {
|
|
143
|
+
getConnectedTabs().delete(tab);
|
|
144
|
+
useDoNotDashStore.getState().setSessionConnected(tab, false);
|
|
145
|
+
// Auto-reconnect on spawn errors too
|
|
146
|
+
scheduleTabReconnect(tab);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (err) { console.warn('[terminal] failed to parse WS message:', err); }
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
ws.onerror = (e) => console.error('[terminal] ws error', e);
|
|
154
|
+
|
|
155
|
+
ws.onclose = (e) => {
|
|
156
|
+
console.log('[terminal] disconnected code=' + e.code, 'reason=' + e.reason);
|
|
157
|
+
setWs(null);
|
|
158
|
+
|
|
159
|
+
// Mark all tabs as disconnected
|
|
160
|
+
for (const tab of getConnectedTabs()) {
|
|
161
|
+
useDoNotDashStore.getState().setSessionConnected(tab, false);
|
|
162
|
+
}
|
|
163
|
+
getConnectedTabs().clear();
|
|
164
|
+
|
|
165
|
+
// Auto-reconnect with exponential backoff (capped)
|
|
166
|
+
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
|
167
|
+
const delay = Math.min(WS_BASE_RECONNECT_DELAY * 2 ** wsReconnectAttempt, WS_MAX_RECONNECT_DELAY);
|
|
168
|
+
wsReconnectAttempt++;
|
|
169
|
+
console.log(`[terminal] reconnecting in ${delay}ms (attempt ${wsReconnectAttempt})`);
|
|
170
|
+
wsReconnectTimer = setTimeout(() => {
|
|
171
|
+
wsReconnectTimer = null;
|
|
172
|
+
ensureWs();
|
|
173
|
+
}, delay);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return ws;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Send a JSON message over the WebSocket.
|
|
181
|
+
* If the socket isn't open yet, queues the send for when it opens.
|
|
182
|
+
* @param msg - Message payload (will be JSON-stringified)
|
|
183
|
+
*/
|
|
184
|
+
function wsSend(msg: Record<string, unknown>): void {
|
|
185
|
+
const ws = ensureWs();
|
|
186
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
187
|
+
ws.send(JSON.stringify(msg));
|
|
188
|
+
} else {
|
|
189
|
+
ws.addEventListener('open', () => ws.send(JSON.stringify(msg)), { once: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// HMR-safe module-level state
|
|
195
|
+
// ============================================================================
|
|
196
|
+
|
|
197
|
+
interface TerminalEntry {
|
|
198
|
+
term: XTerm;
|
|
199
|
+
fit: FitAddon;
|
|
200
|
+
opened: boolean;
|
|
201
|
+
container: HTMLDivElement | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getTerminals(): Map<string, TerminalEntry> {
|
|
205
|
+
if (import.meta.hot) {
|
|
206
|
+
if (!import.meta.hot.data.terminals) {
|
|
207
|
+
import.meta.hot.data.terminals = new Map<string, TerminalEntry>();
|
|
208
|
+
}
|
|
209
|
+
return import.meta.hot.data.terminals as Map<string, TerminalEntry>;
|
|
210
|
+
}
|
|
211
|
+
return fallbackTerminals;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getConnectedTabs(): Set<string> {
|
|
215
|
+
if (import.meta.hot) {
|
|
216
|
+
if (!import.meta.hot.data.connectedTabs) {
|
|
217
|
+
import.meta.hot.data.connectedTabs = new Set<string>();
|
|
218
|
+
}
|
|
219
|
+
return import.meta.hot.data.connectedTabs as Set<string>;
|
|
220
|
+
}
|
|
221
|
+
return fallbackConnected;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const fallbackTerminals = new Map<string, TerminalEntry>();
|
|
225
|
+
const fallbackConnected = new Set<string>();
|
|
226
|
+
const lastSize = new Map<string, string>();
|
|
227
|
+
|
|
228
|
+
function getOrCreateTerminal(tab: string): TerminalEntry {
|
|
229
|
+
const terminals = getTerminals();
|
|
230
|
+
const existing = terminals.get(tab);
|
|
231
|
+
if (existing) return existing;
|
|
232
|
+
|
|
233
|
+
const term = new XTerm({
|
|
234
|
+
cursorBlink: true,
|
|
235
|
+
cursorInactiveStyle: 'none',
|
|
236
|
+
fontSize: 13,
|
|
237
|
+
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace",
|
|
238
|
+
theme: {
|
|
239
|
+
background: '#0a0a0a',
|
|
240
|
+
foreground: '#e4e4e7',
|
|
241
|
+
cursor: '#e4e4e7',
|
|
242
|
+
selectionBackground: '#27272a',
|
|
243
|
+
},
|
|
244
|
+
allowProposedApi: true,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const fit = new FitAddon();
|
|
248
|
+
term.loadAddon(fit);
|
|
249
|
+
term.loadAddon(new WebLinksAddon());
|
|
250
|
+
|
|
251
|
+
term.onData((data) => {
|
|
252
|
+
if (getConnectedTabs().has(tab)) {
|
|
253
|
+
wsSend({ type: 'input', tab, data });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const entry: TerminalEntry = { term, fit, opened: false, container: null };
|
|
258
|
+
terminals.set(tab, entry);
|
|
259
|
+
return entry;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Dispose a terminal entry — xterm instance, DOM container, fit rAF, size cache.
|
|
264
|
+
* Call when a tab is permanently removed (not just hidden).
|
|
265
|
+
* @param tab - Tab identifier to dispose
|
|
266
|
+
*/
|
|
267
|
+
function disposeTerminal(tab: string): void {
|
|
268
|
+
const terminals = getTerminals();
|
|
269
|
+
const entry = terminals.get(tab);
|
|
270
|
+
if (!entry) return;
|
|
271
|
+
|
|
272
|
+
// Cancel any pending fit + PTY resize timers
|
|
273
|
+
const fitTimers = getFitTimers();
|
|
274
|
+
const ft = fitTimers.get(tab);
|
|
275
|
+
if (ft) clearTimeout(ft);
|
|
276
|
+
fitTimers.delete(tab);
|
|
277
|
+
|
|
278
|
+
const ptyTimers = getPtyResizeTimers();
|
|
279
|
+
const pt = ptyTimers.get(tab);
|
|
280
|
+
if (pt) clearTimeout(pt);
|
|
281
|
+
ptyTimers.delete(tab);
|
|
282
|
+
|
|
283
|
+
// Dispose xterm (releases all addons, buffers, DOM listeners)
|
|
284
|
+
try { entry.term.dispose(); } catch { /* already disposed */ }
|
|
285
|
+
|
|
286
|
+
// Remove DOM container
|
|
287
|
+
if (entry.container) {
|
|
288
|
+
entry.container.remove();
|
|
289
|
+
entry.container = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Clean up maps
|
|
293
|
+
terminals.delete(tab);
|
|
294
|
+
lastSize.delete(tab);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Per-tab debounce timers for fit scheduling.
|
|
299
|
+
* Timer-based (not rAF) so we don't hammer xterm mid-drag.
|
|
300
|
+
*/
|
|
301
|
+
function getFitTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
302
|
+
if (import.meta.hot) {
|
|
303
|
+
if (!import.meta.hot.data.fitTimers) {
|
|
304
|
+
import.meta.hot.data.fitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
305
|
+
}
|
|
306
|
+
return import.meta.hot.data.fitTimers as Map<string, ReturnType<typeof setTimeout>>;
|
|
307
|
+
}
|
|
308
|
+
return fallbackFitTimers;
|
|
309
|
+
}
|
|
310
|
+
const fallbackFitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
311
|
+
|
|
312
|
+
/** Visual fit debounce — adapts xterm grid to container. Safe, no PTY side-effects. */
|
|
313
|
+
const FIT_DEBOUNCE_MS = 80;
|
|
314
|
+
/** PTY resize debounce — tells the shell about new dimensions. Shell redraws on this,
|
|
315
|
+
* so it must only fire once after dragging fully stops to avoid duplicate output. */
|
|
316
|
+
const PTY_RESIZE_DEBOUNCE_MS = 400;
|
|
317
|
+
|
|
318
|
+
function getPtyResizeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
319
|
+
if (import.meta.hot) {
|
|
320
|
+
if (!import.meta.hot.data.ptyResizeTimers) {
|
|
321
|
+
import.meta.hot.data.ptyResizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
322
|
+
}
|
|
323
|
+
return import.meta.hot.data.ptyResizeTimers as Map<string, ReturnType<typeof setTimeout>>;
|
|
324
|
+
}
|
|
325
|
+
return fallbackPtyResizeTimers;
|
|
326
|
+
}
|
|
327
|
+
const fallbackPtyResizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Schedule a fit + resize for a tab. Two-phase debounce:
|
|
331
|
+
* 1. Visual fit (80ms) — recalculates xterm grid to match container. Pure client-side.
|
|
332
|
+
* 2. PTY resize (400ms) — tells shell the new dimensions. Only fires after drag settles
|
|
333
|
+
* to prevent the shell from redrawing mid-drag (which causes duplicate rows).
|
|
334
|
+
*/
|
|
335
|
+
function scheduleFit(tab: string, entry: TerminalEntry): void {
|
|
336
|
+
const fitTimers = getFitTimers();
|
|
337
|
+
|
|
338
|
+
const existing = fitTimers.get(tab);
|
|
339
|
+
if (existing) clearTimeout(existing);
|
|
340
|
+
|
|
341
|
+
fitTimers.set(tab, setTimeout(() => {
|
|
342
|
+
fitTimers.delete(tab);
|
|
343
|
+
try {
|
|
344
|
+
entry.fit.fit();
|
|
345
|
+
} catch { /* container might be detached */ }
|
|
346
|
+
|
|
347
|
+
// Schedule PTY resize separately with longer debounce
|
|
348
|
+
schedulePtyResize(tab, entry);
|
|
349
|
+
}, FIT_DEBOUNCE_MS));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Notify PTY of new dimensions — heavily debounced to avoid shell redraw spam. */
|
|
353
|
+
function schedulePtyResize(tab: string, entry: TerminalEntry): void {
|
|
354
|
+
const timers = getPtyResizeTimers();
|
|
355
|
+
const prev = timers.get(tab);
|
|
356
|
+
if (prev) clearTimeout(prev);
|
|
357
|
+
|
|
358
|
+
timers.set(tab, setTimeout(() => {
|
|
359
|
+
timers.delete(tab);
|
|
360
|
+
try {
|
|
361
|
+
const cols = entry.term.cols;
|
|
362
|
+
const rows = entry.term.rows;
|
|
363
|
+
if (cols < 2 || rows < 2) return;
|
|
364
|
+
const key = `${cols}x${rows}`;
|
|
365
|
+
if (lastSize.get(tab) !== key) {
|
|
366
|
+
lastSize.set(tab, key);
|
|
367
|
+
if (getConnectedTabs().has(tab)) {
|
|
368
|
+
wsSend({ type: 'resize', tab, cols, rows });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch { /* detached */ }
|
|
372
|
+
}, PTY_RESIZE_DEBOUNCE_MS));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function connectTab(tab: string): void {
|
|
376
|
+
if (getConnectedTabs().has(tab)) return;
|
|
377
|
+
const entry = getTerminals().get(tab);
|
|
378
|
+
const cols = entry?.term.cols || 120;
|
|
379
|
+
const rows = entry?.term.rows || 30;
|
|
380
|
+
// Record the size we're starting with so the first fit() doesn't send a redundant
|
|
381
|
+
// resize — that causes the shell to redraw its prompt and duplicate the first line.
|
|
382
|
+
lastSize.set(tab, `${cols}x${rows}`);
|
|
383
|
+
wsSend({ type: 'start', tab, cols, rows });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Kill the PTY for a tab, optionally restarting it.
|
|
388
|
+
* @param tab - Tab identifier
|
|
389
|
+
* @param options.restart - If true, reconnects after a short delay
|
|
390
|
+
*/
|
|
391
|
+
function killTab(tab: string, { restart = false }: { restart?: boolean } = {}): void {
|
|
392
|
+
wsSend({ type: 'kill', tab });
|
|
393
|
+
getConnectedTabs().delete(tab);
|
|
394
|
+
useDoNotDashStore.getState().setSessionConnected(tab, false);
|
|
395
|
+
const entry = getTerminals().get(tab);
|
|
396
|
+
if (entry) {
|
|
397
|
+
entry.term.clear();
|
|
398
|
+
if (restart) {
|
|
399
|
+
// Small delay so sidecar finishes cleanup before we reconnect
|
|
400
|
+
setTimeout(() => connectTab(tab), 200);
|
|
401
|
+
} else {
|
|
402
|
+
entry.term.writeln('[Process killed]');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ============================================================================
|
|
408
|
+
// Component
|
|
409
|
+
// ============================================================================
|
|
410
|
+
|
|
411
|
+
interface TerminalPanelProps {
|
|
412
|
+
/** When true, renders inline (flex child). No fixed positioning. */
|
|
413
|
+
embedded?: boolean;
|
|
414
|
+
/** Layout direction — determines chevron orientation for collapse/expand. */
|
|
415
|
+
direction?: 'horizontal' | 'vertical';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function TerminalPanel({ embedded, direction = 'vertical' }: TerminalPanelProps): ReactNode {
|
|
419
|
+
const tabs = useDoNotDashStore((s) => s.tabs);
|
|
420
|
+
const activeTab = useDoNotDashStore((s) => s.activeTab);
|
|
421
|
+
const isExpanded = useDoNotDashStore((s) => s.isExpanded);
|
|
422
|
+
const isFullscreen = useDoNotDashStore((s) => s.isFullscreen);
|
|
423
|
+
const sessions = useDoNotDashStore((s) => s.sessions);
|
|
424
|
+
const pendingPrompt = useDoNotDashStore((s) => s.pendingPrompt);
|
|
425
|
+
const pendingTab = useDoNotDashStore((s) => s.pendingTab);
|
|
426
|
+
|
|
427
|
+
const xtermRef = useRef<HTMLDivElement>(null);
|
|
428
|
+
|
|
429
|
+
// fullscreen: always visible (CSS overlays everything)
|
|
430
|
+
// otherwise: store-controlled expand/collapse (works in embedded too — collapses to tabbar)
|
|
431
|
+
const effectiveExpanded = isFullscreen || isExpanded;
|
|
432
|
+
|
|
433
|
+
// WebSocket on mount
|
|
434
|
+
useEffect(() => { ensureWs(); }, []);
|
|
435
|
+
|
|
436
|
+
// Mount xterm DOM + auto-connect
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (!effectiveExpanded || !xtermRef.current) return;
|
|
439
|
+
|
|
440
|
+
const wrapper = xtermRef.current;
|
|
441
|
+
const entry = getOrCreateTerminal(activeTab);
|
|
442
|
+
|
|
443
|
+
if (!entry.container) {
|
|
444
|
+
const div = document.createElement('div');
|
|
445
|
+
div.className = 'dndev-terminal-tab-pane';
|
|
446
|
+
// Sizing handled by .dndev-terminal-tab-pane CSS rule
|
|
447
|
+
entry.container = div;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Detach from old parent before re-appending (prevents DOM orphans on tab switch)
|
|
451
|
+
if (entry.container.parentNode && entry.container.parentNode !== wrapper) {
|
|
452
|
+
entry.container.parentNode.removeChild(entry.container);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!wrapper.contains(entry.container)) {
|
|
456
|
+
wrapper.appendChild(entry.container);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!entry.opened) {
|
|
460
|
+
try {
|
|
461
|
+
entry.term.open(entry.container);
|
|
462
|
+
entry.opened = true;
|
|
463
|
+
// Refit after layout settles — initial rAF can fire before panels are sized
|
|
464
|
+
setTimeout(() => scheduleFit(activeTab, entry), 150);
|
|
465
|
+
// Refit after fonts load — FitAddon computes cols from char width
|
|
466
|
+
document.fonts.ready.then(() => scheduleFit(activeTab, entry));
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error(`[terminal] failed to open xterm for tab "${activeTab}":`, err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const [tab, e] of getTerminals()) {
|
|
473
|
+
if (tab === activeTab) {
|
|
474
|
+
if (e.container) e.container.style.display = 'block';
|
|
475
|
+
} else {
|
|
476
|
+
// Blur hidden terminals to prevent ghost cursors on focus/blur cycles
|
|
477
|
+
e.term.blur();
|
|
478
|
+
if (e.container) e.container.style.display = 'none';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Auto-connect if not already running
|
|
483
|
+
if (!getConnectedTabs().has(activeTab)) {
|
|
484
|
+
connectTab(activeTab);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
scheduleFit(activeTab, entry);
|
|
488
|
+
entry.term.focus();
|
|
489
|
+
}, [activeTab, effectiveExpanded]);
|
|
490
|
+
|
|
491
|
+
// Pending prompt injection
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
if (!pendingPrompt) return;
|
|
494
|
+
|
|
495
|
+
if (!getConnectedTabs().has(pendingTab)) {
|
|
496
|
+
connectTab(pendingTab);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Track whether the prompt was consumed by the immediate attempt
|
|
500
|
+
let consumed = false;
|
|
501
|
+
|
|
502
|
+
const tryInject = () => {
|
|
503
|
+
if (consumed) return;
|
|
504
|
+
if (getConnectedTabs().has(pendingTab)) {
|
|
505
|
+
consumed = true;
|
|
506
|
+
// All current injectPrompt call sites target AI agent tabs (Claude CLI),
|
|
507
|
+
// where the text is a prompt — NOT shell input. No shell escaping needed.
|
|
508
|
+
wsSend({ type: 'input', tab: pendingTab, data: pendingPrompt + '\n' });
|
|
509
|
+
useDoNotDashStore.getState().consumePrompt();
|
|
510
|
+
// Focus the terminal so user can interact immediately (e.g. hit Enter)
|
|
511
|
+
getTerminals().get(pendingTab)?.term.focus();
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
tryInject();
|
|
516
|
+
// Retry once after 500ms if the tab wasn't connected yet
|
|
517
|
+
const timer = setTimeout(tryInject, 500);
|
|
518
|
+
return () => clearTimeout(timer);
|
|
519
|
+
}, [pendingPrompt, pendingTab]);
|
|
520
|
+
|
|
521
|
+
// Resize observer — refit terminal when panel resizes
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (!effectiveExpanded || !xtermRef.current) return;
|
|
524
|
+
const entry = getOrCreateTerminal(activeTab);
|
|
525
|
+
const observer = new ResizeObserver(() => scheduleFit(activeTab, entry));
|
|
526
|
+
observer.observe(xtermRef.current);
|
|
527
|
+
return () => observer.disconnect();
|
|
528
|
+
}, [activeTab, effectiveExpanded]);
|
|
529
|
+
|
|
530
|
+
// Refit on fullscreen toggle
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (!effectiveExpanded) return;
|
|
533
|
+
const entry = getTerminals().get(activeTab);
|
|
534
|
+
if (entry) scheduleFit(activeTab, entry);
|
|
535
|
+
}, [isFullscreen, effectiveExpanded, activeTab]);
|
|
536
|
+
|
|
537
|
+
function handleTabClick(tab: TabConfig) {
|
|
538
|
+
useDoNotDashStore.getState().setActiveTab(tab.id);
|
|
539
|
+
if (!isExpanded) useDoNotDashStore.getState().setExpanded(true);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handleKill(tabId: string, e: React.MouseEvent) {
|
|
543
|
+
e.stopPropagation();
|
|
544
|
+
killTab(tabId, { restart: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function handleClose(tabId: string, e: React.MouseEvent) {
|
|
548
|
+
e.stopPropagation();
|
|
549
|
+
killTab(tabId);
|
|
550
|
+
// Dispose xterm instance + DOM to prevent memory leaks
|
|
551
|
+
disposeTerminal(tabId);
|
|
552
|
+
useDoNotDashStore.getState().removeTab(tabId);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const isAiAgentTab = (tabId: string) => tabs.find((t) => t.id === tabId)?.mode === 'ai-agent';
|
|
556
|
+
|
|
557
|
+
// App picker state
|
|
558
|
+
const [showAppPicker, setShowAppPicker] = useState(false);
|
|
559
|
+
const [availableApps, setAvailableApps] = useState<{ name: string; packageName: string }[]>([]);
|
|
560
|
+
const appPickerRef = useRef<HTMLDivElement>(null);
|
|
561
|
+
|
|
562
|
+
// Close app picker on blur
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!showAppPicker) return;
|
|
565
|
+
function handleClickOutside(e: MouseEvent) {
|
|
566
|
+
if (appPickerRef.current && !appPickerRef.current.contains(e.target as Node)) {
|
|
567
|
+
setShowAppPicker(false);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
571
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
572
|
+
}, [showAppPicker]);
|
|
573
|
+
|
|
574
|
+
async function openAppPicker() {
|
|
575
|
+
if (showAppPicker) {
|
|
576
|
+
setShowAppPicker(false);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
const res = await fetch('/api/dndev/apps');
|
|
581
|
+
const json = await res.json();
|
|
582
|
+
setAvailableApps(json.apps ?? []);
|
|
583
|
+
} catch {
|
|
584
|
+
setAvailableApps([]);
|
|
585
|
+
}
|
|
586
|
+
setShowAppPicker(true);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<div
|
|
591
|
+
className="dndev-terminal-panel"
|
|
592
|
+
data-embedded={embedded || undefined}
|
|
593
|
+
data-expanded={effectiveExpanded}
|
|
594
|
+
>
|
|
595
|
+
<div className="dndev-terminal-tabbar">
|
|
596
|
+
<div className="dndev-terminal-tabs">
|
|
597
|
+
{tabs.map((tab) => {
|
|
598
|
+
const connected = sessions[tab.id]?.isConnected ?? false;
|
|
599
|
+
return (
|
|
600
|
+
<button
|
|
601
|
+
key={tab.id}
|
|
602
|
+
type="button"
|
|
603
|
+
className="dndev-terminal-tab"
|
|
604
|
+
data-active={activeTab === tab.id}
|
|
605
|
+
onClick={() => handleTabClick(tab)}
|
|
606
|
+
>
|
|
607
|
+
<TabIcon icon={tab.icon} />
|
|
608
|
+
<span>{tab.label}</span>
|
|
609
|
+
<Circle
|
|
610
|
+
size={6}
|
|
611
|
+
fill={connected ? 'var(--success, #22c55e)' : 'var(--muted-foreground)'}
|
|
612
|
+
stroke="none"
|
|
613
|
+
/>
|
|
614
|
+
{connected && (
|
|
615
|
+
<span
|
|
616
|
+
className="dndev-terminal-tab-close"
|
|
617
|
+
onClick={(e) => isAiAgentTab(tab.id) ? handleKill(tab.id, e) : handleClose(tab.id, e)}
|
|
618
|
+
role="button"
|
|
619
|
+
tabIndex={-1}
|
|
620
|
+
aria-label={isAiAgentTab(tab.id) ? `Kill ${tab.label}` : `Close ${tab.label}`}
|
|
621
|
+
>
|
|
622
|
+
<X size={10} />
|
|
623
|
+
</span>
|
|
624
|
+
)}
|
|
625
|
+
{!connected && !isAiAgentTab(tab.id) && (
|
|
626
|
+
<span
|
|
627
|
+
className="dndev-terminal-tab-close"
|
|
628
|
+
style={{ opacity: 1 }}
|
|
629
|
+
onClick={(e) => handleClose(tab.id, e)}
|
|
630
|
+
role="button"
|
|
631
|
+
tabIndex={-1}
|
|
632
|
+
aria-label={`Remove ${tab.label}`}
|
|
633
|
+
>
|
|
634
|
+
<X size={10} />
|
|
635
|
+
</span>
|
|
636
|
+
)}
|
|
637
|
+
</button>
|
|
638
|
+
);
|
|
639
|
+
})}
|
|
640
|
+
<button
|
|
641
|
+
type="button"
|
|
642
|
+
className="dndev-terminal-tab-add"
|
|
643
|
+
onClick={() => useDoNotDashStore.getState().addShellTab()}
|
|
644
|
+
aria-label="Add shell tab"
|
|
645
|
+
>
|
|
646
|
+
<Plus size={14} />
|
|
647
|
+
</button>
|
|
648
|
+
<div style={{ position: 'relative' }} ref={appPickerRef}>
|
|
649
|
+
<button
|
|
650
|
+
type="button"
|
|
651
|
+
className="dndev-terminal-tab-add"
|
|
652
|
+
onClick={openAppPicker}
|
|
653
|
+
aria-label="Launch app dev server"
|
|
654
|
+
>
|
|
655
|
+
<Cpu size={14} />
|
|
656
|
+
</button>
|
|
657
|
+
{showAppPicker && (
|
|
658
|
+
<div className="dndev-terminal-app-picker">
|
|
659
|
+
{availableApps.length === 0 ? (
|
|
660
|
+
<div className="dndev-terminal-app-picker-empty">No apps found</div>
|
|
661
|
+
) : (
|
|
662
|
+
availableApps.map((app) => (
|
|
663
|
+
<button
|
|
664
|
+
key={app.name}
|
|
665
|
+
type="button"
|
|
666
|
+
className="dndev-terminal-app-picker-item"
|
|
667
|
+
onClick={() => {
|
|
668
|
+
useDoNotDashStore.getState().addAppTab(app.name);
|
|
669
|
+
setShowAppPicker(false);
|
|
670
|
+
}}
|
|
671
|
+
>
|
|
672
|
+
<Cpu size={12} />
|
|
673
|
+
<span>{app.name}</span>
|
|
674
|
+
</button>
|
|
675
|
+
))
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<div className="dndev-terminal-actions">
|
|
683
|
+
{effectiveExpanded && (
|
|
684
|
+
<button
|
|
685
|
+
type="button"
|
|
686
|
+
className="dndev-terminal-toggle"
|
|
687
|
+
onClick={() => useDoNotDashStore.getState().toggleFullscreen()}
|
|
688
|
+
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
689
|
+
>
|
|
690
|
+
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
|
691
|
+
</button>
|
|
692
|
+
)}
|
|
693
|
+
<button
|
|
694
|
+
type="button"
|
|
695
|
+
className="dndev-terminal-toggle"
|
|
696
|
+
onClick={() => useDoNotDashStore.getState().toggleExpanded()}
|
|
697
|
+
aria-label={isExpanded ? 'Collapse terminal' : 'Expand terminal'}
|
|
698
|
+
>
|
|
699
|
+
{direction === 'horizontal'
|
|
700
|
+
? (isExpanded ? <ChevronRight size={16} /> : <ChevronLeft size={16} />)
|
|
701
|
+
: (isExpanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />)
|
|
702
|
+
}
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
{effectiveExpanded && (
|
|
708
|
+
<div className="dndev-terminal-content">
|
|
709
|
+
<div ref={xtermRef} className="dndev-terminal-xterm-host" />
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
</div>
|
|
713
|
+
);
|
|
714
|
+
}
|