sanjang 0.3.5 → 0.3.6
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/dashboard/app.js +541 -12
- package/dashboard/index.html +136 -37
- package/dashboard/style.css +293 -4
- package/dist/bin/sanjang.js +144 -1
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +26 -0
- package/dist/lib/engine/conflict.d.ts +13 -0
- package/dist/lib/engine/conflict.js +41 -0
- package/dist/lib/engine/main-server.d.ts +6 -2
- package/dist/lib/engine/main-server.js +152 -82
- package/dist/lib/engine/ports.d.ts +2 -2
- package/dist/lib/engine/ports.js +33 -23
- package/dist/lib/engine/process-utils.d.ts +11 -0
- package/dist/lib/engine/process-utils.js +65 -0
- package/dist/lib/engine/process.d.ts +2 -0
- package/dist/lib/engine/process.js +27 -42
- package/dist/lib/engine/state.js +13 -4
- package/dist/lib/engine/worktree.d.ts +2 -0
- package/dist/lib/engine/worktree.js +30 -8
- package/dist/lib/server.d.ts +1 -0
- package/dist/lib/server.js +464 -49
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
package/dashboard/app.js
CHANGED
|
@@ -60,14 +60,52 @@ function navigatePreview(route) {
|
|
|
60
60
|
const base = new URL(iframe.src);
|
|
61
61
|
base.pathname = route;
|
|
62
62
|
iframe.contentWindow.location.href = base.toString();
|
|
63
|
-
toast(`${route} 로 이동`, 'info');
|
|
64
63
|
} catch {
|
|
65
64
|
// cross-origin — reload with new path
|
|
66
65
|
const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
|
|
67
66
|
iframe.src = src;
|
|
68
67
|
}
|
|
68
|
+
updateUrlBar(route);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function updateUrlBar(route) {
|
|
72
|
+
const input = document.getElementById('ws-url-input');
|
|
73
|
+
if (input) input.value = route || '/';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
window.previewBack = function previewBack() {
|
|
77
|
+
const iframe = document.querySelector('#ws-preview iframe');
|
|
78
|
+
if (!iframe) return;
|
|
79
|
+
try { iframe.contentWindow.history.back(); } catch { /* cross-origin */ }
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
window.previewRefresh = function previewRefresh() {
|
|
83
|
+
const iframe = document.querySelector('#ws-preview iframe');
|
|
84
|
+
if (!iframe) return;
|
|
85
|
+
try { iframe.contentWindow.location.reload(); } catch { iframe.src = iframe.src; }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Viewport presets
|
|
89
|
+
const VIEWPORTS = {
|
|
90
|
+
desktop: { width: '100%', height: '100%', label: '데스크탑' },
|
|
91
|
+
tablet: { width: '768px', height: '100%', label: '태블릿' },
|
|
92
|
+
mobile: { width: '375px', height: '100%', label: '모바일' },
|
|
93
|
+
};
|
|
94
|
+
let currentViewport = 'desktop';
|
|
95
|
+
|
|
96
|
+
window.setViewport = function setViewport(size) {
|
|
97
|
+
currentViewport = size;
|
|
98
|
+
const vp = VIEWPORTS[size];
|
|
99
|
+
const iframe = document.querySelector('#ws-preview iframe');
|
|
100
|
+
if (!iframe) return;
|
|
101
|
+
iframe.style.maxWidth = vp.width;
|
|
102
|
+
iframe.style.margin = size === 'desktop' ? '0' : '0 auto';
|
|
103
|
+
iframe.style.transition = 'max-width 0.2s ease';
|
|
104
|
+
// Update active button
|
|
105
|
+
document.querySelectorAll('.ws-vp-btn').forEach(btn => btn.classList.remove('ws-vp-active'));
|
|
106
|
+
document.querySelector(`.ws-vp-btn[onclick*="${size}"]`)?.classList.add('ws-vp-active');
|
|
107
|
+
};
|
|
108
|
+
|
|
71
109
|
/** @type {Map<string, Array>} diagnostics keyed by playground name */
|
|
72
110
|
const diagnostics = new Map();
|
|
73
111
|
|
|
@@ -211,6 +249,10 @@ function handleWsMessage(msg) {
|
|
|
211
249
|
if (pg) {
|
|
212
250
|
playgrounds.set(name, { ...pg, ...data });
|
|
213
251
|
renderAll();
|
|
252
|
+
// When the current workspace transitions to running, refresh preview
|
|
253
|
+
if (currentWorkspace === name && data.status === 'running' && data.url) {
|
|
254
|
+
api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
|
|
255
|
+
}
|
|
214
256
|
}
|
|
215
257
|
break;
|
|
216
258
|
}
|
|
@@ -300,6 +342,48 @@ function handleWsMessage(msg) {
|
|
|
300
342
|
break;
|
|
301
343
|
}
|
|
302
344
|
|
|
345
|
+
case 'browser-console': {
|
|
346
|
+
if (!name || !data) break;
|
|
347
|
+
if (currentWorkspace !== name) break;
|
|
348
|
+
addBrowserConsole(data);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'browser-network': {
|
|
353
|
+
if (!name || !data) break;
|
|
354
|
+
if (currentWorkspace !== name) break;
|
|
355
|
+
addNetworkRequest(data);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
case 'test-started': {
|
|
360
|
+
if (!name || currentWorkspace !== name) break;
|
|
361
|
+
testOutput = '';
|
|
362
|
+
testRunning = true;
|
|
363
|
+
renderTestPanel();
|
|
364
|
+
switchDevTab('test');
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case 'test-output': {
|
|
368
|
+
if (!name || !data || currentWorkspace !== name) break;
|
|
369
|
+
testOutput += data.text;
|
|
370
|
+
renderTestPanel();
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'test-done': {
|
|
374
|
+
if (!name || !data || currentWorkspace !== name) break;
|
|
375
|
+
testRunning = false;
|
|
376
|
+
testExitCode = data.exitCode;
|
|
377
|
+
renderTestPanel();
|
|
378
|
+
const badge = document.getElementById('ws-test-badge');
|
|
379
|
+
if (badge) {
|
|
380
|
+
badge.style.display = '';
|
|
381
|
+
badge.textContent = data.exitCode === 0 ? '✓' : '✗';
|
|
382
|
+
badge.style.background = data.exitCode === 0 ? '#22c55e' : '#ef4444';
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
303
387
|
case 'file-changes': {
|
|
304
388
|
if (!name || !data) break;
|
|
305
389
|
if (currentWorkspace !== name) break;
|
|
@@ -320,7 +404,7 @@ function handleWsMessage(msg) {
|
|
|
320
404
|
if (summaryText2) summaryText2.textContent = `${data.count}개 파일 변경됨`;
|
|
321
405
|
changesEl2.innerHTML = data.files.map(f => {
|
|
322
406
|
const isNew = !prevPaths.has(f.path);
|
|
323
|
-
return `<div class="ws-file-item${isNew ? ' ws-file-new' : ''}">
|
|
407
|
+
return `<div class="ws-file-item ws-file-clickable${isNew ? ' ws-file-new' : ''}" onclick="showDiff('${escHtml(currentWorkspace)}','${escHtml(f.path)}')">
|
|
324
408
|
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
325
409
|
<span>${escHtml(f.path)}</span>
|
|
326
410
|
</div>`;
|
|
@@ -815,7 +899,19 @@ function branchItemHtml(b) {
|
|
|
815
899
|
+ `</div>`;
|
|
816
900
|
}
|
|
817
901
|
|
|
902
|
+
window.switchNewCampTab = function switchNewCampTab(tab) {
|
|
903
|
+
document.querySelectorAll('.new-camp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
|
904
|
+
document.getElementById('new-camp-quick').style.display = tab === 'quick' ? '' : 'none';
|
|
905
|
+
document.getElementById('new-camp-branch').style.display = tab === 'branch' ? '' : 'none';
|
|
906
|
+
if (tab === 'quick') {
|
|
907
|
+
document.getElementById('modal-quickstart-input').focus();
|
|
908
|
+
} else {
|
|
909
|
+
document.getElementById('new-pg-branch').focus();
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
818
913
|
window.openNewModal = async function openNewModal() {
|
|
914
|
+
document.getElementById('modal-quickstart-input').value = '';
|
|
819
915
|
document.getElementById('new-pg-name').value = '';
|
|
820
916
|
document.getElementById('new-pg-name-error').textContent = '';
|
|
821
917
|
const input = document.getElementById('new-pg-branch');
|
|
@@ -825,6 +921,8 @@ window.openNewModal = async function openNewModal() {
|
|
|
825
921
|
dropdown.innerHTML = '';
|
|
826
922
|
dropdown.classList.remove('open');
|
|
827
923
|
countEl.textContent = '불러오는 중...';
|
|
924
|
+
|
|
925
|
+
switchNewCampTab('quick');
|
|
828
926
|
document.getElementById('new-pg-modal').classList.add('open');
|
|
829
927
|
|
|
830
928
|
try {
|
|
@@ -861,6 +959,34 @@ window.openNewModal = async function openNewModal() {
|
|
|
861
959
|
}, 0);
|
|
862
960
|
};
|
|
863
961
|
|
|
962
|
+
window.modalQuickStart = async function modalQuickStart() {
|
|
963
|
+
const input = document.getElementById('modal-quickstart-input');
|
|
964
|
+
const description = input.value.trim();
|
|
965
|
+
if (!description) { toast('뭘 하고 싶은지 입력해주세요!', 'error'); return; }
|
|
966
|
+
|
|
967
|
+
const btn = document.getElementById('modal-quickstart-btn');
|
|
968
|
+
const origText = btn.textContent;
|
|
969
|
+
btn.disabled = true;
|
|
970
|
+
btn.textContent = '만드는 중...';
|
|
971
|
+
input.disabled = true;
|
|
972
|
+
toast('캠프를 만들고 있습니다... (의존성 설치 중)', 'info');
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const result = await api('POST', '/api/quick-start', { description });
|
|
976
|
+
input.value = '';
|
|
977
|
+
closeNewModal();
|
|
978
|
+
toast(`캠프 "${result.name}" 생성 완료!`, 'success');
|
|
979
|
+
await loadPortal();
|
|
980
|
+
renderAll();
|
|
981
|
+
} catch (err) {
|
|
982
|
+
toast(`생성 실패: ${err.message}`, 'error');
|
|
983
|
+
} finally {
|
|
984
|
+
btn.disabled = false;
|
|
985
|
+
btn.textContent = origText;
|
|
986
|
+
input.disabled = false;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
864
990
|
window.closeNewModal = function closeNewModal() {
|
|
865
991
|
document.getElementById('new-pg-modal').classList.remove('open');
|
|
866
992
|
};
|
|
@@ -1013,6 +1139,60 @@ window.closeChangesModal = function() {
|
|
|
1013
1139
|
changesModalName = null;
|
|
1014
1140
|
};
|
|
1015
1141
|
|
|
1142
|
+
// ---------------------------------------------------------------------------
|
|
1143
|
+
// Diff Modal
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
|
|
1146
|
+
window.showDiff = async function showDiff(campName, filePath) {
|
|
1147
|
+
const modal = document.getElementById('diff-modal');
|
|
1148
|
+
const title = document.getElementById('diff-modal-title');
|
|
1149
|
+
const content = document.getElementById('diff-content');
|
|
1150
|
+
if (!modal || !content) return;
|
|
1151
|
+
|
|
1152
|
+
title.textContent = filePath;
|
|
1153
|
+
content.innerHTML = '<div style="color:var(--text-muted);padding:16px">불러오는 중...</div>';
|
|
1154
|
+
modal.classList.add('open');
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
const data = await api('GET', `/api/playgrounds/${campName}/diff?file=${encodeURIComponent(filePath)}`);
|
|
1158
|
+
|
|
1159
|
+
if (data.type === 'new') {
|
|
1160
|
+
content.innerHTML = `<div class="diff-header">새 파일</div><pre class="diff-pre">${escHtml(data.content)}</pre>`;
|
|
1161
|
+
} else if (data.type === 'deleted') {
|
|
1162
|
+
content.innerHTML = `<div class="diff-header">삭제된 파일</div>`;
|
|
1163
|
+
} else {
|
|
1164
|
+
content.innerHTML = renderDiff(data.diff);
|
|
1165
|
+
}
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
content.innerHTML = `<div style="color:var(--status-error-fg);padding:16px">${escHtml(err.message)}</div>`;
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
window.closeDiffModal = function() {
|
|
1172
|
+
document.getElementById('diff-modal').classList.remove('open');
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
function renderDiff(diff) {
|
|
1176
|
+
if (!diff.trim()) return '<div style="color:var(--text-muted);padding:16px">변경 내용 없음</div>';
|
|
1177
|
+
const lines = diff.split('\n');
|
|
1178
|
+
const html = lines.map(line => {
|
|
1179
|
+
if (line.startsWith('+++') || line.startsWith('---')) {
|
|
1180
|
+
return `<div class="diff-line diff-line-meta">${escHtml(line)}</div>`;
|
|
1181
|
+
}
|
|
1182
|
+
if (line.startsWith('@@')) {
|
|
1183
|
+
return `<div class="diff-line diff-line-hunk">${escHtml(line)}</div>`;
|
|
1184
|
+
}
|
|
1185
|
+
if (line.startsWith('+')) {
|
|
1186
|
+
return `<div class="diff-line diff-line-add">${escHtml(line)}</div>`;
|
|
1187
|
+
}
|
|
1188
|
+
if (line.startsWith('-')) {
|
|
1189
|
+
return `<div class="diff-line diff-line-del">${escHtml(line)}</div>`;
|
|
1190
|
+
}
|
|
1191
|
+
return `<div class="diff-line">${escHtml(line)}</div>`;
|
|
1192
|
+
}).join('');
|
|
1193
|
+
return `<pre class="diff-pre">${html}</pre>`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1016
1196
|
// 행위 단위 되돌리기
|
|
1017
1197
|
window.revertAction = async function revertAction(actionIndex) {
|
|
1018
1198
|
if (!changesModalName) return;
|
|
@@ -1152,6 +1332,7 @@ window.shipPg = async function shipPg() {
|
|
|
1152
1332
|
// ---------------------------------------------------------------------------
|
|
1153
1333
|
|
|
1154
1334
|
let conflictCampName = null;
|
|
1335
|
+
let conflictDetails = [];
|
|
1155
1336
|
|
|
1156
1337
|
window.syncPg = async function syncPg(name) {
|
|
1157
1338
|
if (!confirm('팀의 최신 변경사항을 가져올까요?')) return;
|
|
@@ -1159,13 +1340,46 @@ window.syncPg = async function syncPg(name) {
|
|
|
1159
1340
|
const result = await api('POST', `/api/playgrounds/${name}/sync`);
|
|
1160
1341
|
if (result.conflict) {
|
|
1161
1342
|
conflictCampName = name;
|
|
1343
|
+
conflictDetails = result.conflictDetails || [];
|
|
1162
1344
|
const fileList = document.getElementById('conflict-files');
|
|
1163
|
-
if (
|
|
1164
|
-
fileList.innerHTML =
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1345
|
+
if (conflictDetails.length > 0) {
|
|
1346
|
+
fileList.innerHTML = conflictDetails.map(f => {
|
|
1347
|
+
const sectionsHtml = f.sections.length > 0
|
|
1348
|
+
? f.sections.map((s, i) => `
|
|
1349
|
+
<div class="conflict-section">
|
|
1350
|
+
<div class="conflict-side conflict-ours">
|
|
1351
|
+
<div class="conflict-side-label">내 것</div>
|
|
1352
|
+
<pre>${escHtml(s.ours)}</pre>
|
|
1353
|
+
</div>
|
|
1354
|
+
<div class="conflict-side conflict-theirs">
|
|
1355
|
+
<div class="conflict-side-label">팀 것</div>
|
|
1356
|
+
<pre>${escHtml(s.theirs)}</pre>
|
|
1357
|
+
</div>
|
|
1358
|
+
</div>`).join('')
|
|
1359
|
+
: '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">충돌 내용을 파싱할 수 없습니다</div>';
|
|
1360
|
+
return `<div class="conflict-file">
|
|
1361
|
+
<div class="conflict-file-header">
|
|
1362
|
+
<code>${escHtml(f.path)}</code>
|
|
1363
|
+
<div class="conflict-file-actions">
|
|
1364
|
+
<button class="btn btn-ghost btn-sm" onclick="resolveFile('${escHtml(f.path)}','ours')">내 것</button>
|
|
1365
|
+
<button class="btn btn-ghost btn-sm" onclick="resolveFile('${escHtml(f.path)}','theirs')">팀 것</button>
|
|
1366
|
+
</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
${sectionsHtml}
|
|
1369
|
+
</div>`;
|
|
1370
|
+
}).join('');
|
|
1371
|
+
} else if (result.conflictFiles?.length) {
|
|
1372
|
+
fileList.innerHTML = result.conflictFiles.map(f =>
|
|
1373
|
+
`<div class="conflict-file">
|
|
1374
|
+
<div class="conflict-file-header">
|
|
1375
|
+
<code>${escHtml(f)}</code>
|
|
1376
|
+
<div class="conflict-file-actions">
|
|
1377
|
+
<button class="btn btn-ghost btn-sm" onclick="resolveFile('${escHtml(f)}','ours')">내 것</button>
|
|
1378
|
+
<button class="btn btn-ghost btn-sm" onclick="resolveFile('${escHtml(f)}','theirs')">팀 것</button>
|
|
1379
|
+
</div>
|
|
1380
|
+
</div>
|
|
1381
|
+
</div>`
|
|
1382
|
+
).join('');
|
|
1169
1383
|
}
|
|
1170
1384
|
document.getElementById('conflict-modal').classList.add('open');
|
|
1171
1385
|
} else {
|
|
@@ -1176,6 +1390,30 @@ window.syncPg = async function syncPg(name) {
|
|
|
1176
1390
|
}
|
|
1177
1391
|
};
|
|
1178
1392
|
|
|
1393
|
+
window.resolveFile = async function resolveFile(filePath, strategy) {
|
|
1394
|
+
if (!conflictCampName) return;
|
|
1395
|
+
try {
|
|
1396
|
+
const result = await api('POST', `/api/playgrounds/${conflictCampName}/resolve-file`, { path: filePath, strategy });
|
|
1397
|
+
// Remove resolved file from UI
|
|
1398
|
+
const fileEls = document.querySelectorAll('#conflict-files .conflict-file');
|
|
1399
|
+
for (const el of fileEls) {
|
|
1400
|
+
if (el.querySelector('code')?.textContent === filePath) {
|
|
1401
|
+
el.style.opacity = '0.3';
|
|
1402
|
+
el.querySelector('.conflict-file-actions').innerHTML = `<span style="color:#22c55e;font-size:12px">✓ ${strategy === 'ours' ? '내 것' : '팀 것'}</span>`;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
if (result.remaining === 0) {
|
|
1406
|
+
// All resolved — auto finalize
|
|
1407
|
+
await api('POST', `/api/playgrounds/${conflictCampName}/resolve-finalize`);
|
|
1408
|
+
document.getElementById('conflict-modal').classList.remove('open');
|
|
1409
|
+
toast('모든 충돌이 해결되었습니다!', 'success');
|
|
1410
|
+
conflictCampName = null;
|
|
1411
|
+
}
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
toast(`파일 해결 실패: ${err.message}`, 'error');
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1179
1417
|
window.resolveConflict = async function resolveConflict(strategy) {
|
|
1180
1418
|
if (!conflictCampName) return;
|
|
1181
1419
|
document.getElementById('conflict-modal').classList.remove('open');
|
|
@@ -1219,9 +1457,12 @@ function enterWorkspace(name) {
|
|
|
1219
1457
|
const ws = document.getElementById('workspace');
|
|
1220
1458
|
ws.classList.remove('hidden');
|
|
1221
1459
|
|
|
1222
|
-
// Call enter API
|
|
1223
1460
|
api('POST', `/api/playgrounds/${name}/enter`).then(data => {
|
|
1224
1461
|
renderWorkspace(data);
|
|
1462
|
+
// Auto-start stopped camps so the preview loads immediately
|
|
1463
|
+
if (data.camp.status === 'stopped') {
|
|
1464
|
+
startPg(name);
|
|
1465
|
+
}
|
|
1225
1466
|
}).catch(err => {
|
|
1226
1467
|
toast(`캠프 진입 실패: ${err.message}`, 'error');
|
|
1227
1468
|
exitWorkspace();
|
|
@@ -1236,6 +1477,8 @@ function exitWorkspace() {
|
|
|
1236
1477
|
if (mainPreview) mainPreview.classList.add('hidden');
|
|
1237
1478
|
const container = document.getElementById('ws-preview-container');
|
|
1238
1479
|
if (container) container.classList.remove('ws-split-view');
|
|
1480
|
+
const exitToolbar = document.getElementById('ws-preview-toolbar');
|
|
1481
|
+
if (exitToolbar) exitToolbar.style.display = 'none';
|
|
1239
1482
|
lastReport = null;
|
|
1240
1483
|
currentWorkspace = null;
|
|
1241
1484
|
if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
|
|
@@ -1248,6 +1491,71 @@ function exitWorkspace() {
|
|
|
1248
1491
|
}
|
|
1249
1492
|
window.exitWorkspace = exitWorkspace;
|
|
1250
1493
|
|
|
1494
|
+
// ---------------------------------------------------------------------------
|
|
1495
|
+
// Quick Camp Switcher
|
|
1496
|
+
// ---------------------------------------------------------------------------
|
|
1497
|
+
|
|
1498
|
+
window.toggleCampSwitcher = function toggleCampSwitcher() {
|
|
1499
|
+
const dropdown = document.getElementById('ws-camp-dropdown');
|
|
1500
|
+
if (!dropdown) return;
|
|
1501
|
+
const isOpen = dropdown.classList.toggle('open');
|
|
1502
|
+
if (!isOpen) return;
|
|
1503
|
+
|
|
1504
|
+
const camps = [...playgrounds.values()];
|
|
1505
|
+
if (camps.length <= 1) {
|
|
1506
|
+
dropdown.innerHTML = '<div class="ws-camp-dd-empty">다른 캠프 없음</div>';
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
dropdown.innerHTML = camps
|
|
1510
|
+
.filter(c => c.name !== currentWorkspace)
|
|
1511
|
+
.map(c => {
|
|
1512
|
+
const status = c.status === 'running' ? '🟢' : c.status === 'error' ? '🔴' : '⚪';
|
|
1513
|
+
return `<div class="ws-camp-dd-item" onclick="switchWorkspace('${escHtml(c.name)}')">
|
|
1514
|
+
<span>${status}</span>
|
|
1515
|
+
<span class="ws-camp-dd-name">${escHtml(c.name)}</span>
|
|
1516
|
+
<span class="ws-camp-dd-branch">${escHtml(c.branch)}</span>
|
|
1517
|
+
</div>`;
|
|
1518
|
+
}).join('');
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
window.switchWorkspace = async function switchWorkspace(name) {
|
|
1522
|
+
// Close dropdown
|
|
1523
|
+
const dropdown = document.getElementById('ws-camp-dropdown');
|
|
1524
|
+
if (dropdown) dropdown.classList.remove('open');
|
|
1525
|
+
|
|
1526
|
+
// Clear devtools state
|
|
1527
|
+
browserErrors.length = 0;
|
|
1528
|
+
browserConsole.length = 0;
|
|
1529
|
+
networkRequests.length = 0;
|
|
1530
|
+
clearBrowserErrors();
|
|
1531
|
+
clearBrowserConsole();
|
|
1532
|
+
clearNetworkRequests();
|
|
1533
|
+
|
|
1534
|
+
currentWorkspace = name;
|
|
1535
|
+
try {
|
|
1536
|
+
const data = await api('POST', `/api/playgrounds/${name}/enter`);
|
|
1537
|
+
renderWorkspace(data);
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
toast(`캠프 전환 실패: ${err.message}`, 'error');
|
|
1540
|
+
exitWorkspace();
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
// Cmd+K / Ctrl+K shortcut for camp switcher
|
|
1545
|
+
document.addEventListener('keydown', (e) => {
|
|
1546
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
1547
|
+
e.preventDefault();
|
|
1548
|
+
if (currentWorkspace) toggleCampSwitcher();
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
// Close dropdown on outside click
|
|
1553
|
+
document.addEventListener('click', (e) => {
|
|
1554
|
+
const dropdown = document.getElementById('ws-camp-dropdown');
|
|
1555
|
+
const switcher = e.target.closest?.('.ws-camp-switcher');
|
|
1556
|
+
if (dropdown && !switcher) dropdown.classList.remove('open');
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1251
1559
|
async function fetchAndRenderReport(campName, withAi = false) {
|
|
1252
1560
|
const section = document.getElementById('ws-report-section');
|
|
1253
1561
|
if (!section) return;
|
|
@@ -1286,6 +1594,11 @@ async function fetchAndRenderReport(campName, withAi = false) {
|
|
|
1286
1594
|
if (changeSummaryText && report.summary) {
|
|
1287
1595
|
changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
|
|
1288
1596
|
}
|
|
1597
|
+
// Ensure save button is visible when there are changes
|
|
1598
|
+
const saveBtn = document.getElementById('ws-save-btn');
|
|
1599
|
+
if (saveBtn && report.totalCount > 0) {
|
|
1600
|
+
saveBtn.style.display = '';
|
|
1601
|
+
}
|
|
1289
1602
|
|
|
1290
1603
|
const warningsEl = document.getElementById('ws-report-warnings');
|
|
1291
1604
|
if (report.warnings.length > 0) {
|
|
@@ -1381,7 +1694,7 @@ function renderWorkspace(data) {
|
|
|
1381
1694
|
saveBtn.textContent = '💾 세이브하기';
|
|
1382
1695
|
saveBtn.disabled = false;
|
|
1383
1696
|
changesEl.innerHTML = changes.files.map(f =>
|
|
1384
|
-
`<div class="ws-file-item">
|
|
1697
|
+
`<div class="ws-file-item ws-file-clickable" onclick="showDiff('${escHtml(camp.name)}','${escHtml(f.path)}')">
|
|
1385
1698
|
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
1386
1699
|
<span>${escHtml(f.path)}</span>
|
|
1387
1700
|
</div>`
|
|
@@ -1421,6 +1734,7 @@ function renderWorkspace(data) {
|
|
|
1421
1734
|
|
|
1422
1735
|
// Preview — use proxy URL (same origin, no X-Frame-Options issues)
|
|
1423
1736
|
const previewEl = document.getElementById('ws-preview');
|
|
1737
|
+
const previewToolbar = document.getElementById('ws-preview-toolbar');
|
|
1424
1738
|
if (previewUrl) {
|
|
1425
1739
|
const port = new URL(previewUrl).port || '80';
|
|
1426
1740
|
const proxyUrl = `/preview/${port}/`;
|
|
@@ -1436,7 +1750,11 @@ function renderWorkspace(data) {
|
|
|
1436
1750
|
iframe.style.display = 'none';
|
|
1437
1751
|
previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
|
|
1438
1752
|
});
|
|
1753
|
+
if (previewToolbar) previewToolbar.style.display = '';
|
|
1754
|
+
updateUrlBar('/');
|
|
1755
|
+
if (currentViewport !== 'desktop') setViewport(currentViewport);
|
|
1439
1756
|
} else {
|
|
1757
|
+
if (previewToolbar) previewToolbar.style.display = 'none';
|
|
1440
1758
|
previewEl.innerHTML = `
|
|
1441
1759
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
|
|
1442
1760
|
<div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
|
|
@@ -1704,7 +2022,7 @@ function startWorkspacePolling(name) {
|
|
|
1704
2022
|
changesEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">변경 없음</span>';
|
|
1705
2023
|
} else {
|
|
1706
2024
|
changesEl.innerHTML = data.files.map(f =>
|
|
1707
|
-
`<div class="ws-file-item">
|
|
2025
|
+
`<div class="ws-file-item ws-file-clickable" onclick="showDiff('${escHtml(currentWorkspace)}','${escHtml(f.path)}')">
|
|
1708
2026
|
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
1709
2027
|
<span>${escHtml(f.path)}</span>
|
|
1710
2028
|
</div>`
|
|
@@ -1770,9 +2088,11 @@ function renderBrowserErrors() {
|
|
|
1770
2088
|
if (fixBtn) fixBtn.style.display = '';
|
|
1771
2089
|
panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
|
|
1772
2090
|
const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
|
|
2091
|
+
const stackHtml = e.stack ? `<details class="ws-error-stack"><summary>스택 트레이스</summary><pre>${escHtml(e.stack)}</pre></details>` : '';
|
|
1773
2092
|
return `<div class="ws-browser-error-item">
|
|
1774
2093
|
<span class="ws-browser-error-level">${escHtml(e.level)}</span>
|
|
1775
2094
|
<span class="ws-browser-error-msg">${escHtml(e.message)}</span>${loc}
|
|
2095
|
+
${stackHtml}
|
|
1776
2096
|
</div>`;
|
|
1777
2097
|
}).join('');
|
|
1778
2098
|
}
|
|
@@ -1815,6 +2135,7 @@ window.copyFixPrompt = async function copyFixPrompt() {
|
|
|
1815
2135
|
const errs = browserErrors.slice(-10).map(e => {
|
|
1816
2136
|
let line = `[${e.level}] ${e.message}`;
|
|
1817
2137
|
if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
|
|
2138
|
+
if (e.stack) line += `\n 스택:\n${e.stack.split('\n').map(s => ' ' + s.trim()).join('\n')}`;
|
|
1818
2139
|
return line;
|
|
1819
2140
|
}).join('\n\n');
|
|
1820
2141
|
sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
|
|
@@ -1873,6 +2194,136 @@ function clearBrowserErrors() {
|
|
|
1873
2194
|
renderBrowserErrors();
|
|
1874
2195
|
}
|
|
1875
2196
|
|
|
2197
|
+
// ---------------------------------------------------------------------------
|
|
2198
|
+
// Console Panel
|
|
2199
|
+
// ---------------------------------------------------------------------------
|
|
2200
|
+
|
|
2201
|
+
/** @type {Array<{level: string, message: string, ts: number}>} */
|
|
2202
|
+
const browserConsole = [];
|
|
2203
|
+
|
|
2204
|
+
function addBrowserConsole(data) {
|
|
2205
|
+
browserConsole.push({ ...data, ts: Date.now() });
|
|
2206
|
+
if (browserConsole.length > 200) browserConsole.splice(0, browserConsole.length - 200);
|
|
2207
|
+
renderBrowserConsole();
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function renderBrowserConsole() {
|
|
2211
|
+
const panel = document.getElementById('ws-console-panel');
|
|
2212
|
+
if (!panel) return;
|
|
2213
|
+
const badge = document.getElementById('ws-console-badge');
|
|
2214
|
+
if (browserConsole.length === 0) {
|
|
2215
|
+
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">로그 없음</span>';
|
|
2216
|
+
if (badge) badge.style.display = 'none';
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (badge) { badge.style.display = ''; badge.textContent = browserConsole.length; }
|
|
2220
|
+
panel.innerHTML = browserConsole.slice(-50).reverse().map(e => {
|
|
2221
|
+
const cls = e.level === 'warn' ? 'ws-console-warn' : e.level === 'info' ? 'ws-console-info' : 'ws-console-log';
|
|
2222
|
+
return `<div class="ws-console-item ${cls}">${escHtml(e.message)}</div>`;
|
|
2223
|
+
}).join('');
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function clearBrowserConsole() {
|
|
2227
|
+
browserConsole.length = 0;
|
|
2228
|
+
renderBrowserConsole();
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// ---------------------------------------------------------------------------
|
|
2232
|
+
// Network Panel
|
|
2233
|
+
// ---------------------------------------------------------------------------
|
|
2234
|
+
|
|
2235
|
+
/** @type {Array<{url: string, method: string, status: number, duration: number, error?: string, ts: number}>} */
|
|
2236
|
+
const networkRequests = [];
|
|
2237
|
+
|
|
2238
|
+
function addNetworkRequest(data) {
|
|
2239
|
+
networkRequests.push({ ...data, ts: Date.now() });
|
|
2240
|
+
if (networkRequests.length > 100) networkRequests.shift();
|
|
2241
|
+
renderNetworkRequests();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function renderNetworkRequests() {
|
|
2245
|
+
const panel = document.getElementById('ws-network-panel');
|
|
2246
|
+
if (!panel) return;
|
|
2247
|
+
const badge = document.getElementById('ws-network-badge');
|
|
2248
|
+
if (networkRequests.length === 0) {
|
|
2249
|
+
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">요청 없음</span>';
|
|
2250
|
+
if (badge) badge.style.display = 'none';
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
const failed = networkRequests.filter(r => r.status >= 400 || r.status === 0).length;
|
|
2254
|
+
if (badge) {
|
|
2255
|
+
badge.style.display = failed > 0 ? '' : 'none';
|
|
2256
|
+
badge.textContent = failed;
|
|
2257
|
+
}
|
|
2258
|
+
panel.innerHTML = networkRequests.slice(-30).reverse().map(r => {
|
|
2259
|
+
const statusCls = r.status === 0 ? 'ws-net-err' : r.status >= 400 ? 'ws-net-err' : r.status >= 300 ? 'ws-net-warn' : 'ws-net-ok';
|
|
2260
|
+
const urlShort = r.url.length > 60 ? '...' + r.url.slice(-57) : r.url;
|
|
2261
|
+
return `<div class="ws-net-item">
|
|
2262
|
+
<span class="ws-net-method ws-net-method-${r.method.toLowerCase()}">${escHtml(r.method)}</span>
|
|
2263
|
+
<span class="ws-net-url" title="${escHtml(r.url)}">${escHtml(urlShort)}</span>
|
|
2264
|
+
<span class="ws-net-status ${statusCls}">${r.status || 'ERR'}</span>
|
|
2265
|
+
<span class="ws-net-dur">${r.duration}ms</span>
|
|
2266
|
+
</div>`;
|
|
2267
|
+
}).join('');
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function clearNetworkRequests() {
|
|
2271
|
+
networkRequests.length = 0;
|
|
2272
|
+
renderNetworkRequests();
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// ---------------------------------------------------------------------------
|
|
2276
|
+
// Test Runner Panel
|
|
2277
|
+
// ---------------------------------------------------------------------------
|
|
2278
|
+
|
|
2279
|
+
let testOutput = '';
|
|
2280
|
+
let testRunning = false;
|
|
2281
|
+
let testExitCode = null;
|
|
2282
|
+
|
|
2283
|
+
function renderTestPanel() {
|
|
2284
|
+
const panel = document.getElementById('ws-test-panel');
|
|
2285
|
+
if (!panel) return;
|
|
2286
|
+
let html = '';
|
|
2287
|
+
if (testRunning) {
|
|
2288
|
+
html += '<div class="ws-test-status ws-test-running">실행 중...</div>';
|
|
2289
|
+
} else if (testExitCode !== null) {
|
|
2290
|
+
html += testExitCode === 0
|
|
2291
|
+
? '<div class="ws-test-status ws-test-pass">✅ 테스트 통과</div>'
|
|
2292
|
+
: '<div class="ws-test-status ws-test-fail">❌ 테스트 실패 (exit ' + testExitCode + ')</div>';
|
|
2293
|
+
}
|
|
2294
|
+
if (testOutput) {
|
|
2295
|
+
html += `<pre class="ws-test-output">${escHtml(testOutput)}</pre>`;
|
|
2296
|
+
}
|
|
2297
|
+
panel.innerHTML = html || '<span style="color:var(--text-muted);font-size:12px">🧪 버튼을 눌러 테스트 실행</span>';
|
|
2298
|
+
// Auto-scroll
|
|
2299
|
+
const pre = panel.querySelector('pre');
|
|
2300
|
+
if (pre) pre.scrollTop = pre.scrollHeight;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
window.wsRunTest = async function wsRunTest() {
|
|
2304
|
+
if (!currentWorkspace) return;
|
|
2305
|
+
try {
|
|
2306
|
+
testOutput = '';
|
|
2307
|
+
testRunning = true;
|
|
2308
|
+
testExitCode = null;
|
|
2309
|
+
renderTestPanel();
|
|
2310
|
+
switchDevTab('test');
|
|
2311
|
+
await api('POST', `/api/playgrounds/${currentWorkspace}/test`);
|
|
2312
|
+
} catch (err) {
|
|
2313
|
+
testRunning = false;
|
|
2314
|
+
toast(`테스트 실행 실패: ${err.message}`, 'error');
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
// Tab switching for devtools panel
|
|
2319
|
+
window.switchDevTab = function switchDevTab(tab) {
|
|
2320
|
+
document.querySelectorAll('.ws-devtab-btn').forEach(b => b.classList.remove('ws-devtab-active'));
|
|
2321
|
+
document.querySelectorAll('.ws-devtab-panel').forEach(p => p.style.display = 'none');
|
|
2322
|
+
document.querySelector(`.ws-devtab-btn[data-tab="${tab}"]`)?.classList.add('ws-devtab-active');
|
|
2323
|
+
const panel = document.getElementById(`ws-devtab-${tab}`);
|
|
2324
|
+
if (panel) panel.style.display = '';
|
|
2325
|
+
};
|
|
2326
|
+
|
|
1876
2327
|
window.wsShip = async function() {
|
|
1877
2328
|
if (!currentWorkspace) return;
|
|
1878
2329
|
// Fetch report for ship confirmation
|
|
@@ -2048,8 +2499,11 @@ async function loadPortal() {
|
|
|
2048
2499
|
: 'pending';
|
|
2049
2500
|
const timeAgo = new Date(item.updatedAt).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
|
2050
2501
|
|
|
2502
|
+
const onclick = item.camp
|
|
2503
|
+
? `enterWorkspace('${escHtml(item.camp)}')`
|
|
2504
|
+
: `resumePrWork('${escHtml(item.branch)}')`;
|
|
2051
2505
|
return `
|
|
2052
|
-
<div class="portal-work-item" onclick="${
|
|
2506
|
+
<div class="portal-work-item" onclick="${onclick}">
|
|
2053
2507
|
<div class="portal-work-left">
|
|
2054
2508
|
<span class="portal-work-icon">🟡</span>
|
|
2055
2509
|
<div>
|
|
@@ -2076,8 +2530,66 @@ async function loadPortal() {
|
|
|
2076
2530
|
} catch (err) {
|
|
2077
2531
|
workList.innerHTML = '<div class="portal-empty">작업 목록을 불러올 수 없습니다</div>';
|
|
2078
2532
|
}
|
|
2533
|
+
|
|
2534
|
+
// Check for stale camps
|
|
2535
|
+
try {
|
|
2536
|
+
const stale = await api('GET', '/api/camps/stale?days=7');
|
|
2537
|
+
const banner = document.getElementById('portal-stale-banner');
|
|
2538
|
+
if (banner && stale.length > 0) {
|
|
2539
|
+
const totalSize = stale.map(s => s.size).join(' + ');
|
|
2540
|
+
banner.style.display = '';
|
|
2541
|
+
banner.innerHTML = `
|
|
2542
|
+
<span>🧹 ${stale.length}개 캠프를 7일 이상 사용하지 않았어요 (${totalSize})</span>
|
|
2543
|
+
<button class="btn btn-ghost btn-sm" onclick="showStaleCleanup()">정리하기</button>
|
|
2544
|
+
`;
|
|
2545
|
+
}
|
|
2546
|
+
} catch { /* ignore */ }
|
|
2079
2547
|
}
|
|
2080
2548
|
|
|
2549
|
+
window.showStaleCleanup = async function showStaleCleanup() {
|
|
2550
|
+
try {
|
|
2551
|
+
const stale = await api('GET', '/api/camps/stale?days=7');
|
|
2552
|
+
if (stale.length === 0) { toast('정리할 캠프가 없습니다', 'info'); return; }
|
|
2553
|
+
|
|
2554
|
+
const html = stale.map(s =>
|
|
2555
|
+
`<label class="ws-stale-item">
|
|
2556
|
+
<input type="checkbox" value="${escHtml(s.name)}" checked>
|
|
2557
|
+
<span>${escHtml(s.name)}</span>
|
|
2558
|
+
<span style="color:var(--text-muted);font-size:11px">${escHtml(s.branch)} · ${escHtml(s.size)} · ${s.lastAccessedAt ? new Date(s.lastAccessedAt).toLocaleDateString('ko-KR') : '기록 없음'}</span>
|
|
2559
|
+
</label>`
|
|
2560
|
+
).join('');
|
|
2561
|
+
|
|
2562
|
+
const modal = document.getElementById('stale-modal');
|
|
2563
|
+
if (!modal) return;
|
|
2564
|
+
document.getElementById('stale-list').innerHTML = html;
|
|
2565
|
+
modal.classList.add('open');
|
|
2566
|
+
} catch (err) {
|
|
2567
|
+
toast(`정리 목록 로드 실패: ${err.message}`, 'error');
|
|
2568
|
+
}
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
window.confirmStaleCleanup = async function confirmStaleCleanup() {
|
|
2572
|
+
const checked = [...document.querySelectorAll('#stale-list input:checked')].map(el => el.value);
|
|
2573
|
+
if (checked.length === 0) { toast('선택된 캠프가 없습니다', 'info'); return; }
|
|
2574
|
+
if (!confirm(`${checked.length}개 캠프를 삭제합니다. 되돌릴 수 없습니다.`)) return;
|
|
2575
|
+
|
|
2576
|
+
let deleted = 0;
|
|
2577
|
+
for (const name of checked) {
|
|
2578
|
+
try {
|
|
2579
|
+
await api('DELETE', `/api/playgrounds/${name}`);
|
|
2580
|
+
deleted++;
|
|
2581
|
+
} catch { /* continue */ }
|
|
2582
|
+
}
|
|
2583
|
+
toast(`${deleted}개 캠프 정리 완료`, 'success');
|
|
2584
|
+
document.getElementById('stale-modal').classList.remove('open');
|
|
2585
|
+
document.getElementById('portal-stale-banner').style.display = 'none';
|
|
2586
|
+
loadPortal();
|
|
2587
|
+
};
|
|
2588
|
+
|
|
2589
|
+
window.closeStaleModal = function() {
|
|
2590
|
+
document.getElementById('stale-modal').classList.remove('open');
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2081
2593
|
window.quickStart = async function quickStart() {
|
|
2082
2594
|
const input = document.getElementById('quickstart-input');
|
|
2083
2595
|
const description = input.value.trim();
|
|
@@ -2107,6 +2619,23 @@ window.quickStart = async function quickStart() {
|
|
|
2107
2619
|
};
|
|
2108
2620
|
|
|
2109
2621
|
|
|
2622
|
+
window.resumePrWork = async function resumePrWork(branch) {
|
|
2623
|
+
const name = branch.replace(/^[^/]+\//, '').replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 30) || 'pr-camp';
|
|
2624
|
+
toast(`"${name}" 캠프를 만들고 있습니다...`, 'info');
|
|
2625
|
+
try {
|
|
2626
|
+
await api('POST', '/api/playgrounds', { name, branch });
|
|
2627
|
+
await loadPortal();
|
|
2628
|
+
renderAll();
|
|
2629
|
+
enterWorkspace(name);
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
if (err.message?.includes('이미 있습니다')) {
|
|
2632
|
+
enterWorkspace(name);
|
|
2633
|
+
} else {
|
|
2634
|
+
toast(`캠프 생성 실패: ${err.message}`, 'error');
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2110
2639
|
window.autoFix = async function autoFix(name) {
|
|
2111
2640
|
toast('문제를 분석하고 있습니다...', 'info');
|
|
2112
2641
|
try {
|