@bakapiano/ccsm 0.22.0 → 0.22.2
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/CLAUDE.md +2 -0
- package/lib/persistedSessions.js +5 -1
- package/package.json +1 -1
- package/public/css/terminals.css +38 -1
- package/public/js/api.js +14 -0
- package/public/js/components/Sidebar.js +3 -4
- package/public/js/components/TerminalInstance.js +266 -0
- package/public/js/components/TerminalView.js +22 -524
- package/public/js/components/XtermTerminal.js +198 -0
- package/public/js/icons.js +8 -0
- package/public/js/pages/SessionsPage.js +106 -8
- package/server.js +62 -0
package/CLAUDE.md
CHANGED
|
@@ -311,6 +311,8 @@ allows `https://bakapiano.github.io` only — never `*`.
|
|
|
311
311
|
| GET | `/api/sessions` | list persisted sessions |
|
|
312
312
|
| PUT | `/api/sessions/:id` | rename / move to folder |
|
|
313
313
|
| DELETE | `/api/sessions/:id` | kill PTY + drop record |
|
|
314
|
+
| POST | `/api/sessions/:id/switch-cli` | change the persisted `cliId` for future resumes; current and target CLI must share `type` |
|
|
315
|
+
| POST | `/api/sessions/:id/stop` | kill the live PTY but keep the record; sets `manualStopped:true` so UI won't auto-resume |
|
|
314
316
|
| POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` — NDJSON stream (workspace · clone-progress · launched) |
|
|
315
317
|
| POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
|
|
316
318
|
| GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
|
package/lib/persistedSessions.js
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
// // newSessionIdArgs (claude, copilot); set
|
|
27
27
|
// // from disk for adopted sessions. Used
|
|
28
28
|
// // for precise --resume <id>.
|
|
29
|
+
// manualStopped: false, // true only when the user explicitly stopped
|
|
30
|
+
// // it from ccsm; prevents auto-resume until
|
|
31
|
+
// // they press Resume.
|
|
29
32
|
// }
|
|
30
33
|
|
|
31
34
|
const path = require('node:path');
|
|
@@ -73,6 +76,7 @@ async function create(opts) {
|
|
|
73
76
|
exitCode: null,
|
|
74
77
|
pid: null,
|
|
75
78
|
cliSessionId,
|
|
79
|
+
manualStopped: false,
|
|
76
80
|
};
|
|
77
81
|
list.push(entry);
|
|
78
82
|
await saveAll(list);
|
|
@@ -110,7 +114,7 @@ async function remove(id) {
|
|
|
110
114
|
// Convenience helpers used at runtime so callers don't have to do
|
|
111
115
|
// load/find/update/save themselves.
|
|
112
116
|
async function markRunning(id, pid) {
|
|
113
|
-
return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, lastActiveAt: Date.now() });
|
|
117
|
+
return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, manualStopped: false, lastActiveAt: Date.now() });
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
async function markExited(id, exitCode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/terminals.css
CHANGED
|
@@ -282,6 +282,7 @@
|
|
|
282
282
|
display: flex;
|
|
283
283
|
align-items: center;
|
|
284
284
|
flex-shrink: 0;
|
|
285
|
+
gap: 4px;
|
|
285
286
|
padding-right: 2px;
|
|
286
287
|
}
|
|
287
288
|
/* Close the gap to the page-title-bar above — only when there IS one.
|
|
@@ -335,6 +336,27 @@
|
|
|
335
336
|
.session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
|
336
337
|
.session-tab-add svg { width: 14px; height: 14px; }
|
|
337
338
|
|
|
339
|
+
.session-controls {
|
|
340
|
+
display: inline-flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
flex-shrink: 0;
|
|
343
|
+
}
|
|
344
|
+
.session-control-btn.danger {
|
|
345
|
+
color: #f4b8b8;
|
|
346
|
+
}
|
|
347
|
+
.session-control-btn.danger:hover:not(:disabled) {
|
|
348
|
+
color: #ffd1d1;
|
|
349
|
+
background: rgba(183, 63, 63, 0.22);
|
|
350
|
+
}
|
|
351
|
+
.session-control-btn:disabled {
|
|
352
|
+
opacity: .55;
|
|
353
|
+
cursor: wait;
|
|
354
|
+
}
|
|
355
|
+
.session-control-btn svg {
|
|
356
|
+
width: 13px;
|
|
357
|
+
height: 13px;
|
|
358
|
+
}
|
|
359
|
+
|
|
338
360
|
/* Kebab in the page-title-bar (top-right). Compact 24px square so it
|
|
339
361
|
doesn't dominate the masthead. In WCO mode the title-bar already
|
|
340
362
|
reserves padding-right for OS controls, so this slides cleanly to
|
|
@@ -359,7 +381,8 @@
|
|
|
359
381
|
}
|
|
360
382
|
/* Neutral-grey hover tint works on either strip colour (darkens the light
|
|
361
383
|
one, lightens the dark one) without needing a per-theme override. */
|
|
362
|
-
.session-menu-btn:hover { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
|
|
384
|
+
.session-menu-btn:hover:not(:disabled) { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
|
|
385
|
+
.session-menu-btn:disabled { opacity: .55; cursor: wait; }
|
|
363
386
|
.session-menu-btn svg { width: 16px; height: 16px; }
|
|
364
387
|
|
|
365
388
|
.session-menu {
|
|
@@ -391,6 +414,20 @@
|
|
|
391
414
|
.session-menu-item.danger { color: var(--danger, #b73f3f); }
|
|
392
415
|
.session-menu-item.danger:hover { background: rgba(183, 63, 63, 0.08); }
|
|
393
416
|
.session-menu-item svg { width: 14px; height: 14px; }
|
|
417
|
+
.session-menu-item img { width: 14px; height: 14px; }
|
|
418
|
+
.session-menu-separator {
|
|
419
|
+
height: 1px;
|
|
420
|
+
background: var(--border);
|
|
421
|
+
margin: 3px 2px;
|
|
422
|
+
}
|
|
423
|
+
.session-menu-label {
|
|
424
|
+
padding: 4px 10px 2px;
|
|
425
|
+
font-size: 11px;
|
|
426
|
+
line-height: 1.2;
|
|
427
|
+
color: var(--ink-muted);
|
|
428
|
+
text-transform: uppercase;
|
|
429
|
+
letter-spacing: 0;
|
|
430
|
+
}
|
|
394
431
|
|
|
395
432
|
.session-pane-head {
|
|
396
433
|
display: flex;
|
package/public/js/api.js
CHANGED
|
@@ -254,6 +254,20 @@ export async function setSessionTitle(sessionId, title) {
|
|
|
254
254
|
await loadSessions();
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
export async function switchSessionCli(sessionId, cliId) {
|
|
258
|
+
const r = await api('POST', `/api/sessions/${sessionId}/switch-cli`, { cliId });
|
|
259
|
+
resumeFailed.delete(sessionId);
|
|
260
|
+
await loadSessions();
|
|
261
|
+
return r;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function stopSession(sessionId) {
|
|
265
|
+
const r = await api('POST', `/api/sessions/${sessionId}/stop`);
|
|
266
|
+
resumeFailed.delete(sessionId);
|
|
267
|
+
await loadSessions();
|
|
268
|
+
return r.session;
|
|
269
|
+
}
|
|
270
|
+
|
|
257
271
|
export async function deleteSession(sessionId) {
|
|
258
272
|
await api('DELETE', `/api/sessions/${sessionId}`);
|
|
259
273
|
await loadSessions();
|
|
@@ -54,10 +54,9 @@ function SessionRow({ s, folderId, siblingIds }) {
|
|
|
54
54
|
const onClick = async (ev) => {
|
|
55
55
|
ev.preventDefault();
|
|
56
56
|
selectSession(s.id);
|
|
57
|
-
// Auto-resume on click if the session
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
if (s.status !== 'running') {
|
|
57
|
+
// Auto-resume on click if the session stopped on its own. Explicitly
|
|
58
|
+
// stopped sessions stay stopped until the user presses Resume.
|
|
59
|
+
if (s.status !== 'running' && !s.manualStopped) {
|
|
61
60
|
try { await resumeSession(s.id); }
|
|
62
61
|
catch (e) { setToast(e.message, 'error'); }
|
|
63
62
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// VS Code-style terminal instance lifecycle for a single ccsm session.
|
|
2
|
+
// Owns attach/detach, WebSocket transport, xterm input/output forwarding,
|
|
3
|
+
// resize propagation, paste handling, and browser/mobile lifecycle hooks.
|
|
4
|
+
|
|
5
|
+
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
6
|
+
import { XtermTerminal } from './XtermTerminal.js';
|
|
7
|
+
|
|
8
|
+
export class TerminalInstance {
|
|
9
|
+
constructor({ terminalId, cliType, onDisplaced }) {
|
|
10
|
+
this.terminalId = terminalId;
|
|
11
|
+
this.cliType = cliType;
|
|
12
|
+
this.onDisplaced = onDisplaced;
|
|
13
|
+
this.xterm = new XtermTerminal();
|
|
14
|
+
this.ws = null;
|
|
15
|
+
this.host = null;
|
|
16
|
+
this.closedByUs = false;
|
|
17
|
+
this.reconnectTimer = null;
|
|
18
|
+
this.attempts = 0;
|
|
19
|
+
this.everOpened = false;
|
|
20
|
+
this.disposables = [];
|
|
21
|
+
this.helperTextarea = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
attachToElement(host) {
|
|
25
|
+
this.host = host;
|
|
26
|
+
this.xterm.attachToElement(host);
|
|
27
|
+
this._registerColorOscHandlers();
|
|
28
|
+
this._connect();
|
|
29
|
+
this._wireXtermEvents();
|
|
30
|
+
this._wireDomLifecycle();
|
|
31
|
+
this.xterm.focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
sendInput(data) {
|
|
35
|
+
this._sendFrame({ type: 'input', data });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setCliType(cliType) {
|
|
39
|
+
this.cliType = cliType;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
applyTheme() {
|
|
43
|
+
this.xterm.applyResolvedTheme();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
dispose() {
|
|
47
|
+
this.closedByUs = true;
|
|
48
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
49
|
+
for (const dispose of this.disposables.splice(0)) {
|
|
50
|
+
try { dispose(); } catch {}
|
|
51
|
+
}
|
|
52
|
+
try { this.ws?.close(); } catch {}
|
|
53
|
+
this.ws = null;
|
|
54
|
+
this.helperTextarea = null;
|
|
55
|
+
this.xterm.dispose();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_connect() {
|
|
59
|
+
const ws = new WebSocket(this._wsUrl());
|
|
60
|
+
ws.binaryType = 'arraybuffer';
|
|
61
|
+
this.ws = ws;
|
|
62
|
+
|
|
63
|
+
ws.onopen = () => {
|
|
64
|
+
if (this.everOpened) {
|
|
65
|
+
this.xterm.reset();
|
|
66
|
+
}
|
|
67
|
+
this.everOpened = true;
|
|
68
|
+
this.attempts = 0;
|
|
69
|
+
this.xterm.scheduleFit();
|
|
70
|
+
this._sendFrame({ type: 'resize', cols: this.xterm.cols, rows: this.xterm.rows });
|
|
71
|
+
};
|
|
72
|
+
ws.onmessage = (ev) => {
|
|
73
|
+
let frame;
|
|
74
|
+
try { frame = JSON.parse(ev.data); } catch { return; }
|
|
75
|
+
if (frame.type === 'output') {
|
|
76
|
+
this.xterm.write(frame.data);
|
|
77
|
+
} else if (frame.type === 'exit') {
|
|
78
|
+
this.xterm.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
ws.onclose = (ev) => this._handleClose(ev);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_handleClose(ev) {
|
|
85
|
+
if (this.closedByUs) return;
|
|
86
|
+
if (ev && ev.code === 4001) {
|
|
87
|
+
this.onDisplaced?.();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (ev && ev.code === 4404) {
|
|
91
|
+
this.xterm.write('\r\n\x1b[2m[session ended]\x1b[0m\r\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.attempts++;
|
|
95
|
+
const delay = Math.min(8000, 500 * 2 ** Math.min(this.attempts - 1, 4));
|
|
96
|
+
this.xterm.write('\r\n\x1b[2m[disconnected · reconnecting…]\x1b[0m\r\n');
|
|
97
|
+
this.reconnectTimer = setTimeout(() => {
|
|
98
|
+
if (!this.closedByUs) this._connect();
|
|
99
|
+
}, delay);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_wireXtermEvents() {
|
|
103
|
+
const dataDisposable = this.xterm.onData((data) => {
|
|
104
|
+
this._sendFrame({ type: 'input', data });
|
|
105
|
+
});
|
|
106
|
+
const resizeDisposable = this.xterm.onResize(({ cols, rows }) => {
|
|
107
|
+
this._sendFrame({ type: 'resize', cols, rows });
|
|
108
|
+
});
|
|
109
|
+
this.disposables.push(
|
|
110
|
+
() => dataDisposable.dispose(),
|
|
111
|
+
() => resizeDisposable.dispose(),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_wireDomLifecycle() {
|
|
116
|
+
const host = this.host;
|
|
117
|
+
const ro = new ResizeObserver(() => this.xterm.fit());
|
|
118
|
+
ro.observe(host);
|
|
119
|
+
this.disposables.push(() => ro.disconnect());
|
|
120
|
+
|
|
121
|
+
const vv = window.visualViewport;
|
|
122
|
+
const onVisualResize = () => this.xterm.scheduleFit();
|
|
123
|
+
vv?.addEventListener?.('resize', onVisualResize);
|
|
124
|
+
vv?.addEventListener?.('scroll', onVisualResize);
|
|
125
|
+
this.disposables.push(() => {
|
|
126
|
+
vv?.removeEventListener?.('resize', onVisualResize);
|
|
127
|
+
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const onHostClick = () => this.xterm.focus();
|
|
131
|
+
if (this.xterm.isMobile) {
|
|
132
|
+
host.addEventListener('click', onHostClick);
|
|
133
|
+
this.disposables.push(() => host.removeEventListener('click', onHostClick));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._wireTabVisibilityRefresh(host);
|
|
137
|
+
this._wirePasteHandlers(host);
|
|
138
|
+
this._wireModifiedEnterHandler(host);
|
|
139
|
+
this._wireCompositionHandlers();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_wireTabVisibilityRefresh(host) {
|
|
143
|
+
const panel = host.closest('.tab-panel');
|
|
144
|
+
if (!panel) return;
|
|
145
|
+
const panelMo = new MutationObserver(() => {
|
|
146
|
+
if (panel.hasAttribute('data-active')) {
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
this.xterm.clearTextureAtlas();
|
|
149
|
+
this.xterm.scheduleFit();
|
|
150
|
+
this.xterm.refresh();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
|
|
155
|
+
this.disposables.push(() => panelMo.disconnect());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_wirePasteHandlers(host) {
|
|
159
|
+
const isOurs = () => {
|
|
160
|
+
const ae = document.activeElement;
|
|
161
|
+
return ae && host.contains(ae);
|
|
162
|
+
};
|
|
163
|
+
const doPaste = (text) => {
|
|
164
|
+
if (!text) return;
|
|
165
|
+
const normalized = text.replace(/\r?\n/g, '\r');
|
|
166
|
+
this.sendInput(`\x1b[200~${normalized}\x1b[201~`);
|
|
167
|
+
};
|
|
168
|
+
const onPaste = async (ev) => {
|
|
169
|
+
if (!isOurs()) return;
|
|
170
|
+
let text = '';
|
|
171
|
+
if (ev.clipboardData) text = ev.clipboardData.getData('text');
|
|
172
|
+
if (!text && navigator.clipboard) {
|
|
173
|
+
try { text = await navigator.clipboard.readText(); } catch {}
|
|
174
|
+
}
|
|
175
|
+
if (!text) return;
|
|
176
|
+
ev.preventDefault();
|
|
177
|
+
ev.stopPropagation();
|
|
178
|
+
doPaste(text);
|
|
179
|
+
};
|
|
180
|
+
const onKey = (ev) => {
|
|
181
|
+
const meta = ev.ctrlKey || ev.metaKey;
|
|
182
|
+
if (!meta || ev.key.toLowerCase() !== 'v') return;
|
|
183
|
+
if (ev.shiftKey || ev.altKey) return;
|
|
184
|
+
if (!isOurs()) return;
|
|
185
|
+
if (!navigator.clipboard?.readText) return;
|
|
186
|
+
ev.preventDefault();
|
|
187
|
+
ev.stopPropagation();
|
|
188
|
+
ev.stopImmediatePropagation();
|
|
189
|
+
navigator.clipboard.readText().then((text) => {
|
|
190
|
+
if (text) doPaste(text);
|
|
191
|
+
}).catch(() => {});
|
|
192
|
+
};
|
|
193
|
+
document.addEventListener('paste', onPaste, true);
|
|
194
|
+
document.addEventListener('keydown', onKey, true);
|
|
195
|
+
this.disposables.push(
|
|
196
|
+
() => document.removeEventListener('paste', onPaste, true),
|
|
197
|
+
() => document.removeEventListener('keydown', onKey, true),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_wireModifiedEnterHandler(host) {
|
|
202
|
+
const isOurs = () => {
|
|
203
|
+
const ae = document.activeElement;
|
|
204
|
+
return ae && host.contains(ae);
|
|
205
|
+
};
|
|
206
|
+
const onShiftEnter = (ev) => {
|
|
207
|
+
if (ev.key !== 'Enter') return;
|
|
208
|
+
if (!(ev.shiftKey || ev.ctrlKey)) return;
|
|
209
|
+
if (ev.metaKey || ev.altKey) return;
|
|
210
|
+
if (!isOurs()) return;
|
|
211
|
+
const data = this.cliType === 'claude' ? '\n' : '\x1b\r';
|
|
212
|
+
ev.preventDefault();
|
|
213
|
+
ev.stopPropagation();
|
|
214
|
+
ev.stopImmediatePropagation();
|
|
215
|
+
this.sendInput(data);
|
|
216
|
+
};
|
|
217
|
+
document.addEventListener('keydown', onShiftEnter, true);
|
|
218
|
+
this.disposables.push(() => document.removeEventListener('keydown', onShiftEnter, true));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_wireCompositionHandlers() {
|
|
222
|
+
const helper = this.xterm.helperTextarea;
|
|
223
|
+
this.helperTextarea = helper;
|
|
224
|
+
if (!helper) return;
|
|
225
|
+
const onCompStart = () => this.xterm.setCursorVisible(false);
|
|
226
|
+
const onCompEnd = () => this.xterm.setCursorVisible(true);
|
|
227
|
+
helper.addEventListener('compositionstart', onCompStart);
|
|
228
|
+
helper.addEventListener('compositionend', onCompEnd);
|
|
229
|
+
this.disposables.push(() => {
|
|
230
|
+
helper.removeEventListener('compositionstart', onCompStart);
|
|
231
|
+
helper.removeEventListener('compositionend', onCompEnd);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_registerColorOscHandlers() {
|
|
236
|
+
const answerColorOsc = (code, getHex) => (data) => {
|
|
237
|
+
if (data !== '?') return false;
|
|
238
|
+
const hex = getHex();
|
|
239
|
+
const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
240
|
+
const w = (v) => (v * 257).toString(16).padStart(4, '0');
|
|
241
|
+
const reply = `\x1b]${code};rgb:${w(ch(1))}/${w(ch(3))}/${w(ch(5))}\x07`;
|
|
242
|
+
this.sendInput(reply);
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
this.xterm.parser.registerOscHandler(11, answerColorOsc(11, () => this.xterm.theme.background));
|
|
247
|
+
this.xterm.parser.registerOscHandler(10, answerColorOsc(10, () => this.xterm.theme.foreground));
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_sendFrame(frame) {
|
|
252
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
253
|
+
this.ws.send(JSON.stringify(frame));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_wsUrl() {
|
|
258
|
+
const tok = getToken();
|
|
259
|
+
const dev = getDeviceId();
|
|
260
|
+
const params = new URLSearchParams();
|
|
261
|
+
if (tok) params.set('token', tok);
|
|
262
|
+
if (dev) params.set('device', dev);
|
|
263
|
+
const qs = params.toString();
|
|
264
|
+
return `${wsBase()}/ws/terminal/${encodeURIComponent(this.terminalId)}${qs ? `?${qs}` : ''}`;
|
|
265
|
+
}
|
|
266
|
+
}
|