sanjang 0.3.5 → 0.3.7
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 +572 -26
- 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 +496 -49
- package/dist/lib/types.d.ts +6 -0
- package/package.json +7 -4
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,22 +1734,44 @@ 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
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1739
|
+
const hasExtension = !!window.__SANJANG_EXTENSION__;
|
|
1740
|
+
if (hasExtension) {
|
|
1741
|
+
previewEl.innerHTML = `
|
|
1742
|
+
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
|
|
1743
|
+
<div style="font-size:48px">⛰</div>
|
|
1744
|
+
<div style="color:var(--text-muted);font-size:14px;text-align:center;">
|
|
1745
|
+
확장이 설치되어 있어유!<br>
|
|
1746
|
+
dev 서버를 직접 볼 수 있어유.
|
|
1747
|
+
</div>
|
|
1748
|
+
<a href="${escHtml(previewUrl)}" target="_blank" class="btn btn-primary" style="text-decoration:none">
|
|
1749
|
+
새 탭에서 열기 → ${escHtml(previewUrl)}
|
|
1750
|
+
</a>
|
|
1751
|
+
</div>`;
|
|
1752
|
+
if (previewToolbar) previewToolbar.style.display = 'none';
|
|
1753
|
+
} else {
|
|
1754
|
+
// No extension — use iframe+proxy fallback
|
|
1755
|
+
const port = new URL(previewUrl).port || '80';
|
|
1756
|
+
const proxyUrl = `/preview/${port}/`;
|
|
1757
|
+
previewEl.innerHTML = `
|
|
1758
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>
|
|
1759
|
+
<div class="ws-preview-fallback" style="display:none">
|
|
1760
|
+
<a href="${escHtml(previewUrl)}" target="_blank" class="btn btn-primary">
|
|
1761
|
+
새 탭에서 열기 → ${escHtml(previewUrl)}
|
|
1762
|
+
</a>
|
|
1763
|
+
</div>`;
|
|
1764
|
+
const iframe = previewEl.querySelector('iframe');
|
|
1765
|
+
iframe.addEventListener('error', () => {
|
|
1766
|
+
iframe.style.display = 'none';
|
|
1767
|
+
previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
|
|
1768
|
+
});
|
|
1769
|
+
if (previewToolbar) previewToolbar.style.display = '';
|
|
1770
|
+
updateUrlBar('/');
|
|
1771
|
+
if (currentViewport !== 'desktop') setViewport(currentViewport);
|
|
1772
|
+
}
|
|
1439
1773
|
} else {
|
|
1774
|
+
if (previewToolbar) previewToolbar.style.display = 'none';
|
|
1440
1775
|
previewEl.innerHTML = `
|
|
1441
1776
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
|
|
1442
1777
|
<div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
|
|
@@ -1704,7 +2039,7 @@ function startWorkspacePolling(name) {
|
|
|
1704
2039
|
changesEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">변경 없음</span>';
|
|
1705
2040
|
} else {
|
|
1706
2041
|
changesEl.innerHTML = data.files.map(f =>
|
|
1707
|
-
`<div class="ws-file-item">
|
|
2042
|
+
`<div class="ws-file-item ws-file-clickable" onclick="showDiff('${escHtml(currentWorkspace)}','${escHtml(f.path)}')">
|
|
1708
2043
|
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
1709
2044
|
<span>${escHtml(f.path)}</span>
|
|
1710
2045
|
</div>`
|
|
@@ -1770,9 +2105,11 @@ function renderBrowserErrors() {
|
|
|
1770
2105
|
if (fixBtn) fixBtn.style.display = '';
|
|
1771
2106
|
panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
|
|
1772
2107
|
const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
|
|
2108
|
+
const stackHtml = e.stack ? `<details class="ws-error-stack"><summary>스택 트레이스</summary><pre>${escHtml(e.stack)}</pre></details>` : '';
|
|
1773
2109
|
return `<div class="ws-browser-error-item">
|
|
1774
2110
|
<span class="ws-browser-error-level">${escHtml(e.level)}</span>
|
|
1775
2111
|
<span class="ws-browser-error-msg">${escHtml(e.message)}</span>${loc}
|
|
2112
|
+
${stackHtml}
|
|
1776
2113
|
</div>`;
|
|
1777
2114
|
}).join('');
|
|
1778
2115
|
}
|
|
@@ -1815,6 +2152,7 @@ window.copyFixPrompt = async function copyFixPrompt() {
|
|
|
1815
2152
|
const errs = browserErrors.slice(-10).map(e => {
|
|
1816
2153
|
let line = `[${e.level}] ${e.message}`;
|
|
1817
2154
|
if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
|
|
2155
|
+
if (e.stack) line += `\n 스택:\n${e.stack.split('\n').map(s => ' ' + s.trim()).join('\n')}`;
|
|
1818
2156
|
return line;
|
|
1819
2157
|
}).join('\n\n');
|
|
1820
2158
|
sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
|
|
@@ -1873,6 +2211,136 @@ function clearBrowserErrors() {
|
|
|
1873
2211
|
renderBrowserErrors();
|
|
1874
2212
|
}
|
|
1875
2213
|
|
|
2214
|
+
// ---------------------------------------------------------------------------
|
|
2215
|
+
// Console Panel
|
|
2216
|
+
// ---------------------------------------------------------------------------
|
|
2217
|
+
|
|
2218
|
+
/** @type {Array<{level: string, message: string, ts: number}>} */
|
|
2219
|
+
const browserConsole = [];
|
|
2220
|
+
|
|
2221
|
+
function addBrowserConsole(data) {
|
|
2222
|
+
browserConsole.push({ ...data, ts: Date.now() });
|
|
2223
|
+
if (browserConsole.length > 200) browserConsole.splice(0, browserConsole.length - 200);
|
|
2224
|
+
renderBrowserConsole();
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function renderBrowserConsole() {
|
|
2228
|
+
const panel = document.getElementById('ws-console-panel');
|
|
2229
|
+
if (!panel) return;
|
|
2230
|
+
const badge = document.getElementById('ws-console-badge');
|
|
2231
|
+
if (browserConsole.length === 0) {
|
|
2232
|
+
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">로그 없음</span>';
|
|
2233
|
+
if (badge) badge.style.display = 'none';
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (badge) { badge.style.display = ''; badge.textContent = browserConsole.length; }
|
|
2237
|
+
panel.innerHTML = browserConsole.slice(-50).reverse().map(e => {
|
|
2238
|
+
const cls = e.level === 'warn' ? 'ws-console-warn' : e.level === 'info' ? 'ws-console-info' : 'ws-console-log';
|
|
2239
|
+
return `<div class="ws-console-item ${cls}">${escHtml(e.message)}</div>`;
|
|
2240
|
+
}).join('');
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function clearBrowserConsole() {
|
|
2244
|
+
browserConsole.length = 0;
|
|
2245
|
+
renderBrowserConsole();
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ---------------------------------------------------------------------------
|
|
2249
|
+
// Network Panel
|
|
2250
|
+
// ---------------------------------------------------------------------------
|
|
2251
|
+
|
|
2252
|
+
/** @type {Array<{url: string, method: string, status: number, duration: number, error?: string, ts: number}>} */
|
|
2253
|
+
const networkRequests = [];
|
|
2254
|
+
|
|
2255
|
+
function addNetworkRequest(data) {
|
|
2256
|
+
networkRequests.push({ ...data, ts: Date.now() });
|
|
2257
|
+
if (networkRequests.length > 100) networkRequests.shift();
|
|
2258
|
+
renderNetworkRequests();
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
function renderNetworkRequests() {
|
|
2262
|
+
const panel = document.getElementById('ws-network-panel');
|
|
2263
|
+
if (!panel) return;
|
|
2264
|
+
const badge = document.getElementById('ws-network-badge');
|
|
2265
|
+
if (networkRequests.length === 0) {
|
|
2266
|
+
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">요청 없음</span>';
|
|
2267
|
+
if (badge) badge.style.display = 'none';
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const failed = networkRequests.filter(r => r.status >= 400 || r.status === 0).length;
|
|
2271
|
+
if (badge) {
|
|
2272
|
+
badge.style.display = failed > 0 ? '' : 'none';
|
|
2273
|
+
badge.textContent = failed;
|
|
2274
|
+
}
|
|
2275
|
+
panel.innerHTML = networkRequests.slice(-30).reverse().map(r => {
|
|
2276
|
+
const statusCls = r.status === 0 ? 'ws-net-err' : r.status >= 400 ? 'ws-net-err' : r.status >= 300 ? 'ws-net-warn' : 'ws-net-ok';
|
|
2277
|
+
const urlShort = r.url.length > 60 ? '...' + r.url.slice(-57) : r.url;
|
|
2278
|
+
return `<div class="ws-net-item">
|
|
2279
|
+
<span class="ws-net-method ws-net-method-${r.method.toLowerCase()}">${escHtml(r.method)}</span>
|
|
2280
|
+
<span class="ws-net-url" title="${escHtml(r.url)}">${escHtml(urlShort)}</span>
|
|
2281
|
+
<span class="ws-net-status ${statusCls}">${r.status || 'ERR'}</span>
|
|
2282
|
+
<span class="ws-net-dur">${r.duration}ms</span>
|
|
2283
|
+
</div>`;
|
|
2284
|
+
}).join('');
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function clearNetworkRequests() {
|
|
2288
|
+
networkRequests.length = 0;
|
|
2289
|
+
renderNetworkRequests();
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// ---------------------------------------------------------------------------
|
|
2293
|
+
// Test Runner Panel
|
|
2294
|
+
// ---------------------------------------------------------------------------
|
|
2295
|
+
|
|
2296
|
+
let testOutput = '';
|
|
2297
|
+
let testRunning = false;
|
|
2298
|
+
let testExitCode = null;
|
|
2299
|
+
|
|
2300
|
+
function renderTestPanel() {
|
|
2301
|
+
const panel = document.getElementById('ws-test-panel');
|
|
2302
|
+
if (!panel) return;
|
|
2303
|
+
let html = '';
|
|
2304
|
+
if (testRunning) {
|
|
2305
|
+
html += '<div class="ws-test-status ws-test-running">실행 중...</div>';
|
|
2306
|
+
} else if (testExitCode !== null) {
|
|
2307
|
+
html += testExitCode === 0
|
|
2308
|
+
? '<div class="ws-test-status ws-test-pass">✅ 테스트 통과</div>'
|
|
2309
|
+
: '<div class="ws-test-status ws-test-fail">❌ 테스트 실패 (exit ' + testExitCode + ')</div>';
|
|
2310
|
+
}
|
|
2311
|
+
if (testOutput) {
|
|
2312
|
+
html += `<pre class="ws-test-output">${escHtml(testOutput)}</pre>`;
|
|
2313
|
+
}
|
|
2314
|
+
panel.innerHTML = html || '<span style="color:var(--text-muted);font-size:12px">🧪 버튼을 눌러 테스트 실행</span>';
|
|
2315
|
+
// Auto-scroll
|
|
2316
|
+
const pre = panel.querySelector('pre');
|
|
2317
|
+
if (pre) pre.scrollTop = pre.scrollHeight;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
window.wsRunTest = async function wsRunTest() {
|
|
2321
|
+
if (!currentWorkspace) return;
|
|
2322
|
+
try {
|
|
2323
|
+
testOutput = '';
|
|
2324
|
+
testRunning = true;
|
|
2325
|
+
testExitCode = null;
|
|
2326
|
+
renderTestPanel();
|
|
2327
|
+
switchDevTab('test');
|
|
2328
|
+
await api('POST', `/api/playgrounds/${currentWorkspace}/test`);
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
testRunning = false;
|
|
2331
|
+
toast(`테스트 실행 실패: ${err.message}`, 'error');
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
|
|
2335
|
+
// Tab switching for devtools panel
|
|
2336
|
+
window.switchDevTab = function switchDevTab(tab) {
|
|
2337
|
+
document.querySelectorAll('.ws-devtab-btn').forEach(b => b.classList.remove('ws-devtab-active'));
|
|
2338
|
+
document.querySelectorAll('.ws-devtab-panel').forEach(p => p.style.display = 'none');
|
|
2339
|
+
document.querySelector(`.ws-devtab-btn[data-tab="${tab}"]`)?.classList.add('ws-devtab-active');
|
|
2340
|
+
const panel = document.getElementById(`ws-devtab-${tab}`);
|
|
2341
|
+
if (panel) panel.style.display = '';
|
|
2342
|
+
};
|
|
2343
|
+
|
|
1876
2344
|
window.wsShip = async function() {
|
|
1877
2345
|
if (!currentWorkspace) return;
|
|
1878
2346
|
// Fetch report for ship confirmation
|
|
@@ -2048,8 +2516,11 @@ async function loadPortal() {
|
|
|
2048
2516
|
: 'pending';
|
|
2049
2517
|
const timeAgo = new Date(item.updatedAt).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
|
2050
2518
|
|
|
2519
|
+
const onclick = item.camp
|
|
2520
|
+
? `enterWorkspace('${escHtml(item.camp)}')`
|
|
2521
|
+
: `resumePrWork('${escHtml(item.branch)}')`;
|
|
2051
2522
|
return `
|
|
2052
|
-
<div class="portal-work-item" onclick="${
|
|
2523
|
+
<div class="portal-work-item" onclick="${onclick}">
|
|
2053
2524
|
<div class="portal-work-left">
|
|
2054
2525
|
<span class="portal-work-icon">🟡</span>
|
|
2055
2526
|
<div>
|
|
@@ -2076,8 +2547,66 @@ async function loadPortal() {
|
|
|
2076
2547
|
} catch (err) {
|
|
2077
2548
|
workList.innerHTML = '<div class="portal-empty">작업 목록을 불러올 수 없습니다</div>';
|
|
2078
2549
|
}
|
|
2550
|
+
|
|
2551
|
+
// Check for stale camps
|
|
2552
|
+
try {
|
|
2553
|
+
const stale = await api('GET', '/api/camps/stale?days=7');
|
|
2554
|
+
const banner = document.getElementById('portal-stale-banner');
|
|
2555
|
+
if (banner && stale.length > 0) {
|
|
2556
|
+
const totalSize = stale.map(s => s.size).join(' + ');
|
|
2557
|
+
banner.style.display = '';
|
|
2558
|
+
banner.innerHTML = `
|
|
2559
|
+
<span>🧹 ${stale.length}개 캠프를 7일 이상 사용하지 않았어요 (${totalSize})</span>
|
|
2560
|
+
<button class="btn btn-ghost btn-sm" onclick="showStaleCleanup()">정리하기</button>
|
|
2561
|
+
`;
|
|
2562
|
+
}
|
|
2563
|
+
} catch { /* ignore */ }
|
|
2079
2564
|
}
|
|
2080
2565
|
|
|
2566
|
+
window.showStaleCleanup = async function showStaleCleanup() {
|
|
2567
|
+
try {
|
|
2568
|
+
const stale = await api('GET', '/api/camps/stale?days=7');
|
|
2569
|
+
if (stale.length === 0) { toast('정리할 캠프가 없습니다', 'info'); return; }
|
|
2570
|
+
|
|
2571
|
+
const html = stale.map(s =>
|
|
2572
|
+
`<label class="ws-stale-item">
|
|
2573
|
+
<input type="checkbox" value="${escHtml(s.name)}" checked>
|
|
2574
|
+
<span>${escHtml(s.name)}</span>
|
|
2575
|
+
<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>
|
|
2576
|
+
</label>`
|
|
2577
|
+
).join('');
|
|
2578
|
+
|
|
2579
|
+
const modal = document.getElementById('stale-modal');
|
|
2580
|
+
if (!modal) return;
|
|
2581
|
+
document.getElementById('stale-list').innerHTML = html;
|
|
2582
|
+
modal.classList.add('open');
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
toast(`정리 목록 로드 실패: ${err.message}`, 'error');
|
|
2585
|
+
}
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
window.confirmStaleCleanup = async function confirmStaleCleanup() {
|
|
2589
|
+
const checked = [...document.querySelectorAll('#stale-list input:checked')].map(el => el.value);
|
|
2590
|
+
if (checked.length === 0) { toast('선택된 캠프가 없습니다', 'info'); return; }
|
|
2591
|
+
if (!confirm(`${checked.length}개 캠프를 삭제합니다. 되돌릴 수 없습니다.`)) return;
|
|
2592
|
+
|
|
2593
|
+
let deleted = 0;
|
|
2594
|
+
for (const name of checked) {
|
|
2595
|
+
try {
|
|
2596
|
+
await api('DELETE', `/api/playgrounds/${name}`);
|
|
2597
|
+
deleted++;
|
|
2598
|
+
} catch { /* continue */ }
|
|
2599
|
+
}
|
|
2600
|
+
toast(`${deleted}개 캠프 정리 완료`, 'success');
|
|
2601
|
+
document.getElementById('stale-modal').classList.remove('open');
|
|
2602
|
+
document.getElementById('portal-stale-banner').style.display = 'none';
|
|
2603
|
+
loadPortal();
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
window.closeStaleModal = function() {
|
|
2607
|
+
document.getElementById('stale-modal').classList.remove('open');
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2081
2610
|
window.quickStart = async function quickStart() {
|
|
2082
2611
|
const input = document.getElementById('quickstart-input');
|
|
2083
2612
|
const description = input.value.trim();
|
|
@@ -2107,6 +2636,23 @@ window.quickStart = async function quickStart() {
|
|
|
2107
2636
|
};
|
|
2108
2637
|
|
|
2109
2638
|
|
|
2639
|
+
window.resumePrWork = async function resumePrWork(branch) {
|
|
2640
|
+
const name = branch.replace(/^[^/]+\//, '').replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 30) || 'pr-camp';
|
|
2641
|
+
toast(`"${name}" 캠프를 만들고 있습니다...`, 'info');
|
|
2642
|
+
try {
|
|
2643
|
+
await api('POST', '/api/playgrounds', { name, branch });
|
|
2644
|
+
await loadPortal();
|
|
2645
|
+
renderAll();
|
|
2646
|
+
enterWorkspace(name);
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
if (err.message?.includes('이미 있습니다')) {
|
|
2649
|
+
enterWorkspace(name);
|
|
2650
|
+
} else {
|
|
2651
|
+
toast(`캠프 생성 실패: ${err.message}`, 'error');
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2110
2656
|
window.autoFix = async function autoFix(name) {
|
|
2111
2657
|
toast('문제를 분석하고 있습니다...', 'info');
|
|
2112
2658
|
try {
|