sanjang 0.3.4 → 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 CHANGED
@@ -12,6 +12,100 @@ const playgrounds = new Map();
12
12
  /** @type {Map<string, Array<{text: string, source: string}>>} logs keyed by playground name */
13
13
  const logs = new Map();
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Route inference — map file paths to preview routes
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Infer a preview route from a file path.
21
+ * Only works for file-based routing patterns (pages/, app/, views/).
22
+ * Returns null if no route can be inferred.
23
+ */
24
+ function inferRouteFromPath(filePath) {
25
+ const lower = filePath.toLowerCase();
26
+
27
+ // Match pages/xxx.tsx, app/xxx/page.tsx, views/xxx.vue etc.
28
+ const patterns = [
29
+ // Next.js app router: app/dashboard/page.tsx → /dashboard
30
+ { regex: /(?:^|[/\\])app[/\\](.+?)[/\\]page\.[^.]+$/, transform: m => '/' + m },
31
+ // Next.js app router: app/page.tsx → /
32
+ { regex: /(?:^|[/\\])app[/\\]page\.[^.]+$/, transform: () => '/' },
33
+ // Next.js/Nuxt pages router: pages/login.tsx → /login
34
+ { regex: /(?:^|[/\\])pages[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
35
+ // views/: views/Login.vue → /login
36
+ { regex: /(?:^|[/\\])views[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
37
+ ];
38
+
39
+ for (const { regex, transform } of patterns) {
40
+ const match = regex.exec(lower);
41
+ if (match) {
42
+ let route = transform(match[1] || '');
43
+ // Clean up: remove index, trailing slash dupes
44
+ route = route.replace(/\/index$/, '/').replace(/\/+/g, '/');
45
+ // Remove dynamic route brackets for navigation: [id] → placeholder
46
+ route = route.replace(/\[([^\]]+)\]/g, '1');
47
+ return route || '/';
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Navigate the preview iframe to a given route.
55
+ */
56
+ function navigatePreview(route) {
57
+ const iframe = document.querySelector('#ws-preview iframe');
58
+ if (!iframe) return;
59
+ try {
60
+ const base = new URL(iframe.src);
61
+ base.pathname = route;
62
+ iframe.contentWindow.location.href = base.toString();
63
+ } catch {
64
+ // cross-origin — reload with new path
65
+ const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
66
+ iframe.src = src;
67
+ }
68
+ updateUrlBar(route);
69
+ }
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
+
15
109
  /** @type {Map<string, Array>} diagnostics keyed by playground name */
16
110
  const diagnostics = new Map();
17
111
 
@@ -22,6 +116,11 @@ let currentWorkspace = null;
22
116
  /** @type {number|null} polling interval for workspace changes */
23
117
  let wsPollingInterval = null;
24
118
 
119
+ /** @type {object|null} 마지막 리포트 캐시 — 세이브 후 축소 표시용 */
120
+ let lastReport = null;
121
+
122
+ let compareMode = false;
123
+
25
124
  const SHERPA_QUOTES = [
26
125
  "요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
27
126
  "'간단한 건데~' 그 말이 제일 무섭댜",
@@ -150,6 +249,10 @@ function handleWsMessage(msg) {
150
249
  if (pg) {
151
250
  playgrounds.set(name, { ...pg, ...data });
152
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
+ }
153
256
  }
154
257
  break;
155
258
  }
@@ -218,6 +321,7 @@ function handleWsMessage(msg) {
218
321
  case 'playground-saved': {
219
322
  if (!name) break;
220
323
  toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
324
+ if (currentWorkspace === name) transitionReportToSaved();
221
325
  break;
222
326
  }
223
327
 
@@ -225,6 +329,7 @@ function handleWsMessage(msg) {
225
329
  if (!name) break;
226
330
  toast('💾 오토세이브 완료', 'success');
227
331
  if (currentWorkspace === name) {
332
+ transitionReportToSaved();
228
333
  api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
229
334
  }
230
335
  break;
@@ -237,6 +342,48 @@ function handleWsMessage(msg) {
237
342
  break;
238
343
  }
239
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
+
240
387
  case 'file-changes': {
241
388
  if (!name || !data) break;
242
389
  if (currentWorkspace !== name) break;
@@ -257,14 +404,14 @@ function handleWsMessage(msg) {
257
404
  if (summaryText2) summaryText2.textContent = `${data.count}개 파일 변경됨`;
258
405
  changesEl2.innerHTML = data.files.map(f => {
259
406
  const isNew = !prevPaths.has(f.path);
260
- 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)}')">
261
408
  <span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
262
409
  <span>${escHtml(f.path)}</span>
263
410
  </div>`;
264
411
  }).join('');
265
412
  renderBlocks(data.files);
266
- // Debounced AI summary fetch
267
- debounceSummaryFetch(name);
413
+ // Debounced AI report fetch
414
+ debounceReportFetch(name);
268
415
  }
269
416
 
270
417
  updateChangeSummary(data.count, data.ts);
@@ -272,6 +419,19 @@ function handleWsMessage(msg) {
272
419
  debouncePreviewRefresh();
273
420
  break;
274
421
  }
422
+
423
+ case 'compare-ready': {
424
+ if (!compareMode) break;
425
+ const mainPreview = document.getElementById('ws-preview-main');
426
+ if (mainPreview && data?.port) {
427
+ const proxyUrl = `/preview/${data.port}/`;
428
+ mainPreview.innerHTML = `
429
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
430
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
431
+ toast('원본 프리뷰 준비 완료!', 'success');
432
+ }
433
+ break;
434
+ }
275
435
  }
276
436
  }
277
437
 
@@ -739,7 +899,19 @@ function branchItemHtml(b) {
739
899
  + `</div>`;
740
900
  }
741
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
+
742
913
  window.openNewModal = async function openNewModal() {
914
+ document.getElementById('modal-quickstart-input').value = '';
743
915
  document.getElementById('new-pg-name').value = '';
744
916
  document.getElementById('new-pg-name-error').textContent = '';
745
917
  const input = document.getElementById('new-pg-branch');
@@ -749,6 +921,8 @@ window.openNewModal = async function openNewModal() {
749
921
  dropdown.innerHTML = '';
750
922
  dropdown.classList.remove('open');
751
923
  countEl.textContent = '불러오는 중...';
924
+
925
+ switchNewCampTab('quick');
752
926
  document.getElementById('new-pg-modal').classList.add('open');
753
927
 
754
928
  try {
@@ -785,6 +959,34 @@ window.openNewModal = async function openNewModal() {
785
959
  }, 0);
786
960
  };
787
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
+
788
990
  window.closeNewModal = function closeNewModal() {
789
991
  document.getElementById('new-pg-modal').classList.remove('open');
790
992
  };
@@ -937,6 +1139,60 @@ window.closeChangesModal = function() {
937
1139
  changesModalName = null;
938
1140
  };
939
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
+
940
1196
  // 행위 단위 되돌리기
941
1197
  window.revertAction = async function revertAction(actionIndex) {
942
1198
  if (!changesModalName) return;
@@ -1076,6 +1332,7 @@ window.shipPg = async function shipPg() {
1076
1332
  // ---------------------------------------------------------------------------
1077
1333
 
1078
1334
  let conflictCampName = null;
1335
+ let conflictDetails = [];
1079
1336
 
1080
1337
  window.syncPg = async function syncPg(name) {
1081
1338
  if (!confirm('팀의 최신 변경사항을 가져올까요?')) return;
@@ -1083,13 +1340,46 @@ window.syncPg = async function syncPg(name) {
1083
1340
  const result = await api('POST', `/api/playgrounds/${name}/sync`);
1084
1341
  if (result.conflict) {
1085
1342
  conflictCampName = name;
1343
+ conflictDetails = result.conflictDetails || [];
1086
1344
  const fileList = document.getElementById('conflict-files');
1087
- if (result.conflictFiles?.length) {
1088
- fileList.innerHTML = `<div style="font-size:12px;background:var(--bg-card);padding:8px;border-radius:4px">
1089
- 충돌 파일: ${result.conflictFiles.map(f => `<code>${escHtml(f)}</code>`).join(', ')}
1090
- </div>`;
1091
- } else {
1092
- 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('');
1093
1383
  }
1094
1384
  document.getElementById('conflict-modal').classList.add('open');
1095
1385
  } else {
@@ -1100,6 +1390,30 @@ window.syncPg = async function syncPg(name) {
1100
1390
  }
1101
1391
  };
1102
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
+
1103
1417
  window.resolveConflict = async function resolveConflict(strategy) {
1104
1418
  if (!conflictCampName) return;
1105
1419
  document.getElementById('conflict-modal').classList.remove('open');
@@ -1143,9 +1457,12 @@ function enterWorkspace(name) {
1143
1457
  const ws = document.getElementById('workspace');
1144
1458
  ws.classList.remove('hidden');
1145
1459
 
1146
- // Call enter API
1147
1460
  api('POST', `/api/playgrounds/${name}/enter`).then(data => {
1148
1461
  renderWorkspace(data);
1462
+ // Auto-start stopped camps so the preview loads immediately
1463
+ if (data.camp.status === 'stopped') {
1464
+ startPg(name);
1465
+ }
1149
1466
  }).catch(err => {
1150
1467
  toast(`캠프 진입 실패: ${err.message}`, 'error');
1151
1468
  exitWorkspace();
@@ -1153,6 +1470,16 @@ function enterWorkspace(name) {
1153
1470
  }
1154
1471
 
1155
1472
  function exitWorkspace() {
1473
+ compareMode = false;
1474
+ const compareBtn = document.getElementById('ws-compare-btn');
1475
+ if (compareBtn) compareBtn.classList.remove('btn-active');
1476
+ const mainPreview = document.getElementById('ws-preview-main');
1477
+ if (mainPreview) mainPreview.classList.add('hidden');
1478
+ const container = document.getElementById('ws-preview-container');
1479
+ if (container) container.classList.remove('ws-split-view');
1480
+ const exitToolbar = document.getElementById('ws-preview-toolbar');
1481
+ if (exitToolbar) exitToolbar.style.display = 'none';
1482
+ lastReport = null;
1156
1483
  currentWorkspace = null;
1157
1484
  if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
1158
1485
  document.getElementById('workspace').classList.add('hidden');
@@ -1164,6 +1491,178 @@ function exitWorkspace() {
1164
1491
  }
1165
1492
  window.exitWorkspace = exitWorkspace;
1166
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
+
1559
+ async function fetchAndRenderReport(campName, withAi = false) {
1560
+ const section = document.getElementById('ws-report-section');
1561
+ if (!section) return;
1562
+
1563
+ try {
1564
+ const report = await api('GET', `/api/playgrounds/${campName}/change-report${withAi ? '?ai=true' : ''}`);
1565
+
1566
+ if (report.totalCount === 0) {
1567
+ if (lastReport && lastReport.summary) {
1568
+ section.style.display = '';
1569
+ section.classList.add('ws-report-saved');
1570
+ document.getElementById('ws-report-summary').innerHTML =
1571
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1572
+ document.getElementById('ws-report-warnings').innerHTML = '';
1573
+ document.getElementById('ws-report-categories').innerHTML = '';
1574
+ } else {
1575
+ section.style.display = 'none';
1576
+ }
1577
+ return;
1578
+ }
1579
+
1580
+ lastReport = report;
1581
+ section.style.display = '';
1582
+ section.classList.remove('ws-report-saved');
1583
+
1584
+ const summaryEl = document.getElementById('ws-report-summary');
1585
+ const changeSummaryText = document.getElementById('ws-changes-summary-text');
1586
+ if (report.humanDescription) {
1587
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.humanDescription)}</div>`;
1588
+ } else if (report.summary) {
1589
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.summary)}</div>`;
1590
+ } else {
1591
+ summaryEl.innerHTML = `<div class="ws-report-desc">${report.totalCount}개 파일 변경됨</div>`;
1592
+ }
1593
+
1594
+ if (changeSummaryText && report.summary) {
1595
+ changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
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
+ }
1602
+
1603
+ const warningsEl = document.getElementById('ws-report-warnings');
1604
+ if (report.warnings.length > 0) {
1605
+ warningsEl.innerHTML = report.warnings.map(w =>
1606
+ `<div class="ws-report-warning">
1607
+ <span class="ws-report-warning-icon">⚠️</span>
1608
+ <span>${escHtml(w.message)}</span>
1609
+ </div>`
1610
+ ).join('');
1611
+ } else {
1612
+ warningsEl.innerHTML = '';
1613
+ }
1614
+
1615
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1616
+ const categoriesEl = document.getElementById('ws-report-categories');
1617
+ const details = report.categoryDetails || {};
1618
+ categoriesEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1619
+ const items = details[cat];
1620
+ const hasDetails = items && items.length > 0;
1621
+ return `<div class="ws-report-cat-group">
1622
+ <div class="ws-report-cat-header">
1623
+ <span class="ws-report-cat-label">${categoryNames[cat] || cat}</span>
1624
+ <span class="ws-report-cat-count">${files.length}</span>
1625
+ </div>
1626
+ ${hasDetails
1627
+ ? `<ul class="ws-report-cat-items">${items.map((item, idx) => {
1628
+ const file = files[idx];
1629
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1630
+ return route
1631
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
1632
+ : `<li>${escHtml(item)}</li>`;
1633
+ }).join('')}</ul>`
1634
+ : `<ul class="ws-report-cat-items">${files.map(f => {
1635
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1636
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1637
+ return route
1638
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
1639
+ : `<li>${label}</li>`;
1640
+ }).join('')}</ul>`
1641
+ }
1642
+ </div>`;
1643
+ }).join('');
1644
+
1645
+ } catch {
1646
+ section.style.display = 'none';
1647
+ }
1648
+ }
1649
+
1650
+ function transitionReportToSaved() {
1651
+ const section = document.getElementById('ws-report-section');
1652
+ if (!section || !lastReport) return;
1653
+
1654
+ if (lastReport.summary) {
1655
+ section.style.display = '';
1656
+ section.classList.add('ws-report-saved');
1657
+ document.getElementById('ws-report-summary').innerHTML =
1658
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1659
+ document.getElementById('ws-report-warnings').innerHTML = '';
1660
+ document.getElementById('ws-report-categories').innerHTML = '';
1661
+ } else {
1662
+ section.style.display = 'none';
1663
+ }
1664
+ }
1665
+
1167
1666
  function renderWorkspace(data) {
1168
1667
  const { camp, changes, warpInstalled, previewUrl, autosave } = data;
1169
1668
 
@@ -1187,6 +1686,7 @@ function renderWorkspace(data) {
1187
1686
  saveBtn.style.display = 'none';
1188
1687
  changesEl.innerHTML = '';
1189
1688
  renderBlocks([]);
1689
+ fetchAndRenderReport(camp.name);
1190
1690
  } else {
1191
1691
  unsavedSection.classList.remove('ws-no-changes');
1192
1692
  summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
@@ -1194,16 +1694,15 @@ function renderWorkspace(data) {
1194
1694
  saveBtn.textContent = '💾 세이브하기';
1195
1695
  saveBtn.disabled = false;
1196
1696
  changesEl.innerHTML = changes.files.map(f =>
1197
- `<div class="ws-file-item">
1697
+ `<div class="ws-file-item ws-file-clickable" onclick="showDiff('${escHtml(camp.name)}','${escHtml(f.path)}')">
1198
1698
  <span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
1199
1699
  <span>${escHtml(f.path)}</span>
1200
1700
  </div>`
1201
1701
  ).join('');
1202
1702
  renderBlocks(changes.files);
1203
- // Fetch AI summary
1204
- api('GET', `/api/playgrounds/${camp.name}/changes-summary`).then(data => {
1205
- if (data.summary) summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${data.summary}`;
1206
- }).catch(() => {});
1703
+ // 먼저 fallback으로 빠르게 렌더, 이어서 AI 업그레이드
1704
+ fetchAndRenderReport(camp.name);
1705
+ fetchAndRenderReport(camp.name, true);
1207
1706
  }
1208
1707
 
1209
1708
  // Actions — show commits as work history
@@ -1211,12 +1710,22 @@ function renderWorkspace(data) {
1211
1710
  const commitList = data.commits || [];
1212
1711
  if (commitList.length > 0) {
1213
1712
  actionsEl.innerHTML = commitList.map(c =>
1214
- `<div class="ws-commit-item">
1215
- <span class="ws-commit-msg">${escHtml(c.message)}</span>
1216
- <span class="ws-commit-date">${escHtml(c.date)}</span>
1217
- <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1218
- </div>`
1713
+ `<details class="ws-commit-item" data-hash="${escHtml(c.hash)}">
1714
+ <summary class="ws-commit-summary">
1715
+ <span class="ws-commit-arrow">▶</span>
1716
+ <span class="ws-commit-msg">${escHtml(c.message)}</span>
1717
+ <span class="ws-commit-date">${escHtml(c.date)}</span>
1718
+ <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="event.stopPropagation();event.preventDefault();revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1719
+ </summary>
1720
+ <div class="ws-commit-report"></div>
1721
+ </details>`
1219
1722
  ).join('');
1723
+ // 펼칠 때 자동으로 리포트 로드
1724
+ actionsEl.querySelectorAll('.ws-commit-item').forEach(el => {
1725
+ el.addEventListener('toggle', function() {
1726
+ if (this.open) loadCommitReport(this, this.dataset.hash);
1727
+ });
1728
+ });
1220
1729
  } else if (changes.count > 0) {
1221
1730
  actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
1222
1731
  } else {
@@ -1225,6 +1734,7 @@ function renderWorkspace(data) {
1225
1734
 
1226
1735
  // Preview — use proxy URL (same origin, no X-Frame-Options issues)
1227
1736
  const previewEl = document.getElementById('ws-preview');
1737
+ const previewToolbar = document.getElementById('ws-preview-toolbar');
1228
1738
  if (previewUrl) {
1229
1739
  const port = new URL(previewUrl).port || '80';
1230
1740
  const proxyUrl = `/preview/${port}/`;
@@ -1240,7 +1750,11 @@ function renderWorkspace(data) {
1240
1750
  iframe.style.display = 'none';
1241
1751
  previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
1242
1752
  });
1753
+ if (previewToolbar) previewToolbar.style.display = '';
1754
+ updateUrlBar('/');
1755
+ if (currentViewport !== 'desktop') setViewport(currentViewport);
1243
1756
  } else {
1757
+ if (previewToolbar) previewToolbar.style.display = 'none';
1244
1758
  previewEl.innerHTML = `
1245
1759
  <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;user-select:none;">
1246
1760
  <div style="width:4px;height:4px;image-rendering:pixelated;color:transparent;box-shadow:
@@ -1426,15 +1940,57 @@ function updateQuestProgress(hasChanges, hasSaves) {
1426
1940
  }
1427
1941
  }
1428
1942
 
1429
- let summaryFetchTimer = null;
1430
- function debounceSummaryFetch(campName) {
1431
- if (summaryFetchTimer) clearTimeout(summaryFetchTimer);
1432
- summaryFetchTimer = setTimeout(() => {
1433
- api('GET', `/api/playgrounds/${campName}/changes-summary`).then(data => {
1434
- const el = document.getElementById('ws-changes-summary-text');
1435
- if (data.summary && el) el.textContent = data.summary;
1436
- }).catch(() => {});
1437
- }, 3000);
1943
+ async function loadCommitReport(el, hash) {
1944
+ const reportEl = el.querySelector('.ws-commit-report');
1945
+ if (!reportEl || reportEl.dataset.loaded) return;
1946
+
1947
+ reportEl.innerHTML = '<div class="ws-commit-report-loading">불러오는 중...</div>';
1948
+
1949
+ try {
1950
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/commit-report/${hash}?ai=true`);
1951
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1952
+ const details = report.categoryDetails || {};
1953
+
1954
+ if (report.totalCount === 0) {
1955
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">변경 내용 없음</div>';
1956
+ return;
1957
+ }
1958
+
1959
+ reportEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1960
+ const items = details[cat];
1961
+ const hasDetails = items && items.length > 0;
1962
+ return `<div class="ws-commit-cat">
1963
+ <span class="ws-commit-cat-label">${categoryNames[cat] || cat}</span>
1964
+ ${hasDetails
1965
+ ? items.map((item, idx) => {
1966
+ const file = files[idx];
1967
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1968
+ return route
1969
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></div>`
1970
+ : `<div class="ws-commit-cat-item">${escHtml(item)}</div>`;
1971
+ }).join('')
1972
+ : files.map(f => {
1973
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1974
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1975
+ return route
1976
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></div>`
1977
+ : `<div class="ws-commit-cat-item">${label}</div>`;
1978
+ }).join('')
1979
+ }
1980
+ </div>`;
1981
+ }).join('');
1982
+ reportEl.dataset.loaded = 'true';
1983
+ } catch {
1984
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">불러오기 실패</div>';
1985
+ }
1986
+ }
1987
+
1988
+ let reportFetchTimer = null;
1989
+ function debounceReportFetch(campName) {
1990
+ if (reportFetchTimer) clearTimeout(reportFetchTimer);
1991
+ reportFetchTimer = setTimeout(() => {
1992
+ fetchAndRenderReport(campName, true);
1993
+ }, 5000);
1438
1994
  }
1439
1995
 
1440
1996
  let previewRefreshTimer = null;
@@ -1466,7 +2022,7 @@ function startWorkspacePolling(name) {
1466
2022
  changesEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">변경 없음</span>';
1467
2023
  } else {
1468
2024
  changesEl.innerHTML = data.files.map(f =>
1469
- `<div class="ws-file-item">
2025
+ `<div class="ws-file-item ws-file-clickable" onclick="showDiff('${escHtml(currentWorkspace)}','${escHtml(f.path)}')">
1470
2026
  <span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
1471
2027
  <span>${escHtml(f.path)}</span>
1472
2028
  </div>`
@@ -1515,20 +2071,28 @@ function renderBrowserErrors() {
1515
2071
  const panel = document.getElementById('ws-browser-errors');
1516
2072
  if (!panel) return;
1517
2073
  const badge = document.getElementById('ws-browser-error-badge');
2074
+ const fixBtn = document.getElementById('ws-fix-btn');
2075
+ // Show fix button if there are browser errors OR server error logs
2076
+ const serverHasErrors = currentWorkspace && (logs.get(currentWorkspace) ?? [])
2077
+ .some(l => l.source !== 'frontend' && l.source !== 'task' && /error|ERR|ENOENT|ECONNREFUSED|TypeError|SyntaxError|Cannot find/i.test(l.text));
1518
2078
  if (browserErrors.length === 0) {
1519
2079
  panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
1520
2080
  if (badge) badge.style.display = 'none';
2081
+ if (fixBtn) fixBtn.style.display = serverHasErrors ? '' : 'none';
1521
2082
  return;
1522
2083
  }
1523
2084
  if (badge) {
1524
2085
  badge.style.display = '';
1525
2086
  badge.textContent = browserErrors.length;
1526
2087
  }
2088
+ if (fixBtn) fixBtn.style.display = '';
1527
2089
  panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
1528
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>` : '';
1529
2092
  return `<div class="ws-browser-error-item">
1530
2093
  <span class="ws-browser-error-level">${escHtml(e.level)}</span>
1531
2094
  <span class="ws-browser-error-msg">${escHtml(e.message)}</span>${loc}
2095
+ ${stackHtml}
1532
2096
  </div>`;
1533
2097
  }).join('');
1534
2098
  }
@@ -1556,13 +2120,230 @@ window.revertCommit = async function revertCommit(hash) {
1556
2120
  }
1557
2121
  };
1558
2122
 
2123
+ /**
2124
+ * Build a structured prompt from browser errors + server logs
2125
+ * that Claude Code can use to diagnose and fix the issue.
2126
+ */
2127
+ window.copyFixPrompt = async function copyFixPrompt() {
2128
+ const name = currentWorkspace;
2129
+ if (!name) return;
2130
+
2131
+ const sections = [];
2132
+
2133
+ // 1. Browser errors
2134
+ if (browserErrors.length > 0) {
2135
+ const errs = browserErrors.slice(-10).map(e => {
2136
+ let line = `[${e.level}] ${e.message}`;
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')}`;
2139
+ return line;
2140
+ }).join('\n\n');
2141
+ sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
2142
+ }
2143
+
2144
+ // 2. Server logs (last 20 lines, stderr/error only)
2145
+ const serverLines = (logs.get(name) ?? [])
2146
+ .filter(l => l.source !== 'frontend' && l.source !== 'task')
2147
+ .slice(-20)
2148
+ .map(l => l.text.trim())
2149
+ .filter(Boolean);
2150
+ if (serverLines.length > 0) {
2151
+ sections.push(`## 서버 로그 (최근)\n\n${serverLines.join('\n')}`);
2152
+ }
2153
+
2154
+ // 3. Current changes context
2155
+ try {
2156
+ const changes = await api('GET', `/api/playgrounds/${name}/changes`);
2157
+ if (changes.files?.length > 0) {
2158
+ const fileList = changes.files.map(f => `- ${f.status} ${f.path}`).join('\n');
2159
+ sections.push(`## 현재 수정된 파일\n\n${fileList}`);
2160
+ }
2161
+ } catch { /* ignore */ }
2162
+
2163
+ if (sections.length === 0) {
2164
+ toast('복사할 에러가 없습니다', 'info');
2165
+ return;
2166
+ }
2167
+
2168
+ const prompt = `아래 에러를 분석하고 수정해줘.
2169
+
2170
+ 에러를 읽고 근본 원인을 먼저 파악한 다음, 최소한의 변경으로 고쳐줘.
2171
+ 추측하지 말고 에러 메시지와 스택 트레이스를 근거로 진단해.
2172
+ 수정 후 관련 파일만 변경하고, 변경 이유를 간단히 설명해줘.
2173
+
2174
+ ${sections.join('\n\n---\n\n')}`;
2175
+
2176
+ try {
2177
+ await navigator.clipboard.writeText(prompt);
2178
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
2179
+ } catch {
2180
+ // Fallback for non-HTTPS
2181
+ const ta = document.createElement('textarea');
2182
+ ta.value = prompt;
2183
+ ta.style.cssText = 'position:fixed;left:-9999px';
2184
+ document.body.appendChild(ta);
2185
+ ta.select();
2186
+ document.execCommand('copy');
2187
+ ta.remove();
2188
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
2189
+ }
2190
+ };
2191
+
1559
2192
  function clearBrowserErrors() {
1560
2193
  browserErrors.length = 0;
1561
2194
  renderBrowserErrors();
1562
2195
  }
1563
2196
 
1564
- window.wsShip = function() {
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() {
1565
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
+
2327
+ window.wsShip = async function() {
2328
+ if (!currentWorkspace) return;
2329
+ // Fetch report for ship confirmation
2330
+ try {
2331
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/change-report?ai=true`);
2332
+ const reportPreview = document.getElementById('ship-report-preview');
2333
+ if (reportPreview && report.totalCount > 0) {
2334
+ let html = '';
2335
+ if (report.humanDescription) {
2336
+ html += `<div class="ship-report-desc">${escHtml(report.humanDescription)}</div>`;
2337
+ }
2338
+ if (report.warnings.length > 0) {
2339
+ html += report.warnings.map(w =>
2340
+ `<div class="ws-report-warning"><span>⚠️</span> ${escHtml(w.message)}</div>`
2341
+ ).join('');
2342
+ }
2343
+ reportPreview.innerHTML = html;
2344
+ reportPreview.style.display = html ? '' : 'none';
2345
+ }
2346
+ } catch { /* non-blocking */ }
1566
2347
  openShipModal(currentWorkspace);
1567
2348
  };
1568
2349
 
@@ -1641,6 +2422,48 @@ window.togglePanel = function() {
1641
2422
  document.getElementById('ws-panel')?.classList.toggle('open');
1642
2423
  };
1643
2424
 
2425
+ window.toggleCompare = async function() {
2426
+ const container = document.getElementById('ws-preview-container');
2427
+ const mainPreview = document.getElementById('ws-preview-main');
2428
+ const compareBtn = document.getElementById('ws-compare-btn');
2429
+ if (!container || !mainPreview) return;
2430
+
2431
+ compareMode = !compareMode;
2432
+
2433
+ if (compareMode) {
2434
+ compareBtn.classList.add('btn-active');
2435
+ mainPreview.classList.remove('hidden');
2436
+ container.classList.add('ws-split-view');
2437
+
2438
+ toast('원본 서버를 준비하고 있어요...', 'info');
2439
+ try {
2440
+ const state = await api('POST', '/api/compare/start');
2441
+ if (state.status === 'running' && state.port) {
2442
+ const proxyUrl = `/preview/${state.port}/`;
2443
+ mainPreview.innerHTML = `
2444
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2445
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
2446
+ } else if (state.status === 'starting') {
2447
+ mainPreview.innerHTML = `
2448
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2449
+ <div class="ws-preview-loading">준비 중...</div>`;
2450
+ } else {
2451
+ mainPreview.innerHTML = `
2452
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2453
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2454
+ }
2455
+ } catch {
2456
+ mainPreview.innerHTML = `
2457
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2458
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2459
+ }
2460
+ } else {
2461
+ compareBtn.classList.remove('btn-active');
2462
+ mainPreview.classList.add('hidden');
2463
+ container.classList.remove('ws-split-view');
2464
+ }
2465
+ };
2466
+
1644
2467
  // ---------------------------------------------------------------------------
1645
2468
  // Init
1646
2469
  // ---------------------------------------------------------------------------
@@ -1676,8 +2499,11 @@ async function loadPortal() {
1676
2499
  : 'pending';
1677
2500
  const timeAgo = new Date(item.updatedAt).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
1678
2501
 
2502
+ const onclick = item.camp
2503
+ ? `enterWorkspace('${escHtml(item.camp)}')`
2504
+ : `resumePrWork('${escHtml(item.branch)}')`;
1679
2505
  return `
1680
- <div class="portal-work-item" onclick="${item.camp ? `enterWorkspace('${escHtml(item.camp)}')` : `window.open('${escHtml(item.prUrl)}','_blank')`}">
2506
+ <div class="portal-work-item" onclick="${onclick}">
1681
2507
  <div class="portal-work-left">
1682
2508
  <span class="portal-work-icon">🟡</span>
1683
2509
  <div>
@@ -1704,8 +2530,66 @@ async function loadPortal() {
1704
2530
  } catch (err) {
1705
2531
  workList.innerHTML = '<div class="portal-empty">작업 목록을 불러올 수 없습니다</div>';
1706
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 */ }
1707
2547
  }
1708
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
+
1709
2593
  window.quickStart = async function quickStart() {
1710
2594
  const input = document.getElementById('quickstart-input');
1711
2595
  const description = input.value.trim();
@@ -1735,6 +2619,23 @@ window.quickStart = async function quickStart() {
1735
2619
  };
1736
2620
 
1737
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
+
1738
2639
  window.autoFix = async function autoFix(name) {
1739
2640
  toast('문제를 분석하고 있습니다...', 'info');
1740
2641
  try {
@@ -2467,9 +3368,11 @@ async function loadActivityTrail() {
2467
3368
  }
2468
3369
  });
2469
3370
 
2470
- // Tents on rest days (0 commits, not first/last)
3371
+ // Tents on rest days limit density when most days are rest days
3372
+ const activeDays = days.filter(d => d.commits > 0).length;
3373
+ const tentChance = activeDays < 5 ? 0.85 : 0.5; // fewer active days → fewer tents
2471
3374
  heights.forEach((h, i) => {
2472
- if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
3375
+ if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > tentChance) {
2473
3376
  const x = 20 + i * dayW + dayW / 2 - 4;
2474
3377
  decorations += `
2475
3378
  <rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
@@ -2485,9 +3388,9 @@ async function loadActivityTrail() {
2485
3388
  if (datePrs && datePrs.length > 0) {
2486
3389
  const x = 20 + i * dayW + dayW / 2 - 4;
2487
3390
  const h = heights[i];
2488
- const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
3391
+ const tooltipText = datePrs.map(p => `#${p.number} ${escHtml(p.title)}`).join('\n');
2489
3392
  prMarkers += `
2490
- <g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '&quot;')}">
3393
+ <g class="pr-marker" data-tooltip="${escHtml(tooltipText)}">
2491
3394
  <rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2492
3395
  <rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2493
3396
  <rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
@@ -2520,8 +3423,8 @@ async function loadActivityTrail() {
2520
3423
  <polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
2521
3424
  }
2522
3425
 
2523
- // Sherpa at today (last position)
2524
- const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
3426
+ // Sherpa at today (last position, clamped to SVG bounds)
3427
+ const lastX = Math.min(20 + (days.length - 1) * dayW + dayW / 2 - 4, svgW - 24);
2525
3428
  const lastH = heights[heights.length - 1];
2526
3429
  const sherpaY = lastH - 16;
2527
3430
  const sherpa = `