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 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 (result.conflictFiles?.length) {
1164
- fileList.innerHTML = `<div style="font-size:12px;background:var(--bg-card);padding:8px;border-radius:4px">
1165
- 충돌 파일: ${result.conflictFiles.map(f => `<code>${escHtml(f)}</code>`).join(', ')}
1166
- </div>`;
1167
- } else {
1168
- fileList.innerHTML = '';
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 port = new URL(previewUrl).port || '80';
1426
- const proxyUrl = `/preview/${port}/`;
1427
- previewEl.innerHTML = `
1428
- <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>
1429
- <div class="ws-preview-fallback" style="display:none">
1430
- <a href="${escHtml(previewUrl)}" target="_blank" class="btn btn-primary">
1431
- 탭에서 열기 → ${escHtml(previewUrl)}
1432
- </a>
1433
- </div>`;
1434
- const iframe = previewEl.querySelector('iframe');
1435
- iframe.addEventListener('error', () => {
1436
- iframe.style.display = 'none';
1437
- previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
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="${item.camp ? `enterWorkspace('${escHtml(item.camp)}')` : `window.open('${escHtml(item.prUrl)}','_blank')`}">
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 {