sanjang 0.3.4 → 0.3.5
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 +401 -27
- package/dashboard/index.html +16 -2
- package/dashboard/style.css +83 -5
- package/dist/bin/sanjang.js +8 -6
- package/dist/lib/config.js +3 -5
- package/dist/lib/engine/change-report.d.ts +27 -0
- package/dist/lib/engine/change-report.js +233 -0
- package/dist/lib/engine/diagnostics.js +2 -6
- package/dist/lib/engine/main-server.d.ts +15 -0
- package/dist/lib/engine/main-server.js +111 -0
- package/dist/lib/engine/naming.js +11 -2
- package/dist/lib/engine/pr.js +1 -1
- package/dist/lib/engine/process.js +4 -1
- package/dist/lib/engine/self-heal.js +16 -5
- package/dist/lib/engine/smart-init.js +7 -6
- package/dist/lib/engine/state.js +1 -1
- 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/server.js +241 -49
- package/dist/lib/types.d.ts +19 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
# 산장 (Sanjang)
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 비개발자가 AI로 코드를 고치고 PR을 보낼 수 있는 로컬 개발 환경
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
Git worktree 기반으로 동시에 여러 작업 환경(캠프)을 운영할 수 있습니다.
|
|
5
|
+
"로그인 버튼 색상 변경"이라고 입력하면, 격리된 작업 환경(캠프)이 만들어지고, AI가 코드를 고치고, 결과를 바로 프리뷰하고, 팀에 PR로 보낼 수 있습니다.
|
|
7
6
|
|
|
8
7
|
## 주요 기능
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
9
|
+
### 만들기
|
|
10
|
+
- **자연어 퀵스타트**: "대시보드 필터 추가"라고 입력하면 브랜치+캠프 자동 생성
|
|
11
|
+
- **캠프 격리**: 각 작업이 독립된 환경에서 실행 — 서로 영향 없음
|
|
12
|
+
- **프로젝트 자동 감지**: Next.js, Vite, SvelteKit, Angular, Turborepo 등 인식
|
|
13
|
+
|
|
14
|
+
### 확인하기
|
|
15
|
+
- **내장 프리뷰**: 대시보드 안에서 바로 결과 확인 (프록시 기반, iframe)
|
|
16
|
+
- **비교 모드**: 원본(main)과 내 변경을 나란히 비교
|
|
17
|
+
- **AI 변경 리포트**: "로그인 버튼이 보라색으로 바뀌었어요" — 비개발자도 이해할 수 있는 설명
|
|
18
|
+
- **화면 항목 → 프리뷰 이동**: 리포트에서 UI 항목 클릭하면 해당 화면으로 바로 이동
|
|
19
|
+
|
|
20
|
+
### 고치기
|
|
21
|
+
- **자가 치유**: 서버 에러 자동 감지 → 패턴 매칭 → 자동 수정 → 재시작
|
|
22
|
+
- **고쳐줘 버튼**: 브라우저 에러 + 서버 로그를 모아 Claude Code용 프롬프트로 클립보드 복사
|
|
23
|
+
- **세이브 & 되돌리기**: 작업을 세이브하고, 문제가 생기면 원클릭 되돌리기
|
|
24
|
+
- **오토세이브**: 5분간 변경 없으면 자동 세이브
|
|
25
|
+
|
|
26
|
+
### 보내기
|
|
27
|
+
- **팀에 보내기**: 세이브 → commit + push → PR 생성
|
|
28
|
+
- **AI PR 설명**: diff를 분석해서 리뷰어가 이해하기 쉬운 PR 본문 자동 생성
|
|
20
29
|
|
|
21
30
|
---
|
|
22
31
|
|
|
@@ -183,6 +192,9 @@ export default {
|
|
|
183
192
|
| **산장** | 이 도구 전체. 대시보드 서버 + 캠프 매니저 |
|
|
184
193
|
| **캠프** | 개별 작업 환경. git worktree + dev 서버 |
|
|
185
194
|
| **포털** | 대시보드 첫 화면. 이어하기 + 새로 시작 |
|
|
195
|
+
| **세이브** | 작업 저장 (git commit). 되돌리기 가능 |
|
|
196
|
+
| **변경 리포트** | AI가 생성하는 변경사항 요약. 카테고리별 설명 |
|
|
197
|
+
| **자가 치유** | 에러 패턴 감지 → 자동 수정 → 재시작 |
|
|
186
198
|
| **스냅샷** | 캠프의 현재 상태를 저장한 것 (git stash) |
|
|
187
199
|
| **캐시** | 의존성 프리빌드. 캠프 생성 속도 향상 |
|
|
188
200
|
|
package/dashboard/app.js
CHANGED
|
@@ -12,6 +12,62 @@ 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
|
+
toast(`${route} 로 이동`, 'info');
|
|
64
|
+
} catch {
|
|
65
|
+
// cross-origin — reload with new path
|
|
66
|
+
const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
|
|
67
|
+
iframe.src = src;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
15
71
|
/** @type {Map<string, Array>} diagnostics keyed by playground name */
|
|
16
72
|
const diagnostics = new Map();
|
|
17
73
|
|
|
@@ -22,6 +78,11 @@ let currentWorkspace = null;
|
|
|
22
78
|
/** @type {number|null} polling interval for workspace changes */
|
|
23
79
|
let wsPollingInterval = null;
|
|
24
80
|
|
|
81
|
+
/** @type {object|null} 마지막 리포트 캐시 — 세이브 후 축소 표시용 */
|
|
82
|
+
let lastReport = null;
|
|
83
|
+
|
|
84
|
+
let compareMode = false;
|
|
85
|
+
|
|
25
86
|
const SHERPA_QUOTES = [
|
|
26
87
|
"요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
|
|
27
88
|
"'간단한 건데~' 그 말이 제일 무섭댜",
|
|
@@ -218,6 +279,7 @@ function handleWsMessage(msg) {
|
|
|
218
279
|
case 'playground-saved': {
|
|
219
280
|
if (!name) break;
|
|
220
281
|
toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
|
|
282
|
+
if (currentWorkspace === name) transitionReportToSaved();
|
|
221
283
|
break;
|
|
222
284
|
}
|
|
223
285
|
|
|
@@ -225,6 +287,7 @@ function handleWsMessage(msg) {
|
|
|
225
287
|
if (!name) break;
|
|
226
288
|
toast('💾 오토세이브 완료', 'success');
|
|
227
289
|
if (currentWorkspace === name) {
|
|
290
|
+
transitionReportToSaved();
|
|
228
291
|
api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
|
|
229
292
|
}
|
|
230
293
|
break;
|
|
@@ -263,8 +326,8 @@ function handleWsMessage(msg) {
|
|
|
263
326
|
</div>`;
|
|
264
327
|
}).join('');
|
|
265
328
|
renderBlocks(data.files);
|
|
266
|
-
// Debounced AI
|
|
267
|
-
|
|
329
|
+
// Debounced AI report fetch
|
|
330
|
+
debounceReportFetch(name);
|
|
268
331
|
}
|
|
269
332
|
|
|
270
333
|
updateChangeSummary(data.count, data.ts);
|
|
@@ -272,6 +335,19 @@ function handleWsMessage(msg) {
|
|
|
272
335
|
debouncePreviewRefresh();
|
|
273
336
|
break;
|
|
274
337
|
}
|
|
338
|
+
|
|
339
|
+
case 'compare-ready': {
|
|
340
|
+
if (!compareMode) break;
|
|
341
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
342
|
+
if (mainPreview && data?.port) {
|
|
343
|
+
const proxyUrl = `/preview/${data.port}/`;
|
|
344
|
+
mainPreview.innerHTML = `
|
|
345
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
346
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
|
|
347
|
+
toast('원본 프리뷰 준비 완료!', 'success');
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
275
351
|
}
|
|
276
352
|
}
|
|
277
353
|
|
|
@@ -1153,6 +1229,14 @@ function enterWorkspace(name) {
|
|
|
1153
1229
|
}
|
|
1154
1230
|
|
|
1155
1231
|
function exitWorkspace() {
|
|
1232
|
+
compareMode = false;
|
|
1233
|
+
const compareBtn = document.getElementById('ws-compare-btn');
|
|
1234
|
+
if (compareBtn) compareBtn.classList.remove('btn-active');
|
|
1235
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
1236
|
+
if (mainPreview) mainPreview.classList.add('hidden');
|
|
1237
|
+
const container = document.getElementById('ws-preview-container');
|
|
1238
|
+
if (container) container.classList.remove('ws-split-view');
|
|
1239
|
+
lastReport = null;
|
|
1156
1240
|
currentWorkspace = null;
|
|
1157
1241
|
if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
|
|
1158
1242
|
document.getElementById('workspace').classList.add('hidden');
|
|
@@ -1164,6 +1248,108 @@ function exitWorkspace() {
|
|
|
1164
1248
|
}
|
|
1165
1249
|
window.exitWorkspace = exitWorkspace;
|
|
1166
1250
|
|
|
1251
|
+
async function fetchAndRenderReport(campName, withAi = false) {
|
|
1252
|
+
const section = document.getElementById('ws-report-section');
|
|
1253
|
+
if (!section) return;
|
|
1254
|
+
|
|
1255
|
+
try {
|
|
1256
|
+
const report = await api('GET', `/api/playgrounds/${campName}/change-report${withAi ? '?ai=true' : ''}`);
|
|
1257
|
+
|
|
1258
|
+
if (report.totalCount === 0) {
|
|
1259
|
+
if (lastReport && lastReport.summary) {
|
|
1260
|
+
section.style.display = '';
|
|
1261
|
+
section.classList.add('ws-report-saved');
|
|
1262
|
+
document.getElementById('ws-report-summary').innerHTML =
|
|
1263
|
+
`<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
|
|
1264
|
+
document.getElementById('ws-report-warnings').innerHTML = '';
|
|
1265
|
+
document.getElementById('ws-report-categories').innerHTML = '';
|
|
1266
|
+
} else {
|
|
1267
|
+
section.style.display = 'none';
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
lastReport = report;
|
|
1273
|
+
section.style.display = '';
|
|
1274
|
+
section.classList.remove('ws-report-saved');
|
|
1275
|
+
|
|
1276
|
+
const summaryEl = document.getElementById('ws-report-summary');
|
|
1277
|
+
const changeSummaryText = document.getElementById('ws-changes-summary-text');
|
|
1278
|
+
if (report.humanDescription) {
|
|
1279
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.humanDescription)}</div>`;
|
|
1280
|
+
} else if (report.summary) {
|
|
1281
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.summary)}</div>`;
|
|
1282
|
+
} else {
|
|
1283
|
+
summaryEl.innerHTML = `<div class="ws-report-desc">${report.totalCount}개 파일 변경됨</div>`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (changeSummaryText && report.summary) {
|
|
1287
|
+
changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const warningsEl = document.getElementById('ws-report-warnings');
|
|
1291
|
+
if (report.warnings.length > 0) {
|
|
1292
|
+
warningsEl.innerHTML = report.warnings.map(w =>
|
|
1293
|
+
`<div class="ws-report-warning">
|
|
1294
|
+
<span class="ws-report-warning-icon">⚠️</span>
|
|
1295
|
+
<span>${escHtml(w.message)}</span>
|
|
1296
|
+
</div>`
|
|
1297
|
+
).join('');
|
|
1298
|
+
} else {
|
|
1299
|
+
warningsEl.innerHTML = '';
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
|
|
1303
|
+
const categoriesEl = document.getElementById('ws-report-categories');
|
|
1304
|
+
const details = report.categoryDetails || {};
|
|
1305
|
+
categoriesEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
|
|
1306
|
+
const items = details[cat];
|
|
1307
|
+
const hasDetails = items && items.length > 0;
|
|
1308
|
+
return `<div class="ws-report-cat-group">
|
|
1309
|
+
<div class="ws-report-cat-header">
|
|
1310
|
+
<span class="ws-report-cat-label">${categoryNames[cat] || cat}</span>
|
|
1311
|
+
<span class="ws-report-cat-count">${files.length}</span>
|
|
1312
|
+
</div>
|
|
1313
|
+
${hasDetails
|
|
1314
|
+
? `<ul class="ws-report-cat-items">${items.map((item, idx) => {
|
|
1315
|
+
const file = files[idx];
|
|
1316
|
+
const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
|
|
1317
|
+
return route
|
|
1318
|
+
? `<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>`
|
|
1319
|
+
: `<li>${escHtml(item)}</li>`;
|
|
1320
|
+
}).join('')}</ul>`
|
|
1321
|
+
: `<ul class="ws-report-cat-items">${files.map(f => {
|
|
1322
|
+
const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
|
|
1323
|
+
const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
|
|
1324
|
+
return route
|
|
1325
|
+
? `<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>`
|
|
1326
|
+
: `<li>${label}</li>`;
|
|
1327
|
+
}).join('')}</ul>`
|
|
1328
|
+
}
|
|
1329
|
+
</div>`;
|
|
1330
|
+
}).join('');
|
|
1331
|
+
|
|
1332
|
+
} catch {
|
|
1333
|
+
section.style.display = 'none';
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function transitionReportToSaved() {
|
|
1338
|
+
const section = document.getElementById('ws-report-section');
|
|
1339
|
+
if (!section || !lastReport) return;
|
|
1340
|
+
|
|
1341
|
+
if (lastReport.summary) {
|
|
1342
|
+
section.style.display = '';
|
|
1343
|
+
section.classList.add('ws-report-saved');
|
|
1344
|
+
document.getElementById('ws-report-summary').innerHTML =
|
|
1345
|
+
`<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
|
|
1346
|
+
document.getElementById('ws-report-warnings').innerHTML = '';
|
|
1347
|
+
document.getElementById('ws-report-categories').innerHTML = '';
|
|
1348
|
+
} else {
|
|
1349
|
+
section.style.display = 'none';
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1167
1353
|
function renderWorkspace(data) {
|
|
1168
1354
|
const { camp, changes, warpInstalled, previewUrl, autosave } = data;
|
|
1169
1355
|
|
|
@@ -1187,6 +1373,7 @@ function renderWorkspace(data) {
|
|
|
1187
1373
|
saveBtn.style.display = 'none';
|
|
1188
1374
|
changesEl.innerHTML = '';
|
|
1189
1375
|
renderBlocks([]);
|
|
1376
|
+
fetchAndRenderReport(camp.name);
|
|
1190
1377
|
} else {
|
|
1191
1378
|
unsavedSection.classList.remove('ws-no-changes');
|
|
1192
1379
|
summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
|
|
@@ -1200,10 +1387,9 @@ function renderWorkspace(data) {
|
|
|
1200
1387
|
</div>`
|
|
1201
1388
|
).join('');
|
|
1202
1389
|
renderBlocks(changes.files);
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}).catch(() => {});
|
|
1390
|
+
// 먼저 fallback으로 빠르게 렌더, 이어서 AI로 업그레이드
|
|
1391
|
+
fetchAndRenderReport(camp.name);
|
|
1392
|
+
fetchAndRenderReport(camp.name, true);
|
|
1207
1393
|
}
|
|
1208
1394
|
|
|
1209
1395
|
// Actions — show commits as work history
|
|
@@ -1211,12 +1397,22 @@ function renderWorkspace(data) {
|
|
|
1211
1397
|
const commitList = data.commits || [];
|
|
1212
1398
|
if (commitList.length > 0) {
|
|
1213
1399
|
actionsEl.innerHTML = commitList.map(c =>
|
|
1214
|
-
`<
|
|
1215
|
-
<
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1400
|
+
`<details class="ws-commit-item" data-hash="${escHtml(c.hash)}">
|
|
1401
|
+
<summary class="ws-commit-summary">
|
|
1402
|
+
<span class="ws-commit-arrow">▶</span>
|
|
1403
|
+
<span class="ws-commit-msg">${escHtml(c.message)}</span>
|
|
1404
|
+
<span class="ws-commit-date">${escHtml(c.date)}</span>
|
|
1405
|
+
<button class="btn btn-ghost btn-sm ws-revert-btn" onclick="event.stopPropagation();event.preventDefault();revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
|
|
1406
|
+
</summary>
|
|
1407
|
+
<div class="ws-commit-report"></div>
|
|
1408
|
+
</details>`
|
|
1219
1409
|
).join('');
|
|
1410
|
+
// 펼칠 때 자동으로 리포트 로드
|
|
1411
|
+
actionsEl.querySelectorAll('.ws-commit-item').forEach(el => {
|
|
1412
|
+
el.addEventListener('toggle', function() {
|
|
1413
|
+
if (this.open) loadCommitReport(this, this.dataset.hash);
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1220
1416
|
} else if (changes.count > 0) {
|
|
1221
1417
|
actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
|
|
1222
1418
|
} else {
|
|
@@ -1426,15 +1622,57 @@ function updateQuestProgress(hasChanges, hasSaves) {
|
|
|
1426
1622
|
}
|
|
1427
1623
|
}
|
|
1428
1624
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1625
|
+
async function loadCommitReport(el, hash) {
|
|
1626
|
+
const reportEl = el.querySelector('.ws-commit-report');
|
|
1627
|
+
if (!reportEl || reportEl.dataset.loaded) return;
|
|
1628
|
+
|
|
1629
|
+
reportEl.innerHTML = '<div class="ws-commit-report-loading">불러오는 중...</div>';
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
const report = await api('GET', `/api/playgrounds/${currentWorkspace}/commit-report/${hash}?ai=true`);
|
|
1633
|
+
const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
|
|
1634
|
+
const details = report.categoryDetails || {};
|
|
1635
|
+
|
|
1636
|
+
if (report.totalCount === 0) {
|
|
1637
|
+
reportEl.innerHTML = '<div class="ws-commit-report-empty">변경 내용 없음</div>';
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
reportEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
|
|
1642
|
+
const items = details[cat];
|
|
1643
|
+
const hasDetails = items && items.length > 0;
|
|
1644
|
+
return `<div class="ws-commit-cat">
|
|
1645
|
+
<span class="ws-commit-cat-label">${categoryNames[cat] || cat}</span>
|
|
1646
|
+
${hasDetails
|
|
1647
|
+
? items.map((item, idx) => {
|
|
1648
|
+
const file = files[idx];
|
|
1649
|
+
const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
|
|
1650
|
+
return route
|
|
1651
|
+
? `<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>`
|
|
1652
|
+
: `<div class="ws-commit-cat-item">${escHtml(item)}</div>`;
|
|
1653
|
+
}).join('')
|
|
1654
|
+
: files.map(f => {
|
|
1655
|
+
const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
|
|
1656
|
+
const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
|
|
1657
|
+
return route
|
|
1658
|
+
? `<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>`
|
|
1659
|
+
: `<div class="ws-commit-cat-item">${label}</div>`;
|
|
1660
|
+
}).join('')
|
|
1661
|
+
}
|
|
1662
|
+
</div>`;
|
|
1663
|
+
}).join('');
|
|
1664
|
+
reportEl.dataset.loaded = 'true';
|
|
1665
|
+
} catch {
|
|
1666
|
+
reportEl.innerHTML = '<div class="ws-commit-report-empty">불러오기 실패</div>';
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
let reportFetchTimer = null;
|
|
1671
|
+
function debounceReportFetch(campName) {
|
|
1672
|
+
if (reportFetchTimer) clearTimeout(reportFetchTimer);
|
|
1673
|
+
reportFetchTimer = setTimeout(() => {
|
|
1674
|
+
fetchAndRenderReport(campName, true);
|
|
1675
|
+
}, 5000);
|
|
1438
1676
|
}
|
|
1439
1677
|
|
|
1440
1678
|
let previewRefreshTimer = null;
|
|
@@ -1515,15 +1753,21 @@ function renderBrowserErrors() {
|
|
|
1515
1753
|
const panel = document.getElementById('ws-browser-errors');
|
|
1516
1754
|
if (!panel) return;
|
|
1517
1755
|
const badge = document.getElementById('ws-browser-error-badge');
|
|
1756
|
+
const fixBtn = document.getElementById('ws-fix-btn');
|
|
1757
|
+
// Show fix button if there are browser errors OR server error logs
|
|
1758
|
+
const serverHasErrors = currentWorkspace && (logs.get(currentWorkspace) ?? [])
|
|
1759
|
+
.some(l => l.source !== 'frontend' && l.source !== 'task' && /error|ERR|ENOENT|ECONNREFUSED|TypeError|SyntaxError|Cannot find/i.test(l.text));
|
|
1518
1760
|
if (browserErrors.length === 0) {
|
|
1519
1761
|
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
|
|
1520
1762
|
if (badge) badge.style.display = 'none';
|
|
1763
|
+
if (fixBtn) fixBtn.style.display = serverHasErrors ? '' : 'none';
|
|
1521
1764
|
return;
|
|
1522
1765
|
}
|
|
1523
1766
|
if (badge) {
|
|
1524
1767
|
badge.style.display = '';
|
|
1525
1768
|
badge.textContent = browserErrors.length;
|
|
1526
1769
|
}
|
|
1770
|
+
if (fixBtn) fixBtn.style.display = '';
|
|
1527
1771
|
panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
|
|
1528
1772
|
const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
|
|
1529
1773
|
return `<div class="ws-browser-error-item">
|
|
@@ -1556,13 +1800,99 @@ window.revertCommit = async function revertCommit(hash) {
|
|
|
1556
1800
|
}
|
|
1557
1801
|
};
|
|
1558
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* Build a structured prompt from browser errors + server logs
|
|
1805
|
+
* that Claude Code can use to diagnose and fix the issue.
|
|
1806
|
+
*/
|
|
1807
|
+
window.copyFixPrompt = async function copyFixPrompt() {
|
|
1808
|
+
const name = currentWorkspace;
|
|
1809
|
+
if (!name) return;
|
|
1810
|
+
|
|
1811
|
+
const sections = [];
|
|
1812
|
+
|
|
1813
|
+
// 1. Browser errors
|
|
1814
|
+
if (browserErrors.length > 0) {
|
|
1815
|
+
const errs = browserErrors.slice(-10).map(e => {
|
|
1816
|
+
let line = `[${e.level}] ${e.message}`;
|
|
1817
|
+
if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
|
|
1818
|
+
return line;
|
|
1819
|
+
}).join('\n\n');
|
|
1820
|
+
sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// 2. Server logs (last 20 lines, stderr/error only)
|
|
1824
|
+
const serverLines = (logs.get(name) ?? [])
|
|
1825
|
+
.filter(l => l.source !== 'frontend' && l.source !== 'task')
|
|
1826
|
+
.slice(-20)
|
|
1827
|
+
.map(l => l.text.trim())
|
|
1828
|
+
.filter(Boolean);
|
|
1829
|
+
if (serverLines.length > 0) {
|
|
1830
|
+
sections.push(`## 서버 로그 (최근)\n\n${serverLines.join('\n')}`);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// 3. Current changes context
|
|
1834
|
+
try {
|
|
1835
|
+
const changes = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
1836
|
+
if (changes.files?.length > 0) {
|
|
1837
|
+
const fileList = changes.files.map(f => `- ${f.status} ${f.path}`).join('\n');
|
|
1838
|
+
sections.push(`## 현재 수정된 파일\n\n${fileList}`);
|
|
1839
|
+
}
|
|
1840
|
+
} catch { /* ignore */ }
|
|
1841
|
+
|
|
1842
|
+
if (sections.length === 0) {
|
|
1843
|
+
toast('복사할 에러가 없습니다', 'info');
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const prompt = `아래 에러를 분석하고 수정해줘.
|
|
1848
|
+
|
|
1849
|
+
에러를 읽고 근본 원인을 먼저 파악한 다음, 최소한의 변경으로 고쳐줘.
|
|
1850
|
+
추측하지 말고 에러 메시지와 스택 트레이스를 근거로 진단해.
|
|
1851
|
+
수정 후 관련 파일만 변경하고, 변경 이유를 간단히 설명해줘.
|
|
1852
|
+
|
|
1853
|
+
${sections.join('\n\n---\n\n')}`;
|
|
1854
|
+
|
|
1855
|
+
try {
|
|
1856
|
+
await navigator.clipboard.writeText(prompt);
|
|
1857
|
+
toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
|
|
1858
|
+
} catch {
|
|
1859
|
+
// Fallback for non-HTTPS
|
|
1860
|
+
const ta = document.createElement('textarea');
|
|
1861
|
+
ta.value = prompt;
|
|
1862
|
+
ta.style.cssText = 'position:fixed;left:-9999px';
|
|
1863
|
+
document.body.appendChild(ta);
|
|
1864
|
+
ta.select();
|
|
1865
|
+
document.execCommand('copy');
|
|
1866
|
+
ta.remove();
|
|
1867
|
+
toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1559
1871
|
function clearBrowserErrors() {
|
|
1560
1872
|
browserErrors.length = 0;
|
|
1561
1873
|
renderBrowserErrors();
|
|
1562
1874
|
}
|
|
1563
1875
|
|
|
1564
|
-
window.wsShip = function() {
|
|
1876
|
+
window.wsShip = async function() {
|
|
1565
1877
|
if (!currentWorkspace) return;
|
|
1878
|
+
// Fetch report for ship confirmation
|
|
1879
|
+
try {
|
|
1880
|
+
const report = await api('GET', `/api/playgrounds/${currentWorkspace}/change-report?ai=true`);
|
|
1881
|
+
const reportPreview = document.getElementById('ship-report-preview');
|
|
1882
|
+
if (reportPreview && report.totalCount > 0) {
|
|
1883
|
+
let html = '';
|
|
1884
|
+
if (report.humanDescription) {
|
|
1885
|
+
html += `<div class="ship-report-desc">${escHtml(report.humanDescription)}</div>`;
|
|
1886
|
+
}
|
|
1887
|
+
if (report.warnings.length > 0) {
|
|
1888
|
+
html += report.warnings.map(w =>
|
|
1889
|
+
`<div class="ws-report-warning"><span>⚠️</span> ${escHtml(w.message)}</div>`
|
|
1890
|
+
).join('');
|
|
1891
|
+
}
|
|
1892
|
+
reportPreview.innerHTML = html;
|
|
1893
|
+
reportPreview.style.display = html ? '' : 'none';
|
|
1894
|
+
}
|
|
1895
|
+
} catch { /* non-blocking */ }
|
|
1566
1896
|
openShipModal(currentWorkspace);
|
|
1567
1897
|
};
|
|
1568
1898
|
|
|
@@ -1641,6 +1971,48 @@ window.togglePanel = function() {
|
|
|
1641
1971
|
document.getElementById('ws-panel')?.classList.toggle('open');
|
|
1642
1972
|
};
|
|
1643
1973
|
|
|
1974
|
+
window.toggleCompare = async function() {
|
|
1975
|
+
const container = document.getElementById('ws-preview-container');
|
|
1976
|
+
const mainPreview = document.getElementById('ws-preview-main');
|
|
1977
|
+
const compareBtn = document.getElementById('ws-compare-btn');
|
|
1978
|
+
if (!container || !mainPreview) return;
|
|
1979
|
+
|
|
1980
|
+
compareMode = !compareMode;
|
|
1981
|
+
|
|
1982
|
+
if (compareMode) {
|
|
1983
|
+
compareBtn.classList.add('btn-active');
|
|
1984
|
+
mainPreview.classList.remove('hidden');
|
|
1985
|
+
container.classList.add('ws-split-view');
|
|
1986
|
+
|
|
1987
|
+
toast('원본 서버를 준비하고 있어요...', 'info');
|
|
1988
|
+
try {
|
|
1989
|
+
const state = await api('POST', '/api/compare/start');
|
|
1990
|
+
if (state.status === 'running' && state.port) {
|
|
1991
|
+
const proxyUrl = `/preview/${state.port}/`;
|
|
1992
|
+
mainPreview.innerHTML = `
|
|
1993
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
1994
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
|
|
1995
|
+
} else if (state.status === 'starting') {
|
|
1996
|
+
mainPreview.innerHTML = `
|
|
1997
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
1998
|
+
<div class="ws-preview-loading">준비 중...</div>`;
|
|
1999
|
+
} else {
|
|
2000
|
+
mainPreview.innerHTML = `
|
|
2001
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
2002
|
+
<div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
|
|
2003
|
+
}
|
|
2004
|
+
} catch {
|
|
2005
|
+
mainPreview.innerHTML = `
|
|
2006
|
+
<div class="ws-preview-label">🏔️ 원본 (main)</div>
|
|
2007
|
+
<div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
|
|
2008
|
+
}
|
|
2009
|
+
} else {
|
|
2010
|
+
compareBtn.classList.remove('btn-active');
|
|
2011
|
+
mainPreview.classList.add('hidden');
|
|
2012
|
+
container.classList.remove('ws-split-view');
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
1644
2016
|
// ---------------------------------------------------------------------------
|
|
1645
2017
|
// Init
|
|
1646
2018
|
// ---------------------------------------------------------------------------
|
|
@@ -2467,9 +2839,11 @@ async function loadActivityTrail() {
|
|
|
2467
2839
|
}
|
|
2468
2840
|
});
|
|
2469
2841
|
|
|
2470
|
-
// Tents on rest days
|
|
2842
|
+
// Tents on rest days — limit density when most days are rest days
|
|
2843
|
+
const activeDays = days.filter(d => d.commits > 0).length;
|
|
2844
|
+
const tentChance = activeDays < 5 ? 0.85 : 0.5; // fewer active days → fewer tents
|
|
2471
2845
|
heights.forEach((h, i) => {
|
|
2472
|
-
if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() >
|
|
2846
|
+
if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > tentChance) {
|
|
2473
2847
|
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
2474
2848
|
decorations += `
|
|
2475
2849
|
<rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
|
|
@@ -2485,9 +2859,9 @@ async function loadActivityTrail() {
|
|
|
2485
2859
|
if (datePrs && datePrs.length > 0) {
|
|
2486
2860
|
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
2487
2861
|
const h = heights[i];
|
|
2488
|
-
const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
|
|
2862
|
+
const tooltipText = datePrs.map(p => `#${p.number} ${escHtml(p.title)}`).join('\n');
|
|
2489
2863
|
prMarkers += `
|
|
2490
|
-
<g class="pr-marker" data-tooltip="${tooltipText
|
|
2864
|
+
<g class="pr-marker" data-tooltip="${escHtml(tooltipText)}">
|
|
2491
2865
|
<rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
2492
2866
|
<rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
2493
2867
|
<rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
|
|
@@ -2520,8 +2894,8 @@ async function loadActivityTrail() {
|
|
|
2520
2894
|
<polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
|
|
2521
2895
|
}
|
|
2522
2896
|
|
|
2523
|
-
// Sherpa at today (last position)
|
|
2524
|
-
const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
|
|
2897
|
+
// Sherpa at today (last position, clamped to SVG bounds)
|
|
2898
|
+
const lastX = Math.min(20 + (days.length - 1) * dayW + dayW / 2 - 4, svgW - 24);
|
|
2525
2899
|
const lastH = heights[heights.length - 1];
|
|
2526
2900
|
const sherpaY = lastH - 16;
|
|
2527
2901
|
const sherpa = `
|