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/README.md +25 -13
- package/dashboard/app.js +941 -38
- package/dashboard/index.html +151 -38
- package/dashboard/style.css +374 -7
- package/dist/bin/sanjang.js +152 -7
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +29 -5
- package/dist/lib/engine/change-report.d.ts +27 -0
- package/dist/lib/engine/change-report.js +233 -0
- package/dist/lib/engine/conflict.d.ts +13 -0
- package/dist/lib/engine/conflict.js +41 -0
- package/dist/lib/engine/diagnostics.js +2 -6
- package/dist/lib/engine/main-server.d.ts +19 -0
- package/dist/lib/engine/main-server.js +181 -0
- package/dist/lib/engine/naming.js +11 -2
- package/dist/lib/engine/ports.d.ts +2 -2
- package/dist/lib/engine/ports.js +33 -23
- package/dist/lib/engine/pr.js +1 -1
- 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 -39
- package/dist/lib/engine/self-heal.js +16 -5
- package/dist/lib/engine/smart-init.js +7 -6
- package/dist/lib/engine/state.js +14 -5
- package/dist/lib/engine/suggest.js +1 -4
- package/dist/lib/engine/warp.d.ts +1 -1
- package/dist/lib/engine/warp.js +1 -1
- 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 +701 -94
- package/dist/lib/types.d.ts +25 -0
- package/package.json +2 -2
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
|
|
267
|
-
|
|
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 (
|
|
1088
|
-
fileList.innerHTML =
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
`<
|
|
1215
|
-
<
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
|
|
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="${
|
|
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
|
|
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() >
|
|
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
|
|
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 = `
|